@lolkda/dsh-prompt-manager 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +606 -0
  3. package/client/client.js +2320 -0
  4. package/cordis.patch.yml +20 -0
  5. package/environment.md +24 -0
  6. package/lib/entries.js +303 -0
  7. package/lib/entries.js.map +1 -0
  8. package/lib/guard.js +134 -0
  9. package/lib/guard.js.map +1 -0
  10. package/lib/index.js +959 -0
  11. package/lib/index.js.map +1 -0
  12. package/lib/net.js +179 -0
  13. package/lib/net.js.map +1 -0
  14. package/lib/pack.js +327 -0
  15. package/lib/pack.js.map +1 -0
  16. package/lib/probe.js +251 -0
  17. package/lib/probe.js.map +1 -0
  18. package/lib/routes.js +718 -0
  19. package/lib/routes.js.map +1 -0
  20. package/lib/scripts.js +803 -0
  21. package/lib/scripts.js.map +1 -0
  22. package/lib/source.js +308 -0
  23. package/lib/source.js.map +1 -0
  24. package/lib/store.js +223 -0
  25. package/lib/store.js.map +1 -0
  26. package/lib/subscriptions.js +269 -0
  27. package/lib/subscriptions.js.map +1 -0
  28. package/lib/sync.js +646 -0
  29. package/lib/sync.js.map +1 -0
  30. package/lib/types/entries.d.ts +194 -0
  31. package/lib/types/entries.d.ts.map +1 -0
  32. package/lib/types/guard.d.ts +63 -0
  33. package/lib/types/guard.d.ts.map +1 -0
  34. package/lib/types/index.d.ts +176 -0
  35. package/lib/types/index.d.ts.map +1 -0
  36. package/lib/types/net.d.ts +81 -0
  37. package/lib/types/net.d.ts.map +1 -0
  38. package/lib/types/pack.d.ts +298 -0
  39. package/lib/types/pack.d.ts.map +1 -0
  40. package/lib/types/probe.d.ts +150 -0
  41. package/lib/types/probe.d.ts.map +1 -0
  42. package/lib/types/routes.d.ts +85 -0
  43. package/lib/types/routes.d.ts.map +1 -0
  44. package/lib/types/scripts.d.ts +455 -0
  45. package/lib/types/scripts.d.ts.map +1 -0
  46. package/lib/types/source.d.ts +194 -0
  47. package/lib/types/source.d.ts.map +1 -0
  48. package/lib/types/store.d.ts +140 -0
  49. package/lib/types/store.d.ts.map +1 -0
  50. package/lib/types/subscriptions.d.ts +204 -0
  51. package/lib/types/subscriptions.d.ts.map +1 -0
  52. package/lib/types/sync.d.ts +248 -0
  53. package/lib/types/sync.d.ts.map +1 -0
  54. package/package.json +100 -0
package/lib/sync.js ADDED
@@ -0,0 +1,646 @@
1
+ /**
2
+ * One source's files on disk, and the three operations the settings page drives:
3
+ * check what changed upstream, apply it, undo the last apply.
4
+ *
5
+ * The layout is a three-slot rotation, so nothing is ever half-updated:
6
+ * `staging/` holds what a check downloaded, `current/` is what the prompt reads,
7
+ * and `previous/` keeps the version the last apply replaced so one click can put
8
+ * it back. `state.json` carries the bookkeeping: which commit is in force, which
9
+ * entry each file became, and the hashes and validators that make the next check
10
+ * cheap.
11
+ *
12
+ * @module @lolkda/dsh-prompt-manager/sync
13
+ */
14
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import { bodyHash } from './store.js';
17
+ import { looksLikeHtml } from './net.js';
18
+ import { entryIdFor, entryIdForPrompt, headAtomUrl, isMovableRef, MANIFEST_FILE, MAX_FILE_BYTES, parseHeadSha, parseManifest, rawUrl, stemOf, } from './source.js';
19
+ /** A check that could not conclude. */
20
+ export class CheckError extends Error {
21
+ /** Machine-readable reason. */
22
+ reason;
23
+ /**
24
+ * @param reason - machine-readable reason.
25
+ * @param message - human-facing detail.
26
+ */
27
+ constructor(reason, message) {
28
+ super(message);
29
+ this.name = 'CheckError';
30
+ this.reason = reason;
31
+ }
32
+ }
33
+ /** The files of one source, confined to one directory. */
34
+ export class SourceWorkspace {
35
+ /** Source slug. */
36
+ slug;
37
+ /** `<root>/sources/<slug>`. */
38
+ dir;
39
+ /**
40
+ * @param root - the plugin's storage root, e.g. `$DSH_HOME/prompt-manager`.
41
+ * @param slug - the source id.
42
+ */
43
+ constructor(root, slug) {
44
+ this.slug = slug;
45
+ this.dir = resolve(root, 'sources', slug);
46
+ }
47
+ /** Directory holding the version in force. */
48
+ get currentDir() {
49
+ return join(this.dir, 'current');
50
+ }
51
+ /** Directory holding the version the last apply replaced. */
52
+ get previousDir() {
53
+ return join(this.dir, 'previous');
54
+ }
55
+ /** Directory holding what a check downloaded but nobody applied yet. */
56
+ get stagingDir() {
57
+ return join(this.dir, 'staging');
58
+ }
59
+ /** Path of the bookkeeping file. */
60
+ get statePath() {
61
+ return join(this.dir, 'state.json');
62
+ }
63
+ /**
64
+ * Absolute path of one body file inside one slot.
65
+ * @param slot - which slot.
66
+ * @param path - repository-relative manifest path.
67
+ * @returns the absolute path; a traversal attempt resolves to `undefined`.
68
+ */
69
+ slotPath(slot, path) {
70
+ const base = slot === 'current' ? this.currentDir : slot === 'previous' ? this.previousDir : this.stagingDir;
71
+ const file = resolve(base, path);
72
+ const prefix = `${resolve(base)}${process.platform === 'win32' ? '\\' : '/'}`;
73
+ return file.startsWith(prefix) ? file : undefined;
74
+ }
75
+ /** Whether this source has any files on disk. */
76
+ exists() {
77
+ return existsSync(this.dir);
78
+ }
79
+ /**
80
+ * Read the bookkeeping, defaulting to an empty state.
81
+ * @returns the state; an unreadable or malformed file reads as empty.
82
+ */
83
+ readState() {
84
+ if (!existsSync(this.statePath))
85
+ return { ref: '', files: {} };
86
+ try {
87
+ const parsed = JSON.parse(readFileSync(this.statePath, 'utf8'));
88
+ if (typeof parsed !== 'object' || parsed === null)
89
+ return { ref: '', files: {} };
90
+ const record = parsed;
91
+ const files = typeof record['files'] === 'object' && record['files'] !== null && !Array.isArray(record['files'])
92
+ ? record['files']
93
+ : {};
94
+ const state = { ref: typeof record['ref'] === 'string' ? record['ref'] : '', files };
95
+ if (typeof record['headSha'] === 'string')
96
+ state.headSha = record['headSha'];
97
+ if (typeof record['headShaPrevious'] === 'string')
98
+ state.headShaPrevious = record['headShaPrevious'];
99
+ if (typeof record['manifestSha1'] === 'string')
100
+ state.manifestSha1 = record['manifestSha1'];
101
+ if (typeof record['appliedAt'] === 'string')
102
+ state.appliedAt = record['appliedAt'];
103
+ if (typeof record['undo'] === 'object' && record['undo'] !== null && !Array.isArray(record['undo'])) {
104
+ state.undo = record['undo'];
105
+ }
106
+ return state;
107
+ }
108
+ catch {
109
+ return { ref: '', files: {} };
110
+ }
111
+ }
112
+ /**
113
+ * Persist the bookkeeping.
114
+ * @param state - the state to write.
115
+ */
116
+ writeState(state) {
117
+ mkdirSync(this.dir, { recursive: true });
118
+ writeFileSync(this.statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
119
+ }
120
+ /** Read one body from a slot. */
121
+ read(slot, path) {
122
+ const file = this.slotPath(slot, path);
123
+ if (file === undefined || !existsSync(file))
124
+ return undefined;
125
+ try {
126
+ return readFileSync(file, 'utf8');
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ }
132
+ /** Paths present in a slot, relative to that slot. */
133
+ list(slot) {
134
+ const base = slot === 'current' ? this.currentDir : slot === 'previous' ? this.previousDir : this.stagingDir;
135
+ if (!existsSync(base))
136
+ return [];
137
+ const found = [];
138
+ const walk = (dir, prefix) => {
139
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
140
+ const relative = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`;
141
+ if (entry.isDirectory())
142
+ walk(join(dir, entry.name), relative);
143
+ else if (entry.name.endsWith('.md'))
144
+ found.push(relative);
145
+ }
146
+ };
147
+ walk(base, '');
148
+ return found.sort();
149
+ }
150
+ /** Write one body into the staging slot. */
151
+ stage(path, text) {
152
+ const file = this.slotPath('staging', path);
153
+ if (file === undefined)
154
+ throw new CheckError('manifest', `refusing to stage outside the source: ${path}`);
155
+ mkdirSync(dirname(file), { recursive: true });
156
+ writeFileSync(file, text, 'utf8');
157
+ }
158
+ /** Empty the staging slot. */
159
+ clearStaging() {
160
+ rmSync(this.stagingDir, { recursive: true, force: true });
161
+ }
162
+ /**
163
+ * Record what a check staged, so an apply can run without repeating the
164
+ * network round trip.
165
+ * @param plan - the staged plan.
166
+ */
167
+ writePlan(plan) {
168
+ mkdirSync(this.stagingDir, { recursive: true });
169
+ writeFileSync(join(this.stagingDir, 'plan.json'), `${JSON.stringify(plan, null, 2)}\n`, 'utf8');
170
+ }
171
+ /**
172
+ * Read the staged plan.
173
+ * @returns the plan, or `undefined` when nothing is staged.
174
+ */
175
+ readPlan() {
176
+ const file = join(this.stagingDir, 'plan.json');
177
+ if (!existsSync(file))
178
+ return undefined;
179
+ try {
180
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
181
+ if (typeof parsed !== 'object' || parsed === null)
182
+ return undefined;
183
+ const record = parsed;
184
+ if (!Array.isArray(record['changes']) || !Array.isArray(record['prompts']))
185
+ return undefined;
186
+ const plan = {
187
+ changes: record['changes'],
188
+ prompts: record['prompts'],
189
+ manifestSha1: typeof record['manifestSha1'] === 'string' ? record['manifestSha1'] : '',
190
+ etags: typeof record['etags'] === 'object' && record['etags'] !== null && !Array.isArray(record['etags'])
191
+ ? record['etags']
192
+ : {},
193
+ };
194
+ if (typeof record['headSha'] === 'string')
195
+ plan.headSha = record['headSha'];
196
+ return plan;
197
+ }
198
+ catch {
199
+ return undefined;
200
+ }
201
+ }
202
+ /** Remove the whole source directory. */
203
+ remove() {
204
+ rmSync(this.dir, { recursive: true, force: true });
205
+ }
206
+ }
207
+ /**
208
+ * Line counts of what a change adds and removes, by longest common
209
+ * subsequence over lines. The dynamic table is skipped for pathologically large
210
+ * pairs, where the answer degrades to "roughly how many lines differ" rather
211
+ * than stalling the request.
212
+ *
213
+ * @param before - the body in force, or `''` for a new file.
214
+ * @param after - the checked body.
215
+ * @returns added and removed line counts.
216
+ */
217
+ export function diffCounts(before, after) {
218
+ const left = before.length === 0 ? [] : before.split('\n');
219
+ const right = after.length === 0 ? [] : after.split('\n');
220
+ let head = 0;
221
+ while (head < left.length && head < right.length && left[head] === right[head])
222
+ head += 1;
223
+ let tail = 0;
224
+ while (tail < left.length - head
225
+ && tail < right.length - head
226
+ && left[left.length - 1 - tail] === right[right.length - 1 - tail])
227
+ tail += 1;
228
+ const a = left.slice(head, left.length - tail);
229
+ const b = right.slice(head, right.length - tail);
230
+ if (a.length === 0 && b.length === 0)
231
+ return { added: 0, removed: 0 };
232
+ if (a.length === 0)
233
+ return { added: b.length, removed: 0 };
234
+ if (b.length === 0)
235
+ return { added: 0, removed: a.length };
236
+ const common = a.length * b.length > 250_000 ? Math.min(a.length, b.length) : lcsLength(a, b);
237
+ return { added: b.length - common, removed: a.length - common };
238
+ }
239
+ /**
240
+ * Longest common subsequence length over two line arrays.
241
+ * @param a - first sequence.
242
+ * @param b - second sequence.
243
+ * @returns the length of the longest common subsequence.
244
+ */
245
+ function lcsLength(a, b) {
246
+ let previous = new Array(b.length + 1).fill(0);
247
+ for (let i = 1; i <= a.length; i += 1) {
248
+ const current = new Array(b.length + 1).fill(0);
249
+ for (let j = 1; j <= b.length; j += 1) {
250
+ current[j] = a[i - 1] === b[j - 1]
251
+ ? (previous[j - 1] ?? 0) + 1
252
+ : Math.max(previous[j] ?? 0, current[j - 1] ?? 0);
253
+ }
254
+ previous = current;
255
+ }
256
+ return previous[b.length] ?? 0;
257
+ }
258
+ /**
259
+ * Check one source against its upstream and stage whatever changed.
260
+ *
261
+ * @param input - source, workspace, and the fetcher carrying the proxy and mirror.
262
+ * @returns what changed, or that nothing did.
263
+ * @throws {CheckError} when the check cannot conclude.
264
+ */
265
+ export async function checkSource(input) {
266
+ const { source, workspace, fetcher } = input;
267
+ const state = workspace.readState();
268
+ const warnings = [];
269
+ let headSha;
270
+ if (isMovableRef(source.ref)) {
271
+ try {
272
+ const feed = await fetcher.get(headAtomUrl(source.repo, source.ref), { timeoutMs: 8000 });
273
+ if (feed.status === 200)
274
+ headSha = parseHeadSha(feed.text);
275
+ if (headSha === undefined)
276
+ warnings.push('commits 源里没有读出 commit sha,改用逐文件比对');
277
+ }
278
+ catch (error) {
279
+ warnings.push(`commit 探测失败,改用逐文件比对:${error instanceof Error ? error.message : String(error)}`);
280
+ }
281
+ if (headSha !== undefined && headSha === state.headSha) {
282
+ return { upToDate: true, headSha, changes: [], prompts: [], warnings };
283
+ }
284
+ }
285
+ const manifestResponse = await fetchOrThrow(fetcher, rawUrl(source.repo, source.ref, MANIFEST_FILE));
286
+ if (manifestResponse.status === 404) {
287
+ throw new CheckError('manifest', `仓库里没有 ${MANIFEST_FILE}(在 ${source.repo}@${source.ref} 的根目录)`);
288
+ }
289
+ const manifestSha1 = bodyHash(manifestResponse.text);
290
+ let prompts;
291
+ try {
292
+ prompts = parseManifest(JSON.parse(manifestResponse.text));
293
+ }
294
+ catch (error) {
295
+ throw new CheckError('manifest', error instanceof Error ? error.message : String(error));
296
+ }
297
+ workspace.clearStaging();
298
+ const changes = [];
299
+ const etags = {};
300
+ const wanted = new Set(prompts.map((prompt) => prompt.file));
301
+ /**
302
+ * Paths upstream dropped, with the id and body each one carried.
303
+ *
304
+ * Computed before the loop rather than after it because the loop needs them:
305
+ * a file that is new here and gone there may be the same prompt under a new
306
+ * name, and the only way to tell is to have both sides in hand at once.
307
+ */
308
+ const removals = Object.keys(state.files)
309
+ .filter((path) => !wanted.has(path))
310
+ .map((path) => {
311
+ const record = state.files[path];
312
+ return {
313
+ path,
314
+ id: record?.id ?? entryIdFor(source.id, path),
315
+ sha1: record?.sha1 ?? bodyHash(workspace.read('current', path) ?? ''),
316
+ };
317
+ });
318
+ /** New files whose body might be a renamed copy of something above. */
319
+ const orphans = [];
320
+ for (const prompt of prompts) {
321
+ const known = state.files[prompt.file];
322
+ // Identity, in order of who knows best. A declared `id` is the manifest
323
+ // saying what this file *is*, so it outranks the name the file happens to
324
+ // have; what this path already became outranks the derivation, so a file that
325
+ // keeps its path keeps its id even when the two disagree. A path this machine
326
+ // has never seen is the only case a rename can hide in, and the hash below is
327
+ // what looks there.
328
+ const declared = entryIdForPrompt(source.id, prompt);
329
+ const identity = known !== undefined && prompt.id === undefined ? known.id : declared;
330
+ const identityMoved = known !== undefined && known.id !== identity;
331
+ // A conditional request is only worth sending when the copy it would
332
+ // validate is on disk. With the body file gone — deleted by hand, or lost
333
+ // between two applies — a 304 would answer with no content at all, and
334
+ // staging that empty answer would silently blank the entry for good. So the
335
+ // validator is dropped and the file is fetched in full instead.
336
+ const local = workspace.read('current', prompt.file);
337
+ const response = await fetchOrThrow(fetcher, rawUrl(source.repo, source.ref, prompt.file), local === undefined ? undefined : known?.etag);
338
+ if (response.status === 404) {
339
+ warnings.push(`${prompt.file} 在远端不存在,已跳过`);
340
+ continue;
341
+ }
342
+ if (response.status === 304) {
343
+ // A body nobody touched, under an identity that moved: the entry has to be
344
+ // re-recorded, and the body it already has is the one to stage.
345
+ if (!identityMoved)
346
+ continue;
347
+ const body = local ?? '';
348
+ workspace.stage(prompt.file, body);
349
+ changes.push({ path: prompt.file, id: identity, kind: 'changed', added: 0, removed: 0 });
350
+ continue;
351
+ }
352
+ const sha1 = bodyHash(response.text);
353
+ if (known !== undefined && known.sha1 === sha1 && !identityMoved)
354
+ continue;
355
+ if (Buffer.byteLength(response.text, 'utf8') > MAX_FILE_BYTES) {
356
+ warnings.push(`${prompt.file} 超过 ${String(MAX_FILE_BYTES)} 字节,已跳过`);
357
+ continue;
358
+ }
359
+ workspace.stage(prompt.file, response.text);
360
+ if (response.etag !== undefined)
361
+ etags[prompt.file] = response.etag;
362
+ const counts = diffCounts(local ?? '', response.text);
363
+ const change = {
364
+ id: identity,
365
+ path: prompt.file,
366
+ kind: known === undefined ? 'added' : 'changed',
367
+ added: counts.added,
368
+ removed: counts.removed,
369
+ };
370
+ if (known === undefined && prompt.id === undefined)
371
+ orphans.push({ path: prompt.file, sha1, change });
372
+ if (prompt.title !== undefined)
373
+ change.title = prompt.title;
374
+ if (prompt.order !== undefined)
375
+ change.order = prompt.order;
376
+ changes.push(change);
377
+ }
378
+ // A rename leaves the same body under a new name, so an identical sha1 on
379
+ // exactly one dropped path and exactly one new one is the whole signal — and
380
+ // the only honest one. Every wider match (near-identical bodies, two new files
381
+ // sharing a body, a body that also exists unchanged elsewhere) is a guess that
382
+ // would hand somebody else's entry id to the wrong prompt, so it is declined
383
+ // and reported instead.
384
+ const byOrphanSha1 = indexBySha1(orphans.map((orphan) => [orphan.sha1, orphan.path]));
385
+ const byRemovalSha1 = indexBySha1(removals.map((removal) => [removal.sha1, removal.path]));
386
+ for (const orphan of orphans) {
387
+ // Nothing dropped carries this body, so this is an ordinary new file, not a
388
+ // rename hiding anywhere.
389
+ if (!byRemovalSha1.has(orphan.sha1))
390
+ continue;
391
+ const from = unambiguous(byRemovalSha1.get(orphan.sha1));
392
+ const to = unambiguous(byOrphanSha1.get(orphan.sha1));
393
+ if (from === undefined || to !== orphan.path) {
394
+ warnings.push(`${orphan.path} 的正文与刚被删掉的文件相同,但不止一个文件对得上,不敢确定是改名,按新条目处理(要固定它的 id 就在清单里写 "id")`);
395
+ continue;
396
+ }
397
+ const removal = removals.find((candidate) => candidate.path === from);
398
+ if (removal === undefined)
399
+ continue;
400
+ orphan.change.id = removal.id;
401
+ orphan.change.renamedFrom = removal.path;
402
+ }
403
+ for (const removal of removals) {
404
+ const body = workspace.read('current', removal.path) ?? '';
405
+ const change = {
406
+ path: removal.path,
407
+ id: removal.id,
408
+ kind: 'removed',
409
+ added: 0,
410
+ removed: body.length === 0 ? 0 : body.split('\n').length,
411
+ };
412
+ // A removal and an addition carrying one id are one move even when the body
413
+ // changed on the way: the manifest declared that id, so the entry kept its
414
+ // identity while its file was renamed, and the pair has to land together.
415
+ // (When the body did not change, the hash already paired them; this is the
416
+ // same pairing stated the other way round, so there is one rule and not two.)
417
+ const renamedTo = changes.find((candidate) => candidate.kind === 'added'
418
+ && candidate.id === removal.id
419
+ && (candidate.renamedFrom === undefined || candidate.renamedFrom === removal.path));
420
+ if (renamedTo !== undefined) {
421
+ renamedTo.renamedFrom = removal.path;
422
+ change.renamedTo = renamedTo.path;
423
+ }
424
+ changes.push(change);
425
+ }
426
+ const plan = { changes, prompts, manifestSha1, etags };
427
+ if (headSha !== undefined)
428
+ plan.headSha = headSha;
429
+ workspace.writePlan(plan);
430
+ const outcome = { upToDate: changes.length === 0, changes, prompts, warnings };
431
+ if (headSha !== undefined)
432
+ outcome.headSha = headSha;
433
+ return outcome;
434
+ }
435
+ /**
436
+ * Group paths by the body hash they carry.
437
+ * @param pairs - `[sha1, path]`, in the order the check met them.
438
+ * @returns sha1 to every path carrying it.
439
+ */
440
+ function indexBySha1(pairs) {
441
+ const index = new Map();
442
+ for (const [sha1, path] of pairs) {
443
+ const paths = index.get(sha1);
444
+ if (paths === undefined)
445
+ index.set(sha1, [path]);
446
+ else
447
+ paths.push(path);
448
+ }
449
+ return index;
450
+ }
451
+ /**
452
+ * The one path a hash stands for.
453
+ * @param paths - the paths carrying one hash.
454
+ * @returns that path, or `undefined` when there is not exactly one.
455
+ */
456
+ function unambiguous(paths) {
457
+ return paths !== undefined && paths.length === 1 ? paths[0] : undefined;
458
+ }
459
+ /**
460
+ * Fetch one URL and refuse obvious non-content answers.
461
+ * @param fetcher - the fetcher to use.
462
+ * @param url - absolute upstream URL.
463
+ * @param etag - stored validator, when the caller has one.
464
+ * @returns the response.
465
+ * @throws {CheckError} on network failure, an HTML answer, or a server error.
466
+ */
467
+ async function fetchOrThrow(fetcher, url, etag) {
468
+ let response;
469
+ try {
470
+ response = await fetcher.get(url, etag === undefined ? {} : { etag });
471
+ }
472
+ catch (error) {
473
+ throw new CheckError('network', error instanceof Error ? error.message : String(error));
474
+ }
475
+ if (response.status === 200 && looksLikeHtml(response)) {
476
+ throw new CheckError('mirror', `${url} 返回了 HTML 页面,镜像没有正确代理这个文件`);
477
+ }
478
+ if (response.status !== 200 && response.status !== 304 && response.status !== 404) {
479
+ throw new CheckError('network', `${url} 返回 ${String(response.status)}`);
480
+ }
481
+ return { status: response.status, text: response.text, etag: response.etag };
482
+ }
483
+ /**
484
+ * Apply staged changes into the version in force.
485
+ *
486
+ * @param input - workspace, current state, the staged plan, the source, and the
487
+ * paths the caller selected (all of them when omitted).
488
+ * @returns the new state and the changes that landed.
489
+ */
490
+ export function applyChanges(input) {
491
+ const { workspace, state, plan, source } = input;
492
+ const selected = resolveSelection(plan.changes, input.selected);
493
+ const applied = [];
494
+ const files = { ...state.files };
495
+ const undo = {};
496
+ for (const change of plan.changes) {
497
+ if (selected !== undefined && !selected.has(change.path))
498
+ continue;
499
+ const current = workspace.slotPath('current', change.path);
500
+ const previous = workspace.slotPath('previous', change.path);
501
+ const staged = workspace.slotPath('staging', change.path);
502
+ if (current === undefined || previous === undefined)
503
+ continue;
504
+ undo[change.path] = files[change.path] ?? null;
505
+ if (change.kind === 'removed') {
506
+ if (existsSync(current)) {
507
+ mkdirSync(dirname(previous), { recursive: true });
508
+ copyFileSync(current, previous);
509
+ rmSync(current, { force: true });
510
+ }
511
+ delete files[change.path];
512
+ applied.push(change);
513
+ continue;
514
+ }
515
+ if (staged === undefined || !existsSync(staged))
516
+ continue;
517
+ if (existsSync(current)) {
518
+ mkdirSync(dirname(previous), { recursive: true });
519
+ copyFileSync(current, previous);
520
+ }
521
+ mkdirSync(dirname(current), { recursive: true });
522
+ copyFileSync(staged, current);
523
+ const prompt = plan.prompts.find((candidate) => candidate.file === change.path);
524
+ const before = files[change.path];
525
+ const sha1 = bodyHash(readFileSync(current, 'utf8'));
526
+ const next = {
527
+ id: change.id,
528
+ enabled: prompt?.enabled ?? true,
529
+ sha1,
530
+ };
531
+ // The id moved — a manifest that started declaring its own, or a file that
532
+ // was renamed. Remember what it was, so the index can carry the title,
533
+ // placement, and switch a person chose onto the id it has now.
534
+ if (before !== undefined && before.id !== change.id)
535
+ next.renamedFromId = before.id;
536
+ const title = prompt?.title ?? undefined;
537
+ if (title !== undefined)
538
+ next.title = title;
539
+ const order = prompt?.order ?? undefined;
540
+ if (order !== undefined)
541
+ next.order = order;
542
+ // A validator only ever describes one body, so it is carried over exactly
543
+ // when the body it validated is the one still in force: an id that moved
544
+ // alone keeps the cheap conditional request, a changed body starts over.
545
+ const etag = plan.etags[change.path] ?? (before?.sha1 === sha1 ? before.etag : undefined);
546
+ if (etag !== undefined)
547
+ next.etag = etag;
548
+ files[change.path] = next;
549
+ applied.push(change);
550
+ }
551
+ const next = {
552
+ ref: source.ref,
553
+ files,
554
+ appliedAt: input.nowIso,
555
+ manifestSha1: plan.manifestSha1,
556
+ undo,
557
+ };
558
+ if (plan.headSha !== undefined)
559
+ next.headSha = plan.headSha;
560
+ else if (state.headSha !== undefined)
561
+ next.headSha = state.headSha;
562
+ if (state.headSha !== undefined)
563
+ next.headShaPrevious = state.headSha;
564
+ return { state: next, applied };
565
+ }
566
+ /**
567
+ * The paths an apply should touch, with every rename pair completed.
568
+ *
569
+ * A rename reaches the plan as two changes that are really one move. Applying
570
+ * only the new path would leave two state records claiming the same entry id, and
571
+ * the index would then carry that entry twice; applying only the old one would
572
+ * erase the id without putting the new body in its place — the entry would be
573
+ * gone while the file that replaced it sits applied-but-unknown. So either both
574
+ * sides of a pair land or neither does, whatever the caller selected.
575
+ *
576
+ * @param changes - the staged changes.
577
+ * @param requested - paths the caller selected, or `undefined` for all of them.
578
+ * @returns the paths to apply, or `undefined` when everything applies.
579
+ */
580
+ function resolveSelection(changes, requested) {
581
+ if (requested === undefined)
582
+ return undefined;
583
+ const selected = new Set(requested);
584
+ for (const change of changes) {
585
+ const mate = change.renamedTo ?? change.renamedFrom;
586
+ if (mate === undefined)
587
+ continue;
588
+ if (!selected.has(mate) && !selected.has(change.path))
589
+ continue;
590
+ selected.add(change.path);
591
+ selected.add(mate);
592
+ }
593
+ return selected;
594
+ }
595
+ /**
596
+ * Put the version the last apply replaced back in force, guided by the apply's
597
+ * undo ledger so a restored file also gets its title, placement, and switch back.
598
+ *
599
+ * @param input - workspace and current state.
600
+ * @returns the restored state and the paths that moved back.
601
+ */
602
+ export function revertChanges(input) {
603
+ const { workspace, state } = input;
604
+ const reverted = [];
605
+ const files = { ...state.files };
606
+ for (const [path, record] of Object.entries(state.undo ?? {})) {
607
+ const current = workspace.slotPath('current', path);
608
+ const previous = workspace.slotPath('previous', path);
609
+ if (current === undefined || previous === undefined)
610
+ continue;
611
+ if (record === null) {
612
+ rmSync(current, { force: true });
613
+ delete files[path];
614
+ reverted.push(path);
615
+ continue;
616
+ }
617
+ if (!existsSync(previous))
618
+ continue;
619
+ mkdirSync(dirname(current), { recursive: true });
620
+ copyFileSync(previous, current);
621
+ rmSync(previous, { force: true });
622
+ files[path] = record;
623
+ reverted.push(path);
624
+ }
625
+ const next = { ref: state.ref, files };
626
+ if (state.headShaPrevious !== undefined)
627
+ next.headSha = state.headShaPrevious;
628
+ const manifestSha1 = state.manifestSha1 ?? undefined;
629
+ if (manifestSha1 !== undefined)
630
+ next.manifestSha1 = manifestSha1;
631
+ return { state: next, reverted };
632
+ }
633
+ /**
634
+ * The entry title a manifest file should get on import.
635
+ * @param source - owning source.
636
+ * @param prompt - the manifest entry.
637
+ * @returns a non-empty display title.
638
+ */
639
+ export function titleFor(source, prompt) {
640
+ if (prompt.title !== undefined && prompt.title.length > 0)
641
+ return prompt.title;
642
+ if (prompt.id !== undefined && prompt.id.length > 0)
643
+ return prompt.id;
644
+ return stemOf(prompt.file);
645
+ }
646
+ //# sourceMappingURL=sync.js.map