@lmzhen/dsh-evolution-merge 0.48.0 → 0.49.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/lib/index.js CHANGED
@@ -4874,6 +4874,48 @@ async function resolveBody(io, home, ref, rowBody) {
4874
4874
  return await rowBody();
4875
4875
  }
4876
4876
  //#endregion
4877
+ //#region lib/types/domain/state-migrate.js
4878
+ /**
4879
+ * Migrate v1/v2/v3 merge state to v4. v4 is a no-op; forward-only/needs-repair are reported
4880
+ * before attempting anything, so a newer state is never overwritten.
4881
+ * @param io - the evolution IO seam.
4882
+ * @param home - the evolution home.
4883
+ * @param fence - optional home write fence used by the host.
4884
+ * @returns the migration outcome.
4885
+ */
4886
+ async function migrateMergeStateToV4(io, home, fence) {
4887
+ const v4 = await readV4Readiness(io, home);
4888
+ if (v4.ok) return {
4889
+ ok: true,
4890
+ migrated: false
4891
+ };
4892
+ if (v4.code === "merge-state-forward-only" || v4.code === "merge-state-needs-repair") return {
4893
+ ok: false,
4894
+ code: v4.code,
4895
+ message: v4.message
4896
+ };
4897
+ const v3 = await ensureMergeStateReady(io, home, fence);
4898
+ if (!v3.ok) return {
4899
+ ok: false,
4900
+ code: v3.code,
4901
+ message: v3.message
4902
+ };
4903
+ const normalized = await normalizePairLedger(io, home);
4904
+ if (!normalized.ok) return {
4905
+ ok: false,
4906
+ code: "merge-state-needs-repair",
4907
+ message: normalized.message ?? "v3->v4 normalize failed"
4908
+ };
4909
+ return normalized.backupRef === void 0 ? {
4910
+ ok: true,
4911
+ migrated: normalized.migrated
4912
+ } : {
4913
+ ok: true,
4914
+ migrated: normalized.migrated,
4915
+ backupRef: normalized.backupRef
4916
+ };
4917
+ }
4918
+ //#endregion
4877
4919
  //#region lib/types/domain/evidence-hash.js
4878
4920
  /** Canonical evidence JSON hash shared with core unit receipts. */
4879
4921
  function canonical(value) {
@@ -10461,37 +10503,27 @@ function apply(ctx, rawConfig = {}) {
10461
10503
  if (registry === void 0) return;
10462
10504
  const io = evolutionIoAdapter(() => registry.provider());
10463
10505
  const home = evolutionHome();
10506
+ const homeFence = ctx.get("evolutionMutationAuthority")?.homeFence;
10464
10507
  const ready = (async () => {
10465
- const v4 = await readV4Readiness(io, home);
10466
- if (v4.ok) return {
10508
+ const migration = await migrateMergeStateToV4(io, home, homeFence);
10509
+ if (!migration.ok) return migration;
10510
+ const loaded = await loadMergeState(io, home);
10511
+ return {
10467
10512
  ok: true,
10468
10513
  schema: 3,
10469
- migrated: false,
10514
+ migrated: migration.migrated,
10470
10515
  state: {
10471
10516
  schemaVersion: 3,
10472
- rows: (await loadMergeState(io, home)).state.rows.map(legacyToV3).filter((row) => row !== null)
10517
+ rows: loaded.state.rows.map(legacyToV3).filter((row) => row !== null)
10473
10518
  },
10474
10519
  quarantine: [],
10475
10520
  report: null
10476
10521
  };
10477
- const state = await ensureMergeStateReady(io, home, ctx.get("evolutionMutationAuthority")?.homeFence);
10478
- if (!state.ok) return v4.code === "merge-state-forward-only" || v4.code === "merge-state-needs-repair" ? {
10479
- ok: false,
10480
- code: v4.code,
10481
- message: v4.message
10482
- } : state;
10483
- const normalized = await normalizePairLedger(io, home);
10484
- return normalized.ok ? state : {
10485
- ok: false,
10486
- code: "merge-state-needs-repair",
10487
- message: normalized.message ?? "v4 normalize failed"
10488
- };
10489
10522
  })().catch((error) => ({
10490
10523
  ok: false,
10491
10524
  code: "merge-state-migration-blocked",
10492
10525
  message: error instanceof Error ? error.message : String(error)
10493
10526
  }));
10494
- const homeFence = ctx.get("evolutionMutationAuthority")?.homeFence;
10495
10527
  const runs = newRunRegistry({
10496
10528
  io,
10497
10529
  home,
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The one v2/v3→v4 migration seam. The startup gate and the operator CLI both call this,
3
+ * so a backup/journal fix in one path cannot drift from the other.
4
+ */
5
+ import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
6
+ /** Outcome of the one-time migration. */
7
+ export type MigrateToV4Result = {
8
+ readonly ok: true;
9
+ readonly migrated: boolean;
10
+ readonly backupRef?: string;
11
+ } | {
12
+ readonly ok: false;
13
+ readonly code: string;
14
+ readonly message: string;
15
+ };
16
+ /**
17
+ * Migrate v1/v2/v3 merge state to v4. v4 is a no-op; forward-only/needs-repair are reported
18
+ * before attempting anything, so a newer state is never overwritten.
19
+ * @param io - the evolution IO seam.
20
+ * @param home - the evolution home.
21
+ * @param fence - optional home write fence used by the host.
22
+ * @returns the migration outcome.
23
+ */
24
+ export declare function migrateMergeStateToV4(io: EvolutionIoLike, home: string, fence?: {
25
+ run<T>(body: () => Promise<T>): Promise<T>;
26
+ }): Promise<MigrateToV4Result>;
27
+ //# sourceMappingURL=state-migrate.d.ts.map
@@ -0,0 +1,29 @@
1
+ import { ensureMergeStateReady } from "./state-bootstrap.js";
2
+ import { normalizePairLedger } from "./pair-ledger.js";
3
+ import { readV4Readiness } from "./schema4.js";
4
+ /**
5
+ * Migrate v1/v2/v3 merge state to v4. v4 is a no-op; forward-only/needs-repair are reported
6
+ * before attempting anything, so a newer state is never overwritten.
7
+ * @param io - the evolution IO seam.
8
+ * @param home - the evolution home.
9
+ * @param fence - optional home write fence used by the host.
10
+ * @returns the migration outcome.
11
+ */
12
+ export async function migrateMergeStateToV4(io, home, fence) {
13
+ const v4 = await readV4Readiness(io, home);
14
+ if (v4.ok)
15
+ return { ok: true, migrated: false };
16
+ if (v4.code === 'merge-state-forward-only' || v4.code === 'merge-state-needs-repair') {
17
+ return { ok: false, code: v4.code, message: v4.message };
18
+ }
19
+ const v3 = await ensureMergeStateReady(io, home, fence);
20
+ if (!v3.ok)
21
+ return { ok: false, code: v3.code, message: v3.message };
22
+ const normalized = await normalizePairLedger(io, home);
23
+ if (!normalized.ok)
24
+ return { ok: false, code: 'merge-state-needs-repair', message: normalized.message ?? 'v3->v4 normalize failed' };
25
+ return normalized.backupRef === undefined
26
+ ? { ok: true, migrated: normalized.migrated }
27
+ : { ok: true, migrated: normalized.migrated, backupRef: normalized.backupRef };
28
+ }
29
+ //# sourceMappingURL=state-migrate.js.map
@@ -8,7 +8,7 @@ import { authorityFrom, createSubagentChannel, evolutionHome, evolutionIoAdapter
8
8
  import { newSkillLibrary } from '@lmzhen/dsh-evolution-core/skills';
9
9
  import { registerHostRoutes } from '@lmzhen/dsh-evolution-core/http-routes';
10
10
  import { loadMergeState } from "./state.js";
11
- import { pairLedger, normalizePairLedger } from "./domain/pair-ledger.js";
11
+ import { pairLedger } from "./domain/pair-ledger.js";
12
12
  import { legacyToV3 } from "./domain/row-v3-bridge.js";
13
13
  import { readV4Readiness } from "./domain/schema4.js";
14
14
  import { readMergeSnapshot } from "./domain/merge-snapshot.js";
@@ -27,7 +27,7 @@ import { buildDoctorView } from "./surface/merge-view.js";
27
27
  import { mergeDoctorFact } from "./doctor.js";
28
28
  import { commandError, formatMergeError } from "./domain/errors.js";
29
29
  import { resolveBody } from "./surface/body.js";
30
- import { ensureMergeStateReady } from "./domain/state-bootstrap.js";
30
+ import { migrateMergeStateToV4 } from "./domain/state-migrate.js";
31
31
  import { recoverCommitUnits } from "./recovery-coordinator.js";
32
32
  import { pairKey, rowIdOf } from "./contracts.js";
33
33
  import { makeMergeRoutes } from "./merge-routes.js";
@@ -171,19 +171,14 @@ export function apply(ctx, rawConfig = {}) {
171
171
  return;
172
172
  const io = evolutionIoAdapter(() => registry.provider());
173
173
  const home = evolutionHome();
174
+ const homeFence = ctx.get('evolutionMutationAuthority')?.homeFence;
174
175
  const ready = (async () => {
175
- const v4 = await readV4Readiness(io, home);
176
- if (v4.ok) {
177
- const loaded = await loadMergeState(io, home);
178
- return { ok: true, schema: 3, migrated: false, state: { schemaVersion: 3, rows: loaded.state.rows.map(legacyToV3).filter((row) => row !== null) }, quarantine: [], report: null };
179
- }
180
- const state = await ensureMergeStateReady(io, home, ctx.get('evolutionMutationAuthority')?.homeFence);
181
- if (!state.ok)
182
- return v4.code === 'merge-state-forward-only' || v4.code === 'merge-state-needs-repair' ? { ok: false, code: v4.code, message: v4.message } : state;
183
- const normalized = await normalizePairLedger(io, home);
184
- return normalized.ok ? state : { ok: false, code: 'merge-state-needs-repair', message: normalized.message ?? 'v4 normalize failed' };
176
+ const migration = await migrateMergeStateToV4(io, home, homeFence);
177
+ if (!migration.ok)
178
+ return migration;
179
+ const loaded = await loadMergeState(io, home);
180
+ return { ok: true, schema: 3, migrated: migration.migrated, state: { schemaVersion: 3, rows: loaded.state.rows.map(legacyToV3).filter((row) => row !== null) }, quarantine: [], report: null };
185
181
  })().catch(error => ({ ok: false, code: 'merge-state-migration-blocked', message: error instanceof Error ? error.message : String(error) }));
186
- const homeFence = ctx.get('evolutionMutationAuthority')?.homeFence;
187
182
  const runs = newRunRegistry({ io, home, warn: (message) => { ctx.logger.warn(message); }, ...(homeFence === undefined ? {} : { fence: homeFence }) });
188
183
  ctx.effect(() => {
189
184
  const host = ctx;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-merge",
3
3
  "description": "Skill auto-merge domain: screening, two-round authoring, queues and commit orchestration (community build)",
4
- "version": "0.48.0",
4
+ "version": "0.49.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -13,6 +13,9 @@
13
13
  "type": "module",
14
14
  "main": "lib/index.js",
15
15
  "types": "lib/types/index.d.ts",
16
+ "bin": {
17
+ "dsh-evolution-merge-migrate": "scripts/merge-migrate.mjs"
18
+ },
16
19
  "exports": {
17
20
  ".": {
18
21
  "types": "./lib/types/index.d.ts",
@@ -26,6 +29,9 @@
26
29
  "lib/**/*.js",
27
30
  "lib/types/**/*.d.ts",
28
31
  "data/**/*",
32
+ "scripts/merge-migrate.mjs",
33
+ "scripts/merge-restore.mjs",
34
+ "scripts/merge-backup.mjs",
29
35
  "package.json"
30
36
  ],
31
37
  "license": "MIT",
@@ -34,9 +40,9 @@
34
40
  },
35
41
  "dependencies": {
36
42
  "@deepseek-ai/schemastery": "^3.18.5",
37
- "@lmzhen/dsh-evolution-core": "^0.48.0",
38
- "@lmzhen/dsh-evolution-commands": "^0.48.0",
39
- "@lmzhen/dsh-evolution-merge-engine": "0.48.0"
43
+ "@lmzhen/dsh-evolution-core": "^0.49.0",
44
+ "@lmzhen/dsh-evolution-commands": "^0.49.0",
45
+ "@lmzhen/dsh-evolution-merge-engine": "0.49.0"
40
46
  },
41
47
  "peerDependencies": {
42
48
  "@deepseek-ai/cordis": "^4.0.5"
@@ -0,0 +1,31 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join, relative } from 'node:path'
4
+ const args = new Map()
5
+ for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i], process.argv[i + 1])
6
+ const home = args.get('--home')
7
+ const out = args.get('--out')
8
+ if (!home || !out) { console.error('usage: node scripts/merge-backup.mjs --home <home> --out <dir> [--quiesce <json>]'); process.exit(2) }
9
+ const quiesce = args.get('--quiesce')
10
+ if (!quiesce) { console.error('backup refused: --quiesce <json> required (stop plugin or fence writer)'); process.exit(2) }
11
+ try { if (JSON.parse(readFileSync(quiesce, 'utf8')).stopped !== true) { console.error('backup refused: writer not stopped'); process.exit(3) } } catch { console.error('backup refused: unreadable quiesce marker'); process.exit(3) }
12
+ const sha = file => createHash('sha256').update(readFileSync(file)).digest('hex')
13
+ const roots = ['merge/merge-state.json', 'runs.json', 'merge/receipts', 'merge/journals', 'merge/runs', 'merge/snapshots']
14
+ const files = []
15
+ function walk(path) {
16
+ if (!existsSync(path)) return
17
+ const stat = statSync(path)
18
+ if (stat.isDirectory()) { for (const name of readdirSync(path)) walk(join(path, name)); return }
19
+ const rel = relative(home, path).replace(/\\/g, '/')
20
+ files.push({ path: rel, bytes: stat.size, sha256: sha(path) })
21
+ }
22
+ for (const root of roots) walk(join(home, root))
23
+ mkdirSync(out, { recursive: true })
24
+ for (const file of files) {
25
+ const target = join(out, file.path)
26
+ mkdirSync(dirname(target), { recursive: true })
27
+ copyFileSync(join(home, file.path), target)
28
+ }
29
+ const manifest = { schemaVersion: 1, createdAt: new Date().toISOString(), home, quiesce: { path: quiesce, sha256: sha(quiesce) }, files }
30
+ writeFileSync(join(out, 'backup-manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
31
+ console.log('MERGE BACKUP OK files=' + files.length + ' out=' + out)
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-time merge state migration: v1/v2 -> v3 -> v4, with a home-level lock.
4
+ *
5
+ * The runtime used to do v2->v3 on mount and v3->v4 on the first write. That left the old
6
+ * readers in the hot path and gave an operator no backup/dry-run/restore story. This CLI is
7
+ * the explicit entry: it reuses the SAME domain migrations (ensureMergeStateReady and
8
+ * normalizePairLedger), so it cannot drift from the runtime.
9
+ *
10
+ * Usage:
11
+ * merge-migrate --home <dir> [--dry-run] [--apply] [--restore <backup-dir>] [--json]
12
+ *
13
+ * Stop every host that has this home open before --apply/--restore: the lock only serializes
14
+ * this CLI, not a mounted plugin.
15
+ */
16
+ import { spawnSync } from 'node:child_process'
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
18
+ import { dirname, join, resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const scriptDir = dirname(fileURLToPath(import.meta.url))
22
+ const argv = process.argv.slice(2)
23
+ const value = (flag) => {
24
+ const index = argv.indexOf(flag)
25
+ return index >= 0 ? argv[index + 1] : undefined
26
+ }
27
+ const homeArg = value('--home')
28
+ if (homeArg === undefined || homeArg === '') {
29
+ console.error('usage: merge-migrate --home <dir> [--dry-run] [--apply] [--restore <backup-dir>] [--json]')
30
+ process.exit(2)
31
+ }
32
+ const home = resolve(homeArg)
33
+ const apply = argv.includes('--apply')
34
+ const restoreAt = argv.indexOf('--restore')
35
+ const restore = restoreAt >= 0 ? argv[restoreAt + 1] : undefined
36
+ if (restoreAt >= 0 && (restore === undefined || restore === '' || restore.startsWith('--'))) {
37
+ console.error('usage: merge-migrate --home <dir> [--dry-run] [--apply] [--restore <backup-dir>] [--json]')
38
+ process.exit(2)
39
+ }
40
+ const dryRun = argv.includes('--dry-run') || (!apply && restoreAt < 0)
41
+ const asJson = argv.includes('--json')
42
+ const lockDir = join(home, 'merge', '.migrate-lock')
43
+ const LOCK_TTL_MS = 60 * 60 * 1000
44
+
45
+ function pidAlive(pid) {
46
+ try {
47
+ process.kill(pid, 0)
48
+ return true
49
+ } catch (error) {
50
+ return error?.code === 'EPERM'
51
+ }
52
+ }
53
+
54
+ function acquireLock() {
55
+ mkdirSync(join(home, 'merge'), { recursive: true })
56
+ for (let attempt = 0; attempt < 2; attempt += 1) {
57
+ try {
58
+ mkdirSync(lockDir)
59
+ writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + '\n')
60
+ return
61
+ } catch (error) {
62
+ if (error?.code !== 'EEXIST') throw error
63
+ let owner = null
64
+ try { owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8')) } catch { /* stale/incomplete */ }
65
+ const age = Date.now() - statSync(lockDir).mtimeMs
66
+ if (owner !== null && pidAlive(owner.pid) && age < LOCK_TTL_MS) {
67
+ const failure = { ok: false, code: 'migration-locked', message: 'another process owns ' + lockDir + ' (pid ' + String(owner.pid) + ')' }
68
+ if (asJson) console.log(JSON.stringify(failure, null, 2))
69
+ else console.error('merge-migrate: ' + failure.message)
70
+ process.exit(3)
71
+ }
72
+ rmSync(lockDir, { recursive: true, force: true })
73
+ }
74
+ }
75
+ throw new Error('could not acquire ' + lockDir)
76
+ }
77
+
78
+ if (restore !== undefined && restore !== '') {
79
+ if (dryRun && !apply) {
80
+ const report = { ok: true, dryRun: true, restore }
81
+ if (asJson) console.log(JSON.stringify(report, null, 2))
82
+ else console.log('DRY RUN: would restore ' + restore + ' into ' + home + ' (stop the host first)')
83
+ process.exit(0)
84
+ }
85
+ acquireLock()
86
+ let restoreStatus = 1
87
+ try {
88
+ const result = spawnSync(process.execPath, [join(scriptDir, 'merge-restore.mjs'), '--home', home, '--backup', restore], { stdio: 'inherit' })
89
+ restoreStatus = result.status ?? 1
90
+ } finally {
91
+ rmSync(lockDir, { recursive: true, force: true })
92
+ }
93
+ process.exit(restoreStatus)
94
+ }
95
+
96
+ async function inspect() {
97
+ let raw = null
98
+ try { raw = readFileSync(join(home, 'merge', 'merge-state.json'), 'utf8') } catch { /* unreadable is reported below */ }
99
+ let schema = null
100
+ let rows = 0
101
+ try {
102
+ const parsed = raw === null ? null : JSON.parse(raw)
103
+ schema = parsed?.schemaVersion ?? null
104
+ rows = Array.isArray(parsed?.rows) ? parsed.rows.length : 0
105
+ } catch { /* malformed is a blocker */ }
106
+ const v4 = schema === 4
107
+ ? { ok: true, rows }
108
+ : { ok: false, code: 'merge-state-migration-required', message: 'state is not v4' }
109
+ const v3 = schema === 3
110
+ ? { ok: true, rows }
111
+ : { ok: false, code: 'merge-state-migration-required', message: 'state is not v3 readable' }
112
+ return { home, state: { present: raw !== null, schema, rows }, v4, v3 }
113
+ }
114
+
115
+ if (dryRun && !apply) {
116
+ const report = { ok: true, dryRun: true, ...await inspect() }
117
+ if (asJson) console.log(JSON.stringify(report, null, 2))
118
+ else console.log('DRY RUN ' + JSON.stringify(report.state) + ' v4=' + JSON.stringify(report.v4) + ' v3=' + JSON.stringify(report.v3))
119
+ process.exit(0)
120
+ }
121
+
122
+ const core = await import('@lmzhen/dsh-evolution-core')
123
+ const io = core.nodeEvolutionIo()
124
+ const { migrateMergeStateToV4 } = await import('../lib/types/domain/state-migrate.js')
125
+ const { readV4Readiness } = await import('../lib/types/domain/schema4.js')
126
+
127
+ acquireLock()
128
+ let exitCode = 0
129
+ try {
130
+ const before = await inspect()
131
+ const beforeV4 = await readV4Readiness(io, home)
132
+ if (before.state.schema === 4 && beforeV4.ok) {
133
+ const report = { ok: true, applied: false, reason: 'already-v4', before }
134
+ if (asJson) console.log(JSON.stringify(report, null, 2))
135
+ else console.log('MIGRATE OK already v4 rows=' + String(beforeV4.state.rows.length))
136
+ } else {
137
+ const backupDir = value('--backup') ?? join(home, 'merge', '.backup', 'pre-migrate-' + new Date().toISOString().replace(/[:.]/g, '-'))
138
+ const quiesce = join(home, 'merge', '.migrate-quiesce.json')
139
+ writeFileSync(quiesce, JSON.stringify({ stopped: true, at: new Date().toISOString() }) + '\n')
140
+ try {
141
+ const backedUp = spawnSync(process.execPath, [join(scriptDir, 'merge-backup.mjs'), '--home', home, '--out', backupDir, '--quiesce', quiesce], { stdio: 'inherit' })
142
+ if (backedUp.status !== 0) throw new Error('backup failed with exit ' + String(backedUp.status))
143
+ } finally {
144
+ rmSync(quiesce, { force: true })
145
+ }
146
+ const migration = await migrateMergeStateToV4(io, home)
147
+ if (!migration.ok) throw new Error(migration.code + ': ' + migration.message)
148
+ const after = await inspect()
149
+ const afterV4 = await readV4Readiness(io, home)
150
+ if (!afterV4.ok) throw new Error(afterV4.code + ': ' + afterV4.message)
151
+ const report = { ok: true, applied: true, migrated: migration.migrated, backupRef: migration.backupRef, backupDir, before, after }
152
+ if (asJson) console.log(JSON.stringify(report, null, 2))
153
+ else console.log('MIGRATE OK v2/v3 -> v4 rows=' + String(afterV4.state.rows.length) + ' backup=' + backupDir + (migration.backupRef === undefined ? '' : ' pre1b=' + migration.backupRef))
154
+ }
155
+ } catch (error) {
156
+ const failure = { ok: false, code: 'migration-failed', message: error instanceof Error ? error.message : String(error) }
157
+ if (asJson) console.log(JSON.stringify(failure, null, 2))
158
+ else console.error('merge-migrate: ' + failure.message)
159
+ exitCode = 1
160
+ } finally {
161
+ rmSync(lockDir, { recursive: true, force: true })
162
+ }
163
+ process.exit(exitCode)
@@ -0,0 +1,36 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
3
+ import { dirname, join, resolve, sep } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ const args = new Map()
6
+ for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i], process.argv[i + 1])
7
+ const home = args.get('--home')
8
+ const backup = args.get('--backup')
9
+ if (!home || !backup) { console.error('usage: node scripts/merge-restore.mjs --home <home> --backup <dir>'); process.exit(2) }
10
+ const manifest = JSON.parse(readFileSync(join(backup, 'backup-manifest.json'), 'utf8'))
11
+ const sha = file => createHash('sha256').update(readFileSync(file)).digest('hex')
12
+ const safe = rel => { const target = resolve(home, rel); const base = resolve(home) + sep; if (!target.startsWith(base)) throw new Error('unsafe backup path: ' + rel); return target }
13
+ for (const file of manifest.files) {
14
+ const source = join(backup, file.path)
15
+ if (!existsSync(source)) throw new Error('missing backup file: ' + file.path)
16
+ if (sha(source) !== file.sha256 || statSync(source).size !== file.bytes) throw new Error('backup hash mismatch: ' + file.path)
17
+ safe(file.path)
18
+ }
19
+ const temp = mkdtempSync(join(tmpdir(), 'dsh-merge-restore-'))
20
+ try {
21
+ for (const file of manifest.files) {
22
+ const source = join(backup, file.path)
23
+ const staged = join(temp, file.path)
24
+ mkdirSync(dirname(staged), { recursive: true })
25
+ copyFileSync(source, staged)
26
+ }
27
+ for (const file of manifest.files) {
28
+ const target = safe(file.path)
29
+ mkdirSync(dirname(target), { recursive: true })
30
+ if (existsSync(target)) renameSync(target, target + '.pre-restore')
31
+ copyFileSync(join(temp, file.path), target)
32
+ }
33
+ console.log('MERGE RESTORE OK files=' + manifest.files.length)
34
+ } finally {
35
+ rmSync(temp, { recursive: true, force: true })
36
+ }