@summerscar/dsh-mnemon-sync 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,706 @@
1
+ /**
2
+ * Mnemon Sync — Host half.
3
+ *
4
+ * Backs up the whole `<MNEMON_DATA_DIR>/data` directory — every memory space
5
+ * database, not just `default` — and ships it to an S3-compatible bucket or a
6
+ * WebDAV collection.
7
+ *
8
+ * Why not litestream: it replicates *one* database per instance, and in v0.5 a
9
+ * database accepts exactly one replica, so directory-level backup would mean
10
+ * one long-lived process per space plus dynamic reconfiguration whenever a
11
+ * space is added. It also holds the WAL open, which makes dsh-mnemon's own
12
+ * Pack export fail its `-wal is not checkpointed` check. `node:sqlite`'s
13
+ * `backup()` gives a transaction-consistent copy of each live database with no
14
+ * resident process, which is both simpler and compatible with the store.
15
+ *
16
+ * The browser half only ever sees JSON: credentials are masked on the way out
17
+ * and are never echoed back by a read.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto'
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
22
+ import { createRequire } from 'node:module'
23
+ import { dirname, join, resolve } from 'node:path'
24
+ import { pathToFileURL } from 'node:url'
25
+
26
+ import { SyncHistory, SyncScheduler } from './scheduler.js'
27
+ import {
28
+ COMPONENTS,
29
+ MEMORY_SPACE_DATABASE_FILE,
30
+ buildSnapshot,
31
+ inspectHome,
32
+ readSpaceNames,
33
+ verifySnapshot,
34
+ } from './snapshot.js'
35
+ import { createZip, readZip } from './zip.js'
36
+ import { deleteArchive, downloadArchive, listArchives, normalizeTarget, uploadArchive } from './targets.js'
37
+ import { maskSettings, normalizeSettings, readSettings, settingsPath, writeSettings } from './store.js'
38
+
39
+ const API_PREFIX = '/api/mnemon-sync'
40
+ const ARCHIVE_PREFIX = 'mnemon-sync-'
41
+
42
+ export const name = 'dsh-mnemon-sync'
43
+ // `mnemonMemory` is optional on purpose: the plugin still backs up on a profile
44
+ // where dsh-mnemon is absent or too old to accept Sources, it just cannot offer
45
+ // its page there.
46
+ export const inject = ['webServer']
47
+
48
+ /**
49
+ * `MNEMON_DATA_DIR` names the Mnemon home; the memory store itself lives in
50
+ * its `data` subdirectory, exactly as dsh-mnemon resolves it.
51
+ */
52
+ export function resolveDataRoot(config = {}) {
53
+ if (typeof config.dataRoot === 'string' && config.dataRoot.trim() !== '') {
54
+ return resolve(config.dataRoot.trim())
55
+ }
56
+ const fromEnvironment = process.env.MNEMON_DATA_DIR?.trim()
57
+ const home = fromEnvironment === undefined || fromEnvironment === ''
58
+ ? join(process.env.HOME ?? '', '.mnemon')
59
+ : fromEnvironment
60
+ return join(home, 'data')
61
+ }
62
+
63
+ function sendJson(res, status, body) {
64
+ const payload = Buffer.from(`${JSON.stringify(body)}\n`, 'utf8')
65
+ res.writeHead(status, {
66
+ 'content-type': 'application/json; charset=utf-8',
67
+ 'content-length': String(payload.length),
68
+ 'cache-control': 'no-store',
69
+ })
70
+ res.end(payload)
71
+ }
72
+
73
+ async function readJsonBody(req) {
74
+ const chunks = []
75
+ let total = 0
76
+ for await (const chunk of req) {
77
+ total += chunk.length
78
+ if (total > 1_000_000) throw new Error('request body too large')
79
+ chunks.push(chunk)
80
+ }
81
+ if (chunks.length === 0) return {}
82
+ const text = Buffer.concat(chunks).toString('utf8').trim()
83
+ if (text === '') return {}
84
+ return JSON.parse(text)
85
+ }
86
+
87
+ /** `mnemon-sync-2026-11-09T041500Z.zip`: sorts lexicographically by time. */
88
+ export function archiveKey(at = new Date()) {
89
+ return `${ARCHIVE_PREFIX}${at.toISOString().replace(/:/gu, '-')}.zip`
90
+ }
91
+
92
+ export function apply(ctx, config = {}) {
93
+ const dataRoot = resolveDataRoot(config)
94
+ // `<home>/data` → `<home>`: settings and work files sit beside the store.
95
+ const home = resolve(dataRoot, '..')
96
+ const workRoot = join(home, 'state', 'mnemon-sync-work')
97
+
98
+ const history = new SyncHistory(home)
99
+ const settings = () => readSettings(home)
100
+
101
+ const performBackup = async (trigger, current, signal) => {
102
+ if (current.enabled !== true && trigger === 'schedule') {
103
+ return { skipped: 'disabled' }
104
+ }
105
+ const target = normalizeTarget(current.target)
106
+ const staging = join(workRoot, `build-${String(Date.now())}`)
107
+ mkdirSync(staging, { recursive: true })
108
+ const startedAt = new Date().toISOString()
109
+ try {
110
+ const snapshot = await buildSnapshot(home, staging, { components: current.components })
111
+ verifySnapshot(staging)
112
+ const key = archiveKey()
113
+ const archive = createZip(collect(staging), { level: 6 })
114
+ const uploaded = await uploadArchive(target, key, archive, { signal })
115
+ const removed = await applyRetention(history, target, current.retention.keep, { signal })
116
+ const record = {
117
+ id: `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`,
118
+ startedAt,
119
+ finishedAt: new Date().toISOString(),
120
+ ok: true,
121
+ trigger,
122
+ durationMs: Date.now() - Date.parse(startedAt),
123
+ // What actually shipped, so the history row can say "3 components"
124
+ // rather than assuming memory spaces were involved.
125
+ components: snapshot.manifest.components,
126
+ spaces: countSpaces(snapshot.manifest),
127
+ files: snapshot.files.length,
128
+ bytes: archive.length,
129
+ key,
130
+ location: uploaded.location,
131
+ removed,
132
+ }
133
+ history.append(record)
134
+ return record
135
+ } catch (error) {
136
+ history.append({
137
+ id: `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`,
138
+ startedAt,
139
+ finishedAt: new Date().toISOString(),
140
+ ok: false,
141
+ trigger,
142
+ durationMs: Date.now() - Date.parse(startedAt),
143
+ error: error instanceof Error ? error.message : String(error),
144
+ })
145
+ throw error
146
+ } finally {
147
+ rmSync(staging, { recursive: true, force: true })
148
+ }
149
+ }
150
+
151
+ const scheduler = new SyncScheduler({
152
+ readSettings: settings,
153
+ run: performBackup,
154
+ onError: () => {
155
+ // History already records the failure; the timer must stay silent.
156
+ },
157
+ })
158
+
159
+ ctx.effect(() => scheduler.start(), 'mnemon-sync: scheduler')
160
+
161
+ // Register the Source that owns the sync page.
162
+ //
163
+ // dsh-mnemon shows a page tab only when its `sourceTypeId` appears in the
164
+ // live source catalog, and that catalog lists running Source implementations
165
+ // — not UI contributions. Standing up this minimal Source is what lets the
166
+ // page use its own type instead of borrowing `memory-spaces`, which is the
167
+ // only way its tab can highlight independently.
168
+ ctx.effect(() => registerSyncSource(ctx, dataRoot), 'mnemon-sync: memory source')
169
+
170
+ ctx.effect(() => ctx.webServer.register({
171
+ kind: 'prefix',
172
+ path: API_PREFIX,
173
+ handler: (req, res) => {
174
+ void handle(req, res).catch((error) => {
175
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
176
+ })
177
+ },
178
+ }), 'mnemon-sync: http api')
179
+
180
+ async function handle(req, res) {
181
+ const url = new URL(req.url ?? '/', 'http://mnemon-sync.invalid')
182
+ const route = url.pathname.slice(API_PREFIX.length) || '/'
183
+
184
+ if (req.method === 'GET' && route === '/status') {
185
+ const current = settings()
186
+ sendJson(res, 200, {
187
+ dataRoot,
188
+ settingsPath: settingsPath(home),
189
+ workRoot,
190
+ state: inspectHome(home),
191
+ settings: maskSettings(current),
192
+ scheduler: scheduler.snapshot(current),
193
+
194
+ history: history.read(),
195
+ spaceNames: Object.fromEntries(readSpaceNames(home)),
196
+ })
197
+ return
198
+ }
199
+
200
+ if (req.method === 'PUT' && route === '/settings') {
201
+ const body = await readJsonBody(req)
202
+ const previous = settings()
203
+ const incoming = body?.settings ?? body
204
+ // A masked secret coming back from the UI means "unchanged".
205
+ if (incoming !== null && typeof incoming === 'object' && incoming.target !== null && typeof incoming.target === 'object') {
206
+ const target = incoming.target
207
+ if (target.kind === 'webdav' && isMasked(target.password)) {
208
+ target.password = previous.target.kind === 'webdav' ? previous.target.password : ''
209
+ }
210
+ if (target.kind === 's3' && isMasked(target.secretAccessKey)) {
211
+ target.secretAccessKey = previous.target.kind === 's3' ? previous.target.secretAccessKey : ''
212
+ }
213
+ }
214
+ const saved = writeSettings(home, normalizeSettings(incoming))
215
+ scheduler.refresh()
216
+ sendJson(res, 200, { settings: maskSettings(saved) })
217
+ return
218
+ }
219
+
220
+ if (req.method === 'POST' && route === '/run') {
221
+ // An unsaved edit followed by "Back up now" must back up what the user is
222
+ // looking at, not what was last saved. When the page sends its draft, it
223
+ // is normalized and saved first — so the button never silently ignores a
224
+ // change — and the run then reads that same saved state.
225
+ const body = await readJsonBody(req)
226
+ try {
227
+ if (body?.settings !== undefined) {
228
+ const saved = writeSettings(home, normalizeSettings(body.settings))
229
+ scheduler.refresh()
230
+ const result = await scheduler.runNow('manual')
231
+ sendJson(res, 200, { result, settings: maskSettings(saved) })
232
+ return
233
+ }
234
+ const result = await scheduler.runNow('manual')
235
+ sendJson(res, 200, { result })
236
+ } catch (error) {
237
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
238
+ }
239
+ return
240
+ }
241
+
242
+ if (req.method === 'POST' && route === '/refresh-schedule') {
243
+ scheduler.refresh()
244
+ sendJson(res, 200, { scheduler: scheduler.snapshot(settings()) })
245
+ return
246
+ }
247
+
248
+ if (req.method === 'GET' && route === '/archives') {
249
+ // What the target actually holds, so the page can list backups this
250
+ // machine did not necessarily make — which is what a person wants after
251
+ // reinstalling or on a second machine.
252
+ try {
253
+ const archives = await listArchives(normalizeTarget(settings().target))
254
+ sendJson(res, 200, { archives })
255
+ } catch (error) {
256
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
257
+ }
258
+ return
259
+ }
260
+
261
+ if (req.method === 'POST' && route === '/inspect-archive') {
262
+ // Read one archive back and report what it actually holds. This is the
263
+ // restore half of "back up these components": an archive made when only
264
+ // documents were selected has no memory spaces, and saying so plainly is
265
+ // the difference between a restore and a silent half-restore.
266
+ const body = await readJsonBody(req)
267
+ try {
268
+ const report = await inspectArchive(settings(), String(body?.key ?? '').trim(), body?.components)
269
+ sendJson(res, 200, report)
270
+ } catch (error) {
271
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
272
+ }
273
+ return
274
+ }
275
+
276
+ if (req.method === 'POST' && route === '/restore') {
277
+ // Restore is a staged, verified operation: download, verify every
278
+ // checksum, extract to a staging directory, then either hand the files
279
+ // back for inspection or replace live data only when explicitly asked.
280
+ const body = await readJsonBody(req)
281
+ try {
282
+ const report = await restoreArchive(settings(), body ?? {})
283
+ sendJson(res, 200, report)
284
+ } catch (error) {
285
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
286
+ }
287
+ return
288
+ }
289
+
290
+ sendJson(res, 404, { error: `unknown route: ${req.method} ${route}` })
291
+ }
292
+
293
+ /**
294
+ * Download one archive from the configured target and describe its contents.
295
+ *
296
+ * `components` is what the operator wants to restore; any requested component
297
+ * the archive does not carry is reported in `missing` with the reason, so the
298
+ * UI can warn before touching anything on disk.
299
+ */
300
+ async function inspectArchive(current, key, requested) {
301
+ if (key === '') throw new Error('an archive key is required')
302
+ const target = normalizeTarget(current.target)
303
+ const bytes = await downloadArchive(target, key)
304
+ const files = readZip(bytes)
305
+ const manifestBytes = files.get('manifest.json')
306
+ if (manifestBytes === undefined) {
307
+ throw new Error('this archive has no manifest.json, so its contents cannot be verified')
308
+ }
309
+ const manifest = JSON.parse(manifestBytes.toString('utf8'))
310
+ if (manifest?.format !== 'mnemon-sync-snapshot') {
311
+ throw new Error(`unexpected archive format: ${String(manifest?.format)}`)
312
+ }
313
+ const present = Array.isArray(manifest.components) ? manifest.components : []
314
+ const wanted = Array.isArray(requested) && requested.length > 0
315
+ ? requested.filter((value) => COMPONENTS.includes(value))
316
+ : present
317
+ const missing = wanted
318
+ .filter((component) => !present.includes(component))
319
+ .map((component) => ({
320
+ component,
321
+ reason: skipReason(manifest, component) ?? 'this archive was made without it',
322
+ }))
323
+ // Every payload file the manifest promises is checked against the archive,
324
+ // so a truncated upload is caught here rather than after a restore starts.
325
+ const expected = manifest.checksums?.files ?? {}
326
+ const absent = Object.keys(expected).filter((relative) => !files.has(relative))
327
+
328
+ // The same plan a restore would carry out, reported here so the detail
329
+ // panel can show what pressing the button would write — and so the button
330
+ // exists at all. Keeping one plan shape means the preview and the work
331
+ // cannot drift apart.
332
+ const plan = []
333
+ for (const [relative, content] of files.entries()) {
334
+ if (relative === 'manifest.json') continue
335
+ const component = componentDirectoryOf(relative)
336
+ if (component === undefined || !wanted.includes(component)) continue
337
+ plan.push({ component, path: relative, bytes: content.length })
338
+ }
339
+
340
+ return {
341
+ key,
342
+ createdAt: manifest.createdAt,
343
+ archiveComponents: present,
344
+ requested: wanted,
345
+ missing,
346
+ entries: (manifest.entries ?? []).filter((entry) => typeof entry.component === 'string'),
347
+ skipped: Array.isArray(manifest.skipped) ? manifest.skipped : [],
348
+ files: files.size,
349
+ bytes: bytes.length,
350
+ plan,
351
+ planBytes: plan.reduce((total, entry) => total + entry.bytes, 0),
352
+ // `intact` means "this archive is complete and readable", which is what the
353
+ // badge says. A missing component the operator asked for is reported in
354
+ // `missing` separately: the archive is not damaged, it simply does not
355
+ // contain that component.
356
+ intact: absent.length === 0,
357
+ ...absent.length === 0 ? {} : { absent },
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Restore one archive into the live Mnemon home.
363
+ *
364
+ * The order is deliberate and never short-circuited:
365
+ * 1. download the archive,
366
+ * 2. verify every checksum in its manifest,
367
+ * 3. report any requested component the archive does not carry,
368
+ * 4. write files only for components the caller confirmed.
369
+ *
370
+ * Without `apply: true` this is a dry run: it answers what *would* be written
371
+ * and touches nothing. That makes the button safe to press while exploring,
372
+ * and keeps the destructive step an explicit second decision.
373
+ *
374
+ * Live SQLite databases are replaced with their checkpointed copy from the
375
+ * archive. The WAL and SHM sidecars are removed first, because a stale WAL
376
+ * beside a restored database would make SQLite replay transactions that the
377
+ * restored file never had — the one way a "correct" restore corrupts data.
378
+ */
379
+ async function restoreArchive(current, body) {
380
+ const key = String(body?.key ?? '').trim()
381
+ if (key === '') throw new Error('an archive key is required')
382
+ const apply_ = body?.apply === true
383
+ const target = normalizeTarget(current.target)
384
+ const bytes = await downloadArchive(target, key)
385
+ const files = readZip(bytes)
386
+ const manifestBytes = files.get('manifest.json')
387
+ if (manifestBytes === undefined) {
388
+ throw new Error('this archive has no manifest.json, so its contents cannot be verified')
389
+ }
390
+ const manifest = JSON.parse(manifestBytes.toString('utf8'))
391
+ if (manifest?.format !== 'mnemon-sync-snapshot') {
392
+ throw new Error(`unexpected archive format: ${String(manifest?.format)}`)
393
+ }
394
+
395
+ // Verify before writing anything: a truncated upload must be caught here.
396
+ const expected = manifest.checksums?.files ?? {}
397
+ const mismatched = []
398
+ for (const [relative, digest] of Object.entries(expected)) {
399
+ const content = files.get(relative)
400
+ if (content === undefined) {
401
+ mismatched.push({ path: relative, problem: 'missing from the archive' })
402
+ continue
403
+ }
404
+ const actual = createHash('sha256').update(content).digest('hex')
405
+ if (actual !== digest) mismatched.push({ path: relative, problem: 'checksum mismatch' })
406
+ }
407
+ if (mismatched.length > 0) {
408
+ return { key, applied: false, intact: false, mismatched }
409
+ }
410
+
411
+ const present = Array.isArray(manifest.components) ? manifest.components : []
412
+ const requested = Array.isArray(body?.components) && body.components.length > 0
413
+ ? body.components.filter((value) => COMPONENTS.includes(value))
414
+ : present
415
+ const missing = requested
416
+ .filter((component) => !present.includes(component))
417
+ .map((component) => ({
418
+ component,
419
+ reason: skipReason(manifest, component) ?? 'this archive was made without it',
420
+ }))
421
+ const restorable = requested.filter((component) => present.includes(component))
422
+
423
+ // Plan first, so a dry run and a real run describe the same work.
424
+ const plan = []
425
+ for (const component of restorable) {
426
+ for (const [relative, content] of files.entries()) {
427
+ if (relative === 'manifest.json') continue
428
+ const director = componentDirectoryOf(relative)
429
+ if (director !== component) continue
430
+ plan.push({ component, relative, destination: destinationFor(home, relative), bytes: content.length })
431
+ }
432
+ }
433
+
434
+ if (!apply_) {
435
+ return {
436
+ key,
437
+ applied: false,
438
+ intact: true,
439
+ createdAt: manifest.createdAt,
440
+ archiveComponents: present,
441
+ requested,
442
+ missing,
443
+ plan: plan.map(({ relative, bytes: size }) => ({ path: relative, bytes: size })),
444
+ planBytes: plan.reduce((total, entry) => total + entry.bytes, 0),
445
+ }
446
+ }
447
+
448
+ // Refuse a live restore of memory spaces while the store is writing: the
449
+ // database being replaced may have an open connection, and swapping a file
450
+ // under it loses whatever was committed since the backup.
451
+ const written = []
452
+ for (const component of restorable) {
453
+ if (component === 'memory-spaces') {
454
+ for (const entry of plan.filter((item) => item.component === component)) {
455
+ const database = entry.destination
456
+ if (database.endsWith(MEMORY_SPACE_DATABASE_FILE)) {
457
+ // Remove sidecars before the database, see the doc comment.
458
+ for (const suffix of ['-wal', '-shm']) {
459
+ const sidecar = `${database}${suffix}`
460
+ if (existsSync(sidecar)) rmSync(sidecar, { force: true })
461
+ }
462
+ }
463
+ }
464
+ }
465
+ for (const entry of plan.filter((item) => item.component === component)) {
466
+ const content = files.get(entry.relative)
467
+ mkdirSync(dirname(entry.destination), { recursive: true })
468
+ writeFileSync(entry.destination, content, { mode: 0o600 })
469
+ written.push({ component, path: entry.relative, destination: entry.destination, bytes: entry.bytes })
470
+ }
471
+ }
472
+
473
+ return {
474
+ key,
475
+ applied: true,
476
+ intact: true,
477
+ createdAt: manifest.createdAt,
478
+ archiveComponents: present,
479
+ requested,
480
+ missing,
481
+ restored: restorable,
482
+ written,
483
+ writtenBytes: written.reduce((total, entry) => total + entry.bytes, 0),
484
+ }
485
+ }
486
+ }
487
+
488
+ /** Why a component the operator asked for never made it into an archive. */
489
+ /**
490
+ * Which component an archive path belongs to.
491
+ *
492
+ * The payload layout is fixed by `buildSnapshot`: `payload/data/...` holds
493
+ * memory spaces, `payload/documents/...` project documents and
494
+ * `payload/runtime/...` runtime memory. Reading the component back off the path
495
+ * keeps the restore plan aligned with what was actually written, rather than
496
+ * trusting a second list that could drift.
497
+ */
498
+ /**
499
+ * How many memory spaces a manifest holds.
500
+ *
501
+ * A memory-space summary entry lists its space ids in `spaces`; per-space
502
+ * records carry a single `id`. Both shapes are counted here so the history row
503
+ * is right whichever one the archive was made with, and a backup taken with
504
+ * memory spaces deselected honestly reads zero.
505
+ */
506
+ function countSpaces(manifest) {
507
+ const entries = Array.isArray(manifest?.entries) ? manifest.entries : []
508
+ const ids = new Set()
509
+ for (const entry of entries) {
510
+ if (entry?.component !== 'memory-spaces') continue
511
+ if (Array.isArray(entry.spaces)) {
512
+ for (const id of entry.spaces) if (typeof id === 'string') ids.add(id)
513
+ continue
514
+ }
515
+ if (typeof entry.id === 'string') ids.add(entry.id)
516
+ }
517
+ return ids.size
518
+ }
519
+
520
+ function componentDirectoryOf(relative) {
521
+ if (!relative.startsWith('payload/')) return undefined
522
+ const [, directory] = relative.split('/')
523
+ if (directory === 'data') return 'memory-spaces'
524
+ if (directory === 'documents') return 'documents'
525
+ if (directory === 'runtime') return 'runtime'
526
+ return undefined
527
+ }
528
+
529
+ /**
530
+ * Where one archive path lands in the Mnemon home.
531
+ *
532
+ * `payload/` is dropped and the component directories are the same names the
533
+ * store itself uses, so a restored tree is byte-identical in layout to a live
534
+ * one. The path is resolved and checked so a malformed archive cannot walk out
535
+ * of the home directory.
536
+ */
537
+ function destinationFor(home, relative) {
538
+ const stripped = relative.replace(/^payload\//u, '')
539
+ const destination = resolve(home, stripped)
540
+ const root = resolve(home)
541
+ if (destination !== root && !destination.startsWith(`${root}/`)) {
542
+ throw new Error(`archive entry escapes the Mnemon home: ${relative}`)
543
+ }
544
+ return destination
545
+ }
546
+
547
+ /**
548
+ * Why a component the operator asked for never made it into an archive.
549
+ */
550
+ function skipReason(manifest, component) {
551
+ const skipped = Array.isArray(manifest?.skipped) ? manifest.skipped : []
552
+ const entry = skipped.find((item) => item?.component === component)
553
+ return typeof entry?.reason === 'string' ? entry.reason : undefined
554
+ }
555
+
556
+ function isMasked(value) {
557
+ return typeof value === 'string' && value !== '' && /^•+$/u.test(value)
558
+ }
559
+
560
+ /**
561
+ * Resolve the dsh-mnemon extension SDK.
562
+ *
563
+ * A bare `import('dsh-mnemon/extension-sdk')` is not enough here. Installed
564
+ * from npm the package sits at `<profile>/node_modules/@summerscar/...`, so
565
+ * Node's own ancestor walk would find dsh-mnemon beside it — but a *linked*
566
+ * checkout lives outside the profile with no `node_modules` of its own, and
567
+ * then the walk finds nothing.
568
+ *
569
+ * Both install shapes are therefore covered, cheapest first:
570
+ * 1. From this module's own location, which is right for an npm install.
571
+ * 2. From `DSH_PROFILE_DIR`, which is right for a linked checkout and is set
572
+ * in every shell and Host process a profile launches.
573
+ * 3. From `DSH_HOME/profiles/web`, for a Host booted without that variable.
574
+ *
575
+ * Returns `undefined` when dsh-mnemon is not installed at all, which is a
576
+ * supported configuration: the backup keeps working without its page.
577
+ */
578
+ async function loadExtensionSdk() {
579
+ // 1. Ask Node, anchored at this file. Resolves for an npm install.
580
+ try {
581
+ const resolver = createRequire(import.meta.url)
582
+ const resolved = resolver.resolve('dsh-mnemon/lib/extension-sdk.js')
583
+ return await import(pathToFileURL(resolved).href)
584
+ } catch {
585
+ // Not resolvable from here: fall through to the profile-anchored paths.
586
+ }
587
+
588
+ const candidates = []
589
+ const profileDir = process.env.DSH_PROFILE_DIR?.trim()
590
+ if (profileDir !== undefined && profileDir !== '') {
591
+ candidates.push(join(profileDir, 'node_modules', 'dsh-mnemon', 'lib', 'extension-sdk.js'))
592
+ }
593
+ // The Host that loads this plugin lives in the installation's own tree.
594
+ candidates.push(join(process.env.DSH_HOME ?? '', 'profiles', 'web', 'node_modules', 'dsh-mnemon', 'lib', 'extension-sdk.js'))
595
+ for (const candidate of candidates) {
596
+ if (candidate === '' || !existsSync(candidate)) continue
597
+ return import(pathToFileURL(candidate).href)
598
+ }
599
+ return undefined
600
+ }
601
+
602
+ /**
603
+ * Install the sync Source into dsh-mnemon's composable-memory service.
604
+ *
605
+ * Returns a disposer, and does nothing at all when the Host runs without that
606
+ * service (a profile with dsh-mnemon absent).
607
+ */
608
+ function registerSyncSource(ctx, dataRoot) {
609
+ if (typeof ctx.get !== 'function') return () => {}
610
+ const service = ctx.get('mnemonMemory')
611
+ if (service === undefined || service === null || typeof service.installContributions !== 'function') {
612
+ return () => {}
613
+ }
614
+ let disposed = false
615
+ let uninstall
616
+ void (async () => {
617
+ try {
618
+ const sdk = await loadExtensionSdk()
619
+ if (sdk === undefined || typeof sdk.defineMemorySource !== 'function') {
620
+ console.warn('mnemon-sync: dsh-mnemon extension SDK not found; the sync tab stays hidden')
621
+ return
622
+ }
623
+ const { createSyncSource, syncSourceManifest } = await import('./source.js')
624
+ if (disposed) return
625
+ // The manifest goes through the SDK validator so a contract drift in
626
+ // dsh-mnemon fails loudly here instead of at composition time.
627
+ const definition = sdk.defineMemorySource(createSyncSource({ ...syncSourceManifest }))
628
+ uninstall = service.installContributions({
629
+ plugin: {
630
+ apiVersion: 'dsh-mnemon/plugin/v1',
631
+ packageName: '@summerscar/dsh-mnemon-sync',
632
+ label: { en: 'Sync backup', 'zh-CN': '同步备份' },
633
+ description: {
634
+ en: 'Archive memory spaces to S3 or WebDAV.',
635
+ 'zh-CN': '把记忆空间备份到 S3 或 WebDAV。',
636
+ },
637
+ roles: ['source'],
638
+ provides: [{ id: 'source' }],
639
+ },
640
+ sources: [definition],
641
+ }, { instanceId: 'mnemon-sync' })
642
+ if (disposed) {
643
+ uninstall?.()
644
+ uninstall = undefined
645
+ }
646
+ } catch (error) {
647
+ // A Host that cannot accept the Source must not take the backup down.
648
+ console.warn('mnemon-sync: could not register the sync source', error)
649
+ }
650
+ })()
651
+ return () => {
652
+ disposed = true
653
+ uninstall?.()
654
+ uninstall = undefined
655
+ }
656
+ }
657
+
658
+ /** Read a staging tree into the flat `{ path, bytes }` entries the ZIP writer takes. */
659
+ function collect(root, prefix = '') {
660
+ const entries = []
661
+ for (const entry of readdirSync(join(root, prefix), { withFileTypes: true })) {
662
+ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`
663
+ if (entry.isDirectory()) {
664
+ entries.push(...collect(root, relative))
665
+ continue
666
+ }
667
+ if (!entry.isFile()) continue
668
+ const path = join(root, relative)
669
+ // Every staged file goes in, including an empty one. `USER.md` is legitimately
670
+ // 0 bytes when the user profile holds nothing, and the manifest records a
671
+ // checksum for it — skipping it here made every such archive fail its own
672
+ // integrity check.
673
+ entries.push({ path: relative, bytes: readFileSync(path) })
674
+ }
675
+ return entries
676
+ }
677
+
678
+ /**
679
+ * Delete the oldest archives beyond `keep`.
680
+ *
681
+ * Listing is deliberately *local*: archive keys are timestamped and the run
682
+ * history already records every key this plugin uploaded, so retention never
683
+ * needs a LIST call — which would mean a second signature scheme and would
684
+ * fail on buckets where listing is denied. A key that no longer exists counts
685
+ * as removed.
686
+ */
687
+ async function applyRetention(history, target, keep, options) {
688
+ const uploaded = history.read()
689
+ .filter((run) => run.ok && typeof run.key === 'string')
690
+ .map((run) => run.key)
691
+ const unique = [...new Set(uploaded)].sort()
692
+ const overflow = unique.slice(0, Math.max(0, unique.length - keep))
693
+ const removed = []
694
+ for (const key of overflow) {
695
+ try {
696
+ await deleteArchive(target, key, options)
697
+ removed.push(key)
698
+ } catch {
699
+ // retention is best effort; the next run tries again
700
+ }
701
+ }
702
+ return removed
703
+ }
704
+
705
+ export { buildSnapshot, inspectHome, readSettings, writeSettings }
706
+