dsh-webdav-sync 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +298 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +9 -0
- package/lib/client.js +1117 -0
- package/lib/crypto.d.ts +64 -0
- package/lib/crypto.js +185 -0
- package/lib/dav.d.ts +137 -0
- package/lib/dav.js +548 -0
- package/lib/engine.d.ts +100 -0
- package/lib/engine.js +1042 -0
- package/lib/index.d.ts +74 -0
- package/lib/index.js +329 -0
- package/lib/install.d.ts +28 -0
- package/lib/install.js +269 -0
- package/lib/paths.d.ts +57 -0
- package/lib/paths.js +79 -0
- package/lib/state.d.ts +93 -0
- package/lib/state.js +253 -0
- package/lib/store.d.ts +147 -0
- package/lib/store.js +560 -0
- package/lib/wire.d.ts +346 -0
- package/lib/wire.js +71 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +107 -3
package/lib/store.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and writing a DSH profile.
|
|
3
|
+
*
|
|
4
|
+
* Two files matter. `cordis.patch.yml` is the profile's own patch layer — a
|
|
5
|
+
* top-level YAML array of loader rows, each either an id-targeted override or an
|
|
6
|
+
* `insert:` block that pushes new rows in. The model catalog is one of those rows
|
|
7
|
+
* (`llm-pi-ai`), the default model is another (`agent-default-model`), and
|
|
8
|
+
* everything else — MCP servers, permission presets, UI settings, inserts — is
|
|
9
|
+
* the rest of them. `package.json` names the installed plugins.
|
|
10
|
+
*
|
|
11
|
+
* Writes go through the `yaml` Document API rather than a stringify of a parsed
|
|
12
|
+
* object, so the file's own comments and layout survive a sync that only meant to
|
|
13
|
+
* change one provider.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile, writeFile, rename, mkdir, stat, readdir, unlink, chmod } from 'node:fs/promises';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { parseDocument, isMap, isScalar, isSeq, Scalar, YAMLSeq } from 'yaml';
|
|
19
|
+
import { hashJson } from './crypto.js';
|
|
20
|
+
import { BACKUP_EXT, } from './wire.js';
|
|
21
|
+
/** The profile patch row holding the model catalog. */
|
|
22
|
+
export const MODELS_ID = 'llm-pi-ai';
|
|
23
|
+
/** The profile patch row holding the default model. */
|
|
24
|
+
export const DEFAULTS_ID = 'agent-default-model';
|
|
25
|
+
/** Package whose patch row the plugin adds for itself; never carried as a part. */
|
|
26
|
+
export const SELF_ID = 'dsh-webdav-sync';
|
|
27
|
+
/** Package names that are the DSH shell itself, not installed plugins. */
|
|
28
|
+
const BUILTIN_PREFIX = '@deepseek-ai/dsh-';
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Reading
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
/** Read and parse the profile patch. A missing file reads as an empty patch. */
|
|
33
|
+
export async function readPatch(paths) {
|
|
34
|
+
let text = '';
|
|
35
|
+
let mtimeMs = 0;
|
|
36
|
+
try {
|
|
37
|
+
text = await readFile(paths.patchFile, 'utf8');
|
|
38
|
+
mtimeMs = (await stat(paths.patchFile)).mtimeMs;
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
if (!isMissing(cause))
|
|
42
|
+
throw cause;
|
|
43
|
+
}
|
|
44
|
+
const document = parseDocument(text, { keepSourceTokens: false });
|
|
45
|
+
if (document.errors.length > 0) {
|
|
46
|
+
const first = document.errors[0];
|
|
47
|
+
throw new Error(`${paths.patchFile} 不是可读的 YAML:${first?.message ?? '未知错误'}`);
|
|
48
|
+
}
|
|
49
|
+
const contents = document.contents;
|
|
50
|
+
const rows = [];
|
|
51
|
+
if (isSeq(contents)) {
|
|
52
|
+
contents.items.forEach((item, index) => {
|
|
53
|
+
if (!isMap(item))
|
|
54
|
+
return;
|
|
55
|
+
const value = item.toJSON();
|
|
56
|
+
rows.push({ key: keyOf(value), value });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
else if (contents !== null && contents !== undefined) {
|
|
60
|
+
throw new Error(`${paths.patchFile} 的顶层必须是一串 patch 行`);
|
|
61
|
+
}
|
|
62
|
+
return { document, rows, text, mtimeMs };
|
|
63
|
+
}
|
|
64
|
+
/** Read the profile manifest. A missing file reads as an empty manifest. */
|
|
65
|
+
export async function readManifest(paths) {
|
|
66
|
+
let text = '';
|
|
67
|
+
let mtimeMs = 0;
|
|
68
|
+
try {
|
|
69
|
+
text = await readFile(paths.packageFile, 'utf8');
|
|
70
|
+
mtimeMs = (await stat(paths.packageFile)).mtimeMs;
|
|
71
|
+
}
|
|
72
|
+
catch (cause) {
|
|
73
|
+
if (!isMissing(cause))
|
|
74
|
+
throw cause;
|
|
75
|
+
return { value: {}, text: '', mtimeMs: 0 };
|
|
76
|
+
}
|
|
77
|
+
let value;
|
|
78
|
+
try {
|
|
79
|
+
value = JSON.parse(text);
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
throw new Error(`${paths.packageFile} 不是可读的 JSON:${message(cause)}`);
|
|
83
|
+
}
|
|
84
|
+
return { value, text, mtimeMs };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Gather the parts from one profile.
|
|
88
|
+
*
|
|
89
|
+
* A part is left out entirely when the profile holds nothing for it, so a
|
|
90
|
+
* computer that has never configured models never claims the model part — which
|
|
91
|
+
* is what lets the other computer's catalog come in untouched on a first sync.
|
|
92
|
+
*/
|
|
93
|
+
export function collect(patch, manifest) {
|
|
94
|
+
const out = {};
|
|
95
|
+
const modelsRow = patch.rows.find((row) => row.key === `id:${MODELS_ID}`);
|
|
96
|
+
if (modelsRow !== undefined) {
|
|
97
|
+
const config = modelsRow.value.config;
|
|
98
|
+
out.models = { config: isPlainObject(config) ? config : {} };
|
|
99
|
+
}
|
|
100
|
+
const defaultsRow = patch.rows.find((row) => row.key === `id:${DEFAULTS_ID}`);
|
|
101
|
+
if (defaultsRow !== undefined) {
|
|
102
|
+
const config = defaultsRow.value.config;
|
|
103
|
+
out.defaults = { config: isPlainObject(config) ? config : {} };
|
|
104
|
+
}
|
|
105
|
+
const dependencies = manifest.value.dependencies;
|
|
106
|
+
const bundles = readBundles(manifest.value);
|
|
107
|
+
if (isPlainObject(dependencies) || bundles.length > 0) {
|
|
108
|
+
out.plugins = {
|
|
109
|
+
dependencies: isPlainObject(dependencies) ? dependencies : {},
|
|
110
|
+
bundles,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const entries = patch.rows
|
|
114
|
+
.filter((row) => row.key !== `id:${MODELS_ID}` && row.key !== `id:${DEFAULTS_ID}` && row.key !== `id:${SELF_ID}`)
|
|
115
|
+
.map((row) => row.value);
|
|
116
|
+
if (entries.length > 0)
|
|
117
|
+
out.patch = { entries };
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** `dsh.profile.bundles` from a manifest, as strings. */
|
|
121
|
+
export function readBundles(manifest) {
|
|
122
|
+
const dsh = manifest.dsh;
|
|
123
|
+
if (!isPlainObject(dsh))
|
|
124
|
+
return [];
|
|
125
|
+
const profile = dsh.profile;
|
|
126
|
+
if (!isPlainObject(profile))
|
|
127
|
+
return [];
|
|
128
|
+
const bundles = profile.bundles;
|
|
129
|
+
if (!Array.isArray(bundles))
|
|
130
|
+
return [];
|
|
131
|
+
return bundles.filter((entry) => typeof entry === 'string');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The plugins a manifest declares that a set of installed package names does not
|
|
135
|
+
* have. Built-in shell packages are skipped: they ship with DSH, not with the
|
|
136
|
+
* profile.
|
|
137
|
+
*/
|
|
138
|
+
export function missingPlugins(plugins, installed) {
|
|
139
|
+
if (plugins === undefined)
|
|
140
|
+
return [];
|
|
141
|
+
const have = new Set(installed);
|
|
142
|
+
const wanted = new Set();
|
|
143
|
+
for (const name of Object.keys(plugins.dependencies))
|
|
144
|
+
wanted.add(name);
|
|
145
|
+
for (const name of plugins.bundles)
|
|
146
|
+
wanted.add(name);
|
|
147
|
+
return [...wanted].filter((name) => !have.has(name) && !name.startsWith(BUILTIN_PREFIX)).sort();
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Applying the parts
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
/**
|
|
153
|
+
* Rewrite the profile patch so it holds the merged parts.
|
|
154
|
+
*
|
|
155
|
+
* Model and default rows are replaced in place when they exist and appended when
|
|
156
|
+
* they do not; the remaining rows are replaced wholesale by the merged list, with
|
|
157
|
+
* the model rows kept where they were. A no-op merge leaves the bytes untouched,
|
|
158
|
+
* so a sync that changed nothing does not rewrite the file.
|
|
159
|
+
*/
|
|
160
|
+
export function applyPatch(patch, parts) {
|
|
161
|
+
const document = patch.document;
|
|
162
|
+
const contents = document.contents;
|
|
163
|
+
if (!isSeq(contents)) {
|
|
164
|
+
const rows = [];
|
|
165
|
+
if (parts.models !== undefined && parts.models !== null) {
|
|
166
|
+
rows.push({ id: MODELS_ID, name: '@deepseek-ai/dsh-llm-pi-ai', config: parts.models.config });
|
|
167
|
+
}
|
|
168
|
+
if (parts.defaults !== undefined && parts.defaults !== null) {
|
|
169
|
+
rows.push({ id: DEFAULTS_ID, name: '@deepseek-ai/dsh-agent-default-model', config: parts.defaults.config });
|
|
170
|
+
}
|
|
171
|
+
if (parts.patch !== undefined)
|
|
172
|
+
rows.push(...parts.patch.entries);
|
|
173
|
+
const replacement = document.createNode(rows);
|
|
174
|
+
document.contents = replacement;
|
|
175
|
+
return document.toString();
|
|
176
|
+
}
|
|
177
|
+
// What the document held before the merge, so a merge that decided nothing
|
|
178
|
+
// can hand back the original bytes. Re-serialising an untouched document is
|
|
179
|
+
// not byte-stable — YAML writes `[text, image]` back as `[ text, image ]` —
|
|
180
|
+
// and a sync that rewrote a person's file without changing anything in it
|
|
181
|
+
// would be a sync that lied about what it did.
|
|
182
|
+
const before = nodeJson(contents);
|
|
183
|
+
const byKey = new Map();
|
|
184
|
+
contents.items.forEach((item, index) => {
|
|
185
|
+
if (!isMap(item))
|
|
186
|
+
return;
|
|
187
|
+
const value = item.toJSON();
|
|
188
|
+
byKey.set(keyOf(value), { node: item, index });
|
|
189
|
+
});
|
|
190
|
+
// 1. Replace or append the two id-targeted model rows.
|
|
191
|
+
for (const [id, part] of [
|
|
192
|
+
[MODELS_ID, parts.models],
|
|
193
|
+
[DEFAULTS_ID, parts.defaults],
|
|
194
|
+
]) {
|
|
195
|
+
if (part === undefined)
|
|
196
|
+
continue;
|
|
197
|
+
const existing = byKey.get(`id:${id}`);
|
|
198
|
+
if (part === null) {
|
|
199
|
+
// The other computer took the row away, and that is the merge: a part that
|
|
200
|
+
// was removed there has to leave here, or the two never converge.
|
|
201
|
+
if (existing !== undefined) {
|
|
202
|
+
const at = contents.items.indexOf(existing.node);
|
|
203
|
+
if (at !== -1)
|
|
204
|
+
contents.items.splice(at, 1);
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (existing !== undefined) {
|
|
209
|
+
// Replace the config node only when it actually differs, and then merge
|
|
210
|
+
// into it rather than rebuilding: the catalog holds the user's own
|
|
211
|
+
// comments about their providers. `keepScalar` keeps the node itself.
|
|
212
|
+
//
|
|
213
|
+
// A hand-written row may carry no `config:` key at all — an id, a name and
|
|
214
|
+
// nothing else. There is then no node to merge into, and merging into an
|
|
215
|
+
// absent one hashed `undefined` and threw. The part's config arrives whole
|
|
216
|
+
// instead, and only when it has something in it, so a row that merely
|
|
217
|
+
// lacked the key does not gain an empty one.
|
|
218
|
+
const config = existing.node.has('config') ? existing.node.get('config', true) : undefined;
|
|
219
|
+
if (config === undefined) {
|
|
220
|
+
if (Object.keys(part.config).length > 0) {
|
|
221
|
+
existing.node.set('config', document.createNode(part.config));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else if (!sameJson(config, part.config)) {
|
|
225
|
+
existing.node.set('config', mergeInto(document, config, part.config));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const row = document.createNode({ id, name: defaultNameFor(id), config: part.config });
|
|
230
|
+
contents.items.push(row);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// 2. Replace the remaining rows with the merged list, leaving the model rows
|
|
234
|
+
// and this plugin's own row where they stand.
|
|
235
|
+
if (parts.patch !== undefined) {
|
|
236
|
+
const keep = [];
|
|
237
|
+
const wanted = parts.patch.entries;
|
|
238
|
+
const wantedByKey = new Map(wanted.map((entry, index) => [keyOf(entry), entry]));
|
|
239
|
+
const placed = new Set();
|
|
240
|
+
// Kept in the list so a row the plugin does not understand — a comment, a
|
|
241
|
+
// scalar, an `!!js` expression that parsed to a plain node — is not dropped
|
|
242
|
+
// from the profile by a sync that only meant to add a provider.
|
|
243
|
+
for (const item of contents.items) {
|
|
244
|
+
if (!isMap(item)) {
|
|
245
|
+
keep.push(item);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const value = item.toJSON();
|
|
249
|
+
const key = keyOf(value);
|
|
250
|
+
if (key === `id:${MODELS_ID}` || key === `id:${DEFAULTS_ID}` || key === `id:${SELF_ID}`) {
|
|
251
|
+
keep.push(item);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const wantedEntry = wantedByKey.get(key);
|
|
255
|
+
if (wantedEntry === undefined)
|
|
256
|
+
continue;
|
|
257
|
+
placed.add(key);
|
|
258
|
+
// Merge into the existing row when it is the same shape, so the row's own
|
|
259
|
+
// comments and quoting survive; rebuild only when it is a different thing.
|
|
260
|
+
keep.push(mergeInto(document, item, wantedEntry));
|
|
261
|
+
}
|
|
262
|
+
for (const entry of wanted) {
|
|
263
|
+
const key = keyOf(entry);
|
|
264
|
+
if (placed.has(key) || key === `id:${MODELS_ID}` || key === `id:${DEFAULTS_ID}` || key === `id:${SELF_ID}`)
|
|
265
|
+
continue;
|
|
266
|
+
keep.push(document.createNode(entry));
|
|
267
|
+
}
|
|
268
|
+
contents.items = keep;
|
|
269
|
+
}
|
|
270
|
+
// A merge that decided nothing hands back the file it was given. The document
|
|
271
|
+
// was re-serialised from its own nodes, and that alone is not byte-stable —
|
|
272
|
+
// YAML prints `[text, image]` back as `[ text, image ]` — so writing the
|
|
273
|
+
// result would rewrite a person's file without having changed anything in it.
|
|
274
|
+
if (sameJson(nodeJson(document.contents), before))
|
|
275
|
+
return patch.text;
|
|
276
|
+
return document.toString();
|
|
277
|
+
}
|
|
278
|
+
/** The loader `name` a row carries when this plugin has to create it. */
|
|
279
|
+
function defaultNameFor(id) {
|
|
280
|
+
return id === MODELS_ID ? '@deepseek-ai/dsh-llm-pi-ai' : '@deepseek-ai/dsh-agent-default-model';
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Rewrite the profile manifest with merged plugin declarations, two-space
|
|
284
|
+
* indented.
|
|
285
|
+
*
|
|
286
|
+
* The declaration is mirrored rather than merged: a plugin another computer
|
|
287
|
+
* removed goes from here too, or the two manifests never converge and every sync
|
|
288
|
+
* would push again. Nothing is uninstalled — `node_modules` is not synced — so
|
|
289
|
+
* what leaves is a line in a file, not a package.
|
|
290
|
+
*/
|
|
291
|
+
export function applyManifest(manifest, plugins) {
|
|
292
|
+
const value = manifest.value;
|
|
293
|
+
value.dependencies = sortKeys(plugins.dependencies);
|
|
294
|
+
const dsh = isPlainObject(value.dsh) ? { ...value.dsh } : {};
|
|
295
|
+
const profile = isPlainObject(dsh.profile) ? { ...dsh.profile } : {};
|
|
296
|
+
profile.bundles = plugins.bundles;
|
|
297
|
+
dsh.profile = profile;
|
|
298
|
+
value.dsh = dsh;
|
|
299
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Writing
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
/** Write bytes only when they differ from what is already there. */
|
|
305
|
+
export async function writeIfChanged(file, data) {
|
|
306
|
+
let previous;
|
|
307
|
+
try {
|
|
308
|
+
previous = await readFile(file);
|
|
309
|
+
}
|
|
310
|
+
catch (cause) {
|
|
311
|
+
if (!isMissing(cause))
|
|
312
|
+
throw cause;
|
|
313
|
+
previous = Buffer.alloc(0);
|
|
314
|
+
}
|
|
315
|
+
const next = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data);
|
|
316
|
+
if (previous.equals(next))
|
|
317
|
+
return false;
|
|
318
|
+
await writeAtomic(file, next);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Write a file by writing a sibling and renaming it over the target, so a crash
|
|
323
|
+
* or a power cut leaves either the old file or the new one and never half of
|
|
324
|
+
* either. The temporary file is removed when the write fails.
|
|
325
|
+
*/
|
|
326
|
+
export async function writeAtomic(file, data, mode) {
|
|
327
|
+
await mkdir(dirname(file), { recursive: true });
|
|
328
|
+
const temporary = join(dirname(file), `.${basenameOf(file)}.${randomBytes(6).toString('hex')}.tmp`);
|
|
329
|
+
const body = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data);
|
|
330
|
+
try {
|
|
331
|
+
await writeFile(temporary, body, mode === undefined ? undefined : { mode });
|
|
332
|
+
if (mode !== undefined)
|
|
333
|
+
await chmod(temporary, mode);
|
|
334
|
+
await rename(temporary, file);
|
|
335
|
+
}
|
|
336
|
+
catch (cause) {
|
|
337
|
+
await unlink(temporary).catch(() => undefined);
|
|
338
|
+
throw cause;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The last stamp `keepAside` handed out.
|
|
343
|
+
*
|
|
344
|
+
* Milliseconds are part of the name on purpose: two copies kept in the same
|
|
345
|
+
* second would otherwise land on one filename, and the second would silently
|
|
346
|
+
* destroy the first — which is the copy an undo points at. A millisecond is not
|
|
347
|
+
* a guarantee either (eight copies land inside one on a fast filesystem), so the
|
|
348
|
+
* stamp never repeats and never goes backwards: a call that would reuse the
|
|
349
|
+
* previous millisecond takes the next one instead.
|
|
350
|
+
*/
|
|
351
|
+
let lastAsideAt = 0;
|
|
352
|
+
/** `<YYYYMMDD-HHMMSSmmm>` in UTC: the chronological prefix a kept copy is named with. */
|
|
353
|
+
function asideStamp(at) {
|
|
354
|
+
const moment = new Date(at);
|
|
355
|
+
const seconds = moment.toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-');
|
|
356
|
+
return `${seconds}${String(moment.getUTCMilliseconds()).padStart(3, '0')}`;
|
|
357
|
+
}
|
|
358
|
+
/** Whether something is already at `file`. */
|
|
359
|
+
async function fileExists(file) {
|
|
360
|
+
try {
|
|
361
|
+
await stat(file);
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Keep a copy of something the plugin is about to replace, in `sync/` beside its
|
|
370
|
+
* own state. The newest `keep` files matching the tag survive.
|
|
371
|
+
*/
|
|
372
|
+
export async function keepAside(dir, tag, data, keep = 5) {
|
|
373
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
374
|
+
let file;
|
|
375
|
+
do {
|
|
376
|
+
lastAsideAt = Math.max(Date.now(), lastAsideAt + 1);
|
|
377
|
+
file = join(dir, `${asideStamp(lastAsideAt)}-${tag}${BACKUP_EXT}`);
|
|
378
|
+
// A name already on disk is a millisecond an earlier run used, or one a clock
|
|
379
|
+
// that stepped backwards repeated: take the next one rather than write over a
|
|
380
|
+
// copy an undo may point at.
|
|
381
|
+
} while (await fileExists(file));
|
|
382
|
+
await writeAtomic(file, data, 0o600);
|
|
383
|
+
const names = (await readdir(dir).catch(() => []))
|
|
384
|
+
.filter((name) => name.endsWith(`-${tag}${BACKUP_EXT}`))
|
|
385
|
+
.sort();
|
|
386
|
+
for (const stale of names.slice(0, Math.max(0, names.length - keep))) {
|
|
387
|
+
await unlink(join(dir, stale)).catch(() => undefined);
|
|
388
|
+
}
|
|
389
|
+
return file;
|
|
390
|
+
}
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Small helpers
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
/**
|
|
395
|
+
* A patch row's stable key.
|
|
396
|
+
*
|
|
397
|
+
* An `insert:` block is keyed by the ids of the rows it inserts, which is what
|
|
398
|
+
* makes it the same block on two computers that wrote it separately. An
|
|
399
|
+
* insert block whose rows carry no ids has no such name, and keying it by its
|
|
400
|
+
* position of `?`s would give every such block the one key `insert:?` — two
|
|
401
|
+
* different blocks would be read as one row, and a merge would write one
|
|
402
|
+
* block's contents into the other. Those are keyed by their own content
|
|
403
|
+
* instead, exactly like any other row that has neither id nor usable insert.
|
|
404
|
+
*/
|
|
405
|
+
export function keyOf(entry) {
|
|
406
|
+
if (typeof entry.id === 'string' && entry.id !== '')
|
|
407
|
+
return `id:${entry.id}`;
|
|
408
|
+
if (Array.isArray(entry.insert)) {
|
|
409
|
+
const ids = entry.insert.map((row) => isPlainObject(row) && typeof row.id === 'string' && row.id !== ''
|
|
410
|
+
? row.id
|
|
411
|
+
: undefined);
|
|
412
|
+
if (ids.every((id) => id !== undefined))
|
|
413
|
+
return `insert:${ids.join(',')}`;
|
|
414
|
+
}
|
|
415
|
+
// A patch row normally carries an id or an insert list of id-carrying rows.
|
|
416
|
+
// Anything else is keyed by its own content so it still survives a round trip
|
|
417
|
+
// rather than colliding with every other anonymous row.
|
|
418
|
+
return `row:${hashJson(entry)}`;
|
|
419
|
+
}
|
|
420
|
+
/** `true` for a value that is a plain object rather than an array or null. */
|
|
421
|
+
export function isPlainObject(value) {
|
|
422
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Whether two values from a YAML document hold the same data.
|
|
426
|
+
*
|
|
427
|
+
* A node read back out of a Document is not a plain object, so this normalises
|
|
428
|
+
* through JSON before comparing. It is what lets a sync tell "the merge decided
|
|
429
|
+
* the same thing" from "the merge decided something new", and therefore skip a
|
|
430
|
+
* write that would only reformat the file.
|
|
431
|
+
*/
|
|
432
|
+
export function sameJson(a, b) {
|
|
433
|
+
return hashJson(nodeJson(a)) === hashJson(nodeJson(b));
|
|
434
|
+
}
|
|
435
|
+
/** A YAML node's plain value, or the value itself when it is already plain. */
|
|
436
|
+
function nodeJson(value) {
|
|
437
|
+
if (isScalar(value))
|
|
438
|
+
return value.value;
|
|
439
|
+
if (isMap(value) || isSeq(value))
|
|
440
|
+
return value.toJSON();
|
|
441
|
+
return value;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Write `value` into `node`, keeping every comment and quote that is still
|
|
445
|
+
* describing the same thing.
|
|
446
|
+
*
|
|
447
|
+
* A sync usually changes one key — a model id, an effort level — and the file
|
|
448
|
+
* around it is a person's own work: their comments, their ordering, their
|
|
449
|
+
* quoting. Rebuilding the row from plain JSON would lose all of it, so a map
|
|
450
|
+
* whose keys are unchanged in name and order is walked key by key, and a scalar
|
|
451
|
+
* that only changed value is written onto the node it already sits on. Anything
|
|
452
|
+
* whose shape changed enough that "the same node" is no longer meaningful is
|
|
453
|
+
* rebuilt.
|
|
454
|
+
*/
|
|
455
|
+
function mergeInto(document, node, value) {
|
|
456
|
+
// Nothing to do when the node already says exactly this. Returning it
|
|
457
|
+
// untouched is what keeps a sync that decided nothing from even re-printing
|
|
458
|
+
// the document, let alone moving a comment.
|
|
459
|
+
if (sameJson(nodeJson(node), value))
|
|
460
|
+
return node;
|
|
461
|
+
if (isMap(node) && isPlainObject(value)) {
|
|
462
|
+
const keys = Object.keys(value);
|
|
463
|
+
const wanted = new Set(keys);
|
|
464
|
+
// Drop keys the merged row no longer carries — a plugin removed on another
|
|
465
|
+
// computer has to leave, not linger.
|
|
466
|
+
for (const pair of [...node.items]) {
|
|
467
|
+
const key = isScalar(pair.key) ? String(pair.key.value) : '';
|
|
468
|
+
if (key !== '' && !wanted.has(key))
|
|
469
|
+
node.delete(key);
|
|
470
|
+
}
|
|
471
|
+
for (const key of keys) {
|
|
472
|
+
// `keepScalar` is load-bearing: a plain `get` unwraps a Scalar to its
|
|
473
|
+
// value, and the comment lives on the Scalar, so merging into the unwrapped
|
|
474
|
+
// value would rebuild the node and quietly drop the comment with it.
|
|
475
|
+
const existing = node.has(key) ? node.get(key, true) : undefined;
|
|
476
|
+
// A key the node never had is added whole; a key it has is walked, so the
|
|
477
|
+
// comments attached to whatever did not change stay attached.
|
|
478
|
+
node.set(key, existing === undefined ? document.createNode(value[key]) : mergeInto(document, existing, value[key]));
|
|
479
|
+
}
|
|
480
|
+
return node;
|
|
481
|
+
}
|
|
482
|
+
if (isSeq(node) && Array.isArray(value)) {
|
|
483
|
+
// Per index, so a model added to the end keeps every earlier model's own
|
|
484
|
+
// comments; anything longer than the merged list is dropped.
|
|
485
|
+
const shared = Math.min(node.items.length, value.length);
|
|
486
|
+
for (let index = 0; index < shared; index += 1) {
|
|
487
|
+
node.items[index] = mergeInto(document, node.items[index], value[index]);
|
|
488
|
+
}
|
|
489
|
+
for (let index = shared; index < value.length; index += 1) {
|
|
490
|
+
node.items.push(document.createNode(value[index]));
|
|
491
|
+
}
|
|
492
|
+
if (node.items.length > value.length)
|
|
493
|
+
node.items.length = value.length;
|
|
494
|
+
return node;
|
|
495
|
+
}
|
|
496
|
+
if (isScalar(node) && (value === null || typeof value !== 'object')) {
|
|
497
|
+
if (sameJson(node.value, value))
|
|
498
|
+
return node;
|
|
499
|
+
// Written onto the existing Scalar so its own comment stays attached.
|
|
500
|
+
node.value = value;
|
|
501
|
+
return node;
|
|
502
|
+
}
|
|
503
|
+
return document.createNode(value);
|
|
504
|
+
}
|
|
505
|
+
/** An object's keys in sorted order, so a rewritten manifest is stable. */
|
|
506
|
+
export function sortKeys(value) {
|
|
507
|
+
const out = {};
|
|
508
|
+
for (const key of Object.keys(value).sort())
|
|
509
|
+
out[key] = value[key];
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
/** A filesystem error meaning "there is nothing there". */
|
|
513
|
+
export function isMissing(cause) {
|
|
514
|
+
return isPlainObject(cause) && cause.code === 'ENOENT';
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* An error's message, said in one line, with whatever it was caused by.
|
|
518
|
+
*
|
|
519
|
+
* 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
|
|
521
|
+
* says `ECONNREFUSED` or the parse error, and that is what a reader needs. The
|
|
522
|
+
* chain is followed rather than only read at the top, since the ends that were
|
|
523
|
+
* wrapped twice on the way up are the ones with the reason in them. The walk is
|
|
524
|
+
* bounded and watches for repeats, because a cause pointing back at itself would
|
|
525
|
+
* otherwise turn one sentence into a hang.
|
|
526
|
+
*/
|
|
527
|
+
export function message(cause) {
|
|
528
|
+
const seen = new Set();
|
|
529
|
+
const said = [];
|
|
530
|
+
let current = cause;
|
|
531
|
+
while (current !== undefined && current !== null && !seen.has(current) && said.length < 5) {
|
|
532
|
+
seen.add(current);
|
|
533
|
+
if (current instanceof Error) {
|
|
534
|
+
if (current.message !== '')
|
|
535
|
+
said.push(current.message);
|
|
536
|
+
current = current.cause;
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
said.push(String(current));
|
|
540
|
+
current = undefined;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (said.length === 0)
|
|
544
|
+
return String(cause);
|
|
545
|
+
// A cause that only repeats what its wrapper said adds nothing.
|
|
546
|
+
const parts = [];
|
|
547
|
+
for (const part of said)
|
|
548
|
+
if (!parts.includes(part))
|
|
549
|
+
parts.push(part);
|
|
550
|
+
return parts.join(' — ');
|
|
551
|
+
}
|
|
552
|
+
/** A path's final segment. */
|
|
553
|
+
function basenameOf(file) {
|
|
554
|
+
const cut = Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\'));
|
|
555
|
+
return cut === -1 ? file : file.slice(cut + 1);
|
|
556
|
+
}
|
|
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;
|