pantheon-opencode 1.6.0-beta.10 → 1.6.0-beta.12

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 (71) hide show
  1. package/AGENTS.global.md +42 -18
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +122 -38
  5. package/README.pt-BR.md +5 -4
  6. package/bin/pantheon-init.mjs +322 -101
  7. package/package.json +7 -6
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +128 -10
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +648 -0
  14. package/scripts/install/cli-ui.mjs +79 -1
  15. package/scripts/install/exit-codes.mjs +26 -0
  16. package/scripts/install/file-inventory.mjs +555 -0
  17. package/scripts/install/interactive.mjs +15 -1
  18. package/scripts/install/migrate.mjs +32 -6
  19. package/scripts/install/model-picker.mjs +63 -22
  20. package/scripts/install/opencode-version.mjs +9 -1
  21. package/scripts/install/opencode.mjs +761 -63
  22. package/scripts/install/plan-artifact.mjs +517 -0
  23. package/scripts/install/plan.mjs +449 -0
  24. package/scripts/install/plugin.mjs +136 -18
  25. package/scripts/install/shared.mjs +249 -94
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/strings.mjs +20 -0
  28. package/scripts/install/venv.mjs +25 -6
  29. package/scripts/install/verify.mjs +128 -0
  30. package/scripts/install/vision-prompt.mjs +13 -1
  31. package/scripts/install-mcp.mjs +1 -1
  32. package/scripts/manifest-inventory.mjs +49 -3
  33. package/scripts/mcp_persistence.py +50 -15
  34. package/scripts/node-engine-policy.mjs +74 -0
  35. package/scripts/package-evidence.mjs +6 -0
  36. package/scripts/postinstall.mjs +3 -4
  37. package/scripts/prune.mjs +9 -3
  38. package/scripts/reconcile.mjs +325 -0
  39. package/scripts/restore.mjs +222 -0
  40. package/scripts/uninstall.mjs +258 -34
  41. package/scripts/update.mjs +164 -0
  42. package/scripts/validate-node-engine-policy.mjs +13 -0
  43. package/scripts/version-check.mjs +34 -1
  44. package/scripts/versioning.mjs +9 -1
  45. package/skills-lock.json +4 -4
  46. package/src/agents/iris.md +6 -0
  47. package/src/agents/mnemosyne.md +7 -2
  48. package/src/agents/zeus.md +19 -62
  49. package/src/instructions/agent-return-format.instructions.md +17 -15
  50. package/src/instructions/memory-protocol.instructions.md +3 -2
  51. package/src/instructions/test-economy.instructions.md +21 -0
  52. package/src/instructions/zeus-anti-stall.instructions.md +6 -6
  53. package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
  54. package/src/instructions/zeus-memory-operations.instructions.md +11 -6
  55. package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
  56. package/src/mcp/mcp_persistence.py +50 -15
  57. package/src/pantheon/cost-command.ts +5 -2
  58. package/src/pantheon/model-agents.ts +19 -0
  59. package/src/pantheon/model-command.ts +218 -56
  60. package/src/pantheon/v2-model-overrides.ts +275 -0
  61. package/src/pantheon/v2-tools.ts +18 -29
  62. package/src/pantheon/v2-unsupported.mjs +2 -3
  63. package/src/plugin-v2.ts +13 -0
  64. package/src/plugins/tui/dist/tui-v2.js +2 -2
  65. package/src/plugins/tui/dist/tui.js +1 -1
  66. package/src/plugins/tui/package-lock.json +3 -3
  67. package/src/plugins/tui/package.json +2 -2
  68. package/src/skills/artifact-management/SKILL.md +5 -1
  69. package/src/skills/auto-continue/SKILL.md +14 -6
  70. package/src/skills/memory-bank/SKILL.md +5 -1
  71. package/src/skills/worktrees/SKILL.md +30 -11
@@ -0,0 +1,648 @@
1
+ /**
2
+ * backup.mjs — Exact backup sets for the Pantheon installer (Beta 11 Setup,
3
+ * phase 2).
4
+ *
5
+ * Before the installer modifies or creates a file, the CURRENT state of every
6
+ * affected path is preserved byte-for-byte under
7
+ * `<configDir>/.pantheon/backups/<UTC-timestamp>/`, mirroring the path relative
8
+ * to `configDir`, and described by a `manifest.json`. Files that do not exist
9
+ * yet are recorded as absent so a restore can reproduce the original state
10
+ * exactly.
11
+ *
12
+ * SECURITY
13
+ * --------
14
+ * A backup is an exact copy, so it can contain API keys or tokens that were
15
+ * already in the user's files. This module therefore:
16
+ * - never prints file contents, values or hashes (only paths / counts / modes);
17
+ * - writes the backup directory `0700` and every file `0600`.
18
+ * Windows is best-effort: `chmodSync` is attempted and a failure never aborts.
19
+ *
20
+ * SCOPE
21
+ * -----
22
+ * IN scope (byte-for-byte, restorable): every regular file the install can
23
+ * modify, rewrite, or remove INSIDE the config dir — including stale files that
24
+ * a `--clean` run deletes.
25
+ * OUT of scope (recorded in `manifest.notRevertible`, never restored): bulk
26
+ * directories whose byte-for-byte copy is not feasible (`.venv`, the plugin
27
+ * dir with its `node_modules`) and files OUTSIDE the config dir (the global
28
+ * `tui.json` copies and the global `opencode-vision.json`). The manifest states
29
+ * these explicitly rather than silently omitting them.
30
+ *
31
+ * The functions are `pure-ish`: they take explicit paths and an injectable
32
+ * `clock`, return data (never logging), and are safe to unit-test without a
33
+ * running installer.
34
+ */
35
+
36
+ import { createHash } from 'node:crypto'
37
+ import {
38
+ chmodSync,
39
+ existsSync,
40
+ lstatSync,
41
+ mkdirSync,
42
+ readdirSync,
43
+ readFileSync,
44
+ realpathSync,
45
+ rmSync,
46
+ statSync,
47
+ writeFileSync,
48
+ } from 'node:fs'
49
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
50
+ import { assertNoSymlinkAncestors, verifyRegularFile, writeFileAtomically } from './verify.mjs'
51
+
52
+ /** Directory (relative to the config dir) that holds the backup sets. */
53
+ export const BACKUP_SUBDIR = join('.pantheon', 'backups')
54
+
55
+ /** Number of most-recent backup sets retained. */
56
+ export const DEFAULT_KEEP = 5
57
+
58
+ /** UTC timestamp directory name: `YYYY-MM-DDTHH-MM-SSZ` (filesystem-safe). */
59
+ const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/
60
+
61
+ /**
62
+ * Format a Date as the backup-directory timestamp.
63
+ * @param {Date} date
64
+ * @returns {string}
65
+ */
66
+ export function formatTimestamp(date = new Date()) {
67
+ return date
68
+ .toISOString()
69
+ .replace(/\.\d+Z$/, 'Z')
70
+ .replace(/:/g, '-')
71
+ }
72
+
73
+ /**
74
+ * Whether a directory name is one of our timestamped backup sets. Used as the
75
+ * guard so retention can never delete a directory we did not create.
76
+ * @param {string} name
77
+ * @returns {boolean}
78
+ */
79
+ export function isBackupDirName(name) {
80
+ return typeof name === 'string' && TIMESTAMP_RE.test(name)
81
+ }
82
+
83
+ /** Absolute path of the backups root for a config dir. */
84
+ export function backupRoot(configDir) {
85
+ return join(configDir, BACKUP_SUBDIR)
86
+ }
87
+
88
+ /** Absolute path of a named backup set. */
89
+ export function backupSetPath(configDir, name) {
90
+ return join(backupRoot(configDir), name)
91
+ }
92
+
93
+ /**
94
+ * Whether `candidate` is a direct child of `root` (no traversal). Pure string
95
+ * check — the retention path guard.
96
+ * @param {string} root
97
+ * @param {string} candidate
98
+ * @returns {boolean}
99
+ */
100
+ export function isWithin(root, candidate) {
101
+ const rel = relative(resolve(root), resolve(candidate))
102
+ if (rel === '') return false
103
+ return (
104
+ !rel.startsWith('..') && !rel.split(sep).includes('..') && !resolve(candidate).startsWith('..')
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Guard for a deletable backup set: it must live directly under
110
+ * `<configDir>/.pantheon/backups/` AND its name must match the timestamp
111
+ * pattern. Nothing else may ever be removed by retention.
112
+ * @param {string} configDir
113
+ * @param {string} dir
114
+ * @returns {boolean}
115
+ */
116
+ export function isDeletableBackupDir(configDir, dir) {
117
+ const root = backupRoot(configDir)
118
+ if (!isWithin(root, dir)) return false
119
+ const rel = relative(resolve(root), resolve(dir))
120
+ return isBackupDirName(rel)
121
+ }
122
+
123
+ /** List backup sets, newest first. */
124
+ export function listBackupSets(configDir) {
125
+ const root = backupRoot(configDir)
126
+ if (!existsSync(root)) return []
127
+ const sets = []
128
+ for (const name of readdirSync(root)) {
129
+ if (!isBackupDirName(name)) continue
130
+ const dir = join(root, name)
131
+ try {
132
+ if (!statSync(dir).isDirectory()) continue
133
+ } catch {
134
+ continue
135
+ }
136
+ sets.push({ name, dir })
137
+ }
138
+ sets.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
139
+ return sets
140
+ }
141
+
142
+ /**
143
+ * Choose the next free backup-set name for `configDir`. Second-granularity names
144
+ * collide when two sets are created in the same second (apply immediately
145
+ * followed by a pre-restore snapshot); the seconds are bumped until the name is
146
+ * free so a new set never overwrites an existing one. Shared by the apply path
147
+ * and the dry-run preview so both agree.
148
+ *
149
+ * @param {string} configDir
150
+ * @param {Date} [base]
151
+ * @returns {string}
152
+ */
153
+ export function nextBackupName(configDir, base = new Date()) {
154
+ let name = formatTimestamp(base)
155
+ let bump = 0
156
+ while (existsSync(backupSetPath(configDir, name))) {
157
+ bump += 1
158
+ name = formatTimestamp(new Date(base.getTime() + bump * 1000))
159
+ }
160
+ return name
161
+ }
162
+
163
+ /** sha256 hex of a buffer. */
164
+ function sha256(buffer) {
165
+ return createHash('sha256').update(buffer).digest('hex')
166
+ }
167
+
168
+ function chmodBestEffort(path, mode) {
169
+ try {
170
+ chmodSync(path, mode)
171
+ } catch {
172
+ // Windows / exotic filesystems: best-effort only.
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Create a directory ONLY when it is absent, and never chmod a directory that
178
+ * already exists. Chmodding a pre-existing directory is an irreversible side
179
+ * effect on the user's tree (F1): restoring into a project would otherwise
180
+ * rewrite the project root to 0700. Bulk-directory modes are out of scope for
181
+ * the exact backup (see `notRevertible`).
182
+ */
183
+ function ensureDir(path, mode) {
184
+ assertNoSymlinkAncestors(path)
185
+ let info = null
186
+ try {
187
+ info = lstatSync(path)
188
+ } catch (error) {
189
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error
190
+ }
191
+ if (info) {
192
+ if (!info.isDirectory()) throw new Error(`Backup path is not a directory: ${path}`)
193
+ return
194
+ }
195
+ mkdirSync(path, { recursive: true, mode })
196
+ assertNoSymlinkAncestors(path)
197
+ info = lstatSync(path)
198
+ if (!info.isDirectory() || info.isSymbolicLink()) {
199
+ throw new Error(`Backup path is not a regular directory: ${path}`)
200
+ }
201
+ chmodBestEffort(path, mode)
202
+ }
203
+
204
+ function assertBackupEntry(configDir, relativePath) {
205
+ const rel = String(relativePath)
206
+ if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).some((part) => part === '..' || part === '')) {
207
+ throw new Error(`Unsafe backup entry path: ${rel}`)
208
+ }
209
+ const source = resolve(configDir, rel)
210
+ const fromRoot = relative(resolve(configDir), source)
211
+ if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
212
+ throw new Error(`Backup entry escapes config directory: ${rel}`)
213
+ }
214
+ return { rel, source }
215
+ }
216
+
217
+ /**
218
+ * Create an exact backup set for `entries` (paths relative to `configDir`).
219
+ *
220
+ * A path that exists as a regular file is copied byte-for-byte with mode 0600
221
+ * and hashed; anything else (absent, directory, symlink) is recorded as absent.
222
+ * The whole set is written before this function returns, so a caller can run it
223
+ * BEFORE the first modifying write.
224
+ *
225
+ * @param {object} opts
226
+ * @param {string} opts.configDir
227
+ * @param {string[]} opts.entries - relative paths (deduped, order preserved)
228
+ * @param {string[]} [opts.notRevertible] - paths the run may delete but cannot
229
+ * capture byte-for-byte (bulk dirs like `.venv`/plugin `node_modules`, or
230
+ * out-of-target global files). Recorded in the manifest so the guarantee is
231
+ * honest, never restored.
232
+ * @param {string} [opts.packageVersion]
233
+ * @param {() => Date} [opts.clock]
234
+ * @param {number} [opts.keep=DEFAULT_KEEP] - retained sets (0 = no pruning)
235
+ * @returns {{ dir: string, name: string, manifest: object }}
236
+ */
237
+ export function createBackupSet({
238
+ configDir,
239
+ entries,
240
+ notRevertible = [],
241
+ packageVersion = 'unknown',
242
+ clock = () => new Date(),
243
+ keep = DEFAULT_KEEP,
244
+ } = {}) {
245
+ assertNoSymlinkAncestors(configDir)
246
+ const base = clock()
247
+ const name = nextBackupName(configDir, base)
248
+ const dir = backupSetPath(configDir, name)
249
+ ensureDir(backupRoot(configDir), 0o700)
250
+ ensureDir(dir, 0o700)
251
+
252
+ const seen = new Set()
253
+ const manifestEntries = []
254
+ for (const relativePath of entries ?? []) {
255
+ const { rel, source } = assertBackupEntry(configDir, relativePath)
256
+ if (rel === '' || seen.has(rel)) continue
257
+ seen.add(rel)
258
+ assertNoSymlinkAncestors(source, { includeLeaf: false })
259
+ let stat = null
260
+ try {
261
+ stat = lstatSync(source)
262
+ } catch {
263
+ stat = null
264
+ }
265
+ if (stat?.isFile()) {
266
+ const buffer = readFileSync(source)
267
+ const dest = join(dir, rel)
268
+ ensureDir(dirname(dest), 0o700)
269
+ assertNoSymlinkAncestors(dest)
270
+ writeFileSync(dest, buffer, { mode: 0o600, flag: 'wx' })
271
+ chmodBestEffort(dest, 0o600)
272
+ verifyRegularFile(dest, buffer)
273
+ manifestEntries.push({
274
+ relativePath: rel,
275
+ existed: true,
276
+ sha256: sha256(buffer),
277
+ bytes: buffer.length,
278
+ mode: stat.mode & 0o777,
279
+ })
280
+ } else {
281
+ manifestEntries.push({
282
+ relativePath: rel,
283
+ existed: false,
284
+ sha256: null,
285
+ bytes: 0,
286
+ mode: null,
287
+ })
288
+ }
289
+ }
290
+
291
+ const manifest = {
292
+ createdAt: base.toISOString(),
293
+ packageVersion,
294
+ configDir,
295
+ notRevertible: [...new Set((notRevertible ?? []).map(String))],
296
+ entries: manifestEntries,
297
+ }
298
+ const manifestPath = join(dir, 'manifest.json')
299
+ assertNoSymlinkAncestors(manifestPath)
300
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
301
+ mode: 0o600,
302
+ flag: 'wx',
303
+ })
304
+ chmodBestEffort(manifestPath, 0o600)
305
+ verifyRegularFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
306
+
307
+ const pruned = keep > 0 ? pruneBackups(configDir, keep) : { removed: [], failures: [] }
308
+ return { dir, name, manifest, pruned }
309
+ }
310
+
311
+ /** Build the exact backup outputs without writing files. */
312
+ export function previewBackupSet({
313
+ configDir,
314
+ entries,
315
+ notRevertible = [],
316
+ packageVersion = 'unknown',
317
+ clock = () => new Date(),
318
+ } = {}) {
319
+ const base = clock()
320
+ const name = nextBackupName(configDir, base)
321
+ const dir = backupSetPath(configDir, name)
322
+ const seen = new Set()
323
+ const manifestEntries = []
324
+ const files = []
325
+ for (const relativePath of entries ?? []) {
326
+ const rel = String(relativePath)
327
+ if (rel === '' || seen.has(rel)) continue
328
+ seen.add(rel)
329
+ const source = join(configDir, rel)
330
+ let stat = null
331
+ try {
332
+ stat = lstatSync(source)
333
+ } catch {}
334
+ if (stat?.isFile()) {
335
+ const buffer = readFileSync(source)
336
+ files.push({
337
+ path: join(dir, rel),
338
+ bytes: buffer.length,
339
+ outputFingerprint: sha256(buffer),
340
+ })
341
+ manifestEntries.push({
342
+ relativePath: rel,
343
+ existed: true,
344
+ sha256: sha256(buffer),
345
+ bytes: buffer.length,
346
+ mode: stat.mode & 0o777,
347
+ })
348
+ } else {
349
+ manifestEntries.push({
350
+ relativePath: rel,
351
+ existed: false,
352
+ sha256: null,
353
+ bytes: 0,
354
+ mode: null,
355
+ })
356
+ }
357
+ }
358
+ const manifest = {
359
+ createdAt: base.toISOString(),
360
+ packageVersion,
361
+ configDir,
362
+ notRevertible: [...new Set((notRevertible ?? []).map(String))],
363
+ entries: manifestEntries,
364
+ }
365
+ const manifestContent = `${JSON.stringify(manifest, null, 2)}\n`
366
+ return {
367
+ dir,
368
+ files,
369
+ manifestPath: join(dir, 'manifest.json'),
370
+ manifestBytes: Buffer.byteLength(manifestContent, 'utf8'),
371
+ manifestFingerprint: sha256(Buffer.from(manifestContent)),
372
+ }
373
+ }
374
+
375
+ /**
376
+ * A manifest entry path must be a plain relative path inside the config dir —
377
+ * no absolute path and no `..` segment (the backup mirrors the config dir).
378
+ * Rejected at read time so a crafted manifest can never steer verify or restore
379
+ * outside the tree.
380
+ * @param {unknown} rel
381
+ * @returns {boolean}
382
+ */
383
+ function isSafeRelativePath(rel) {
384
+ if (typeof rel !== 'string' || rel === '') return false
385
+ if (isAbsolute(rel)) return false
386
+ return !rel.split(/[\\/]/).some((part) => part === '' || part === '.' || part === '..')
387
+ }
388
+
389
+ function assertRestoreRoot(configDir) {
390
+ const root = resolve(configDir)
391
+ assertNoSymlinkAncestors(root)
392
+ const info = lstatSync(root)
393
+ if (!info.isDirectory() || info.isSymbolicLink()) {
394
+ throw new Error('Restore config root is not a regular directory')
395
+ }
396
+ return { root, canonicalRoot: realpathSync(root) }
397
+ }
398
+
399
+ function assertRestoreTarget(root, canonicalRoot, relativePath) {
400
+ if (!isSafeRelativePath(relativePath)) throw new Error('Unsafe restore target path')
401
+ const target = resolve(root, relativePath)
402
+ const lexical = relative(root, target)
403
+ if (!lexical || lexical === '..' || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) {
404
+ throw new Error('Restore target escapes config directory')
405
+ }
406
+
407
+ assertNoSymlinkAncestors(target)
408
+
409
+ let existing = target
410
+ let canonicalExisting
411
+ while (true) {
412
+ try {
413
+ canonicalExisting = realpathSync(existing)
414
+ break
415
+ } catch (error) {
416
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error
417
+ const parent = dirname(existing)
418
+ if (parent === existing) throw error
419
+ existing = parent
420
+ }
421
+ }
422
+ const canonicalTarget = resolve(canonicalExisting, relative(existing, target))
423
+ const canonicalRelative = relative(canonicalRoot, canonicalTarget)
424
+ if (
425
+ canonicalRelative === '..' ||
426
+ canonicalRelative.startsWith(`..${sep}`) ||
427
+ isAbsolute(canonicalRelative)
428
+ ) {
429
+ throw new Error('Restore target escapes config directory')
430
+ }
431
+ return target
432
+ }
433
+
434
+ /**
435
+ * Read and validate the manifest of a backup set.
436
+ *
437
+ * VALIDATION SCOPE (documented, not implied): this rejects a malformed manifest
438
+ * and any entry whose `relativePath` is absolute or contains `..` (fail closed).
439
+ * It does NOT make the manifest self-verifying — a DROPPED entry or a flipped
440
+ * `existed` flag cannot be detected from the manifest alone. Tampering with the
441
+ * CONTENT of a listed file (newline/truncation/hash change) IS detected by
442
+ * {@link verifyBackupSet}, because each listed file is re-hashed.
443
+ *
444
+ * @param {string} dir
445
+ * @returns {{ ok: true, manifest: object } | { ok: false, reason: string }}
446
+ */
447
+ export function readManifest(dir) {
448
+ const manifestPath = join(dir, 'manifest.json')
449
+ if (!existsSync(manifestPath)) return { ok: false, reason: 'missing-manifest' }
450
+ try {
451
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
452
+ if (!manifest || !Array.isArray(manifest.entries)) {
453
+ return { ok: false, reason: 'invalid-manifest' }
454
+ }
455
+ if (manifest.entries.some((e) => !e || !isSafeRelativePath(e.relativePath))) {
456
+ return { ok: false, reason: 'unsafe-path' }
457
+ }
458
+ return { ok: true, manifest }
459
+ } catch {
460
+ return { ok: false, reason: 'invalid-manifest' }
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Verify every backed-up file still matches its manifest hash. Any missing file
466
+ * or hash mismatch is reported (no silent partial restore).
467
+ * @param {string} dir
468
+ * @returns {{ ok: boolean, reason?: string, mismatches: string[] }}
469
+ */
470
+ export function verifyBackupSet(dir) {
471
+ const read = readManifest(dir)
472
+ if (!read.ok) return { ok: false, reason: read.reason, mismatches: [] }
473
+ const mismatches = []
474
+ for (const entry of read.manifest.entries) {
475
+ if (!entry.existed) continue
476
+ const file = join(dir, entry.relativePath)
477
+ if (!existsSync(file)) {
478
+ mismatches.push(entry.relativePath)
479
+ continue
480
+ }
481
+ if (sha256(readFileSync(file)) !== entry.sha256) mismatches.push(entry.relativePath)
482
+ }
483
+ return { ok: mismatches.length === 0, mismatches }
484
+ }
485
+
486
+ /** Order for writes: `opencode.json` strictly last. */
487
+ function restoreOrder(a, b) {
488
+ const last = (rel) => (rel === 'opencode.json' ? 1 : 0)
489
+ if (last(a.relativePath) !== last(b.relativePath))
490
+ return last(a.relativePath) - last(b.relativePath)
491
+ return a.relativePath.localeCompare(b.relativePath)
492
+ }
493
+
494
+ /**
495
+ * Compute the restore plan by PATH ONLY (no contents exposed). Absent-in-backup
496
+ * entries that now exist become `remove`; a present target is hashed internally
497
+ * to classify it as `unchanged` or `update`. Hashes are never returned or
498
+ * printed at this layer — only `{ relativePath, action }`.
499
+ *
500
+ * @param {string} dir - backup set directory
501
+ * @param {string} configDir
502
+ * @returns {{ entries: Array<{relativePath: string, action: string}>, manifest: object } | { error: string }}
503
+ */
504
+ export function planRestore(dir, configDir) {
505
+ let root
506
+ let canonicalRoot
507
+ try {
508
+ ;({ root, canonicalRoot } = assertRestoreRoot(configDir))
509
+ } catch {
510
+ return { error: 'unsafe-target-root' }
511
+ }
512
+ const read = readManifest(dir)
513
+ if (!read.ok) return { error: read.reason }
514
+ const verify = verifyBackupSet(dir)
515
+ if (!verify.ok) return { error: verify.reason ?? 'integrity', mismatches: verify.mismatches }
516
+
517
+ try {
518
+ for (const entry of read.manifest.entries) {
519
+ assertRestoreTarget(root, canonicalRoot, entry.relativePath)
520
+ }
521
+ } catch {
522
+ return { error: 'unsafe-target-path' }
523
+ }
524
+
525
+ const entries = []
526
+ for (const entry of [...read.manifest.entries].sort(restoreOrder)) {
527
+ const target = assertRestoreTarget(root, canonicalRoot, entry.relativePath)
528
+ if (entry.existed) {
529
+ let action = 'create'
530
+ try {
531
+ const st = lstatSync(target)
532
+ if (st.isFile()) {
533
+ assertRestoreTarget(root, canonicalRoot, entry.relativePath)
534
+ const current = readFileSync(target)
535
+ action = sha256(current) === entry.sha256 ? 'unchanged' : 'update'
536
+ } else {
537
+ action = 'update'
538
+ }
539
+ } catch {
540
+ action = 'create'
541
+ }
542
+ entries.push({ relativePath: entry.relativePath, action })
543
+ } else {
544
+ assertRestoreTarget(root, canonicalRoot, entry.relativePath)
545
+ const exists = existsSync(target)
546
+ entries.push({ relativePath: entry.relativePath, action: exists ? 'remove' : 'unchanged' })
547
+ }
548
+ }
549
+ return { entries, manifest: read.manifest }
550
+ }
551
+
552
+ /**
553
+ * Restore a verified backup set into `configDir`. Refuses (throws) if the
554
+ * manifest is missing/invalid or any backed-up file is tampered.
555
+ *
556
+ * @param {string} dir
557
+ * @param {string} configDir
558
+ * @param {{ dryRun?: boolean }} [opts]
559
+ * @returns {{ entries: Array<{relativePath: string, action: string}> }}
560
+ */
561
+ export function restoreBackupSet(dir, configDir, { dryRun = false } = {}) {
562
+ const plan = planRestore(dir, configDir)
563
+ if (plan.error) {
564
+ const detail = plan.mismatches?.length ? ` (${plan.mismatches.length} mismatch(es))` : ''
565
+ throw new Error(`Backup is not restorable: ${plan.error}${detail}`)
566
+ }
567
+ const { root, canonicalRoot } = assertRestoreRoot(configDir)
568
+ for (const item of plan.entries) {
569
+ assertRestoreTarget(root, canonicalRoot, item.relativePath)
570
+ }
571
+ if (dryRun) return { entries: plan.entries }
572
+
573
+ for (const item of plan.entries) {
574
+ const backUpFile = join(dir, item.relativePath)
575
+ const target = assertRestoreTarget(root, canonicalRoot, item.relativePath)
576
+ if (item.action === 'unchanged') continue
577
+
578
+ let linkStat = null
579
+ try {
580
+ linkStat = lstatSync(target)
581
+ } catch {
582
+ linkStat = null
583
+ }
584
+ if (linkStat?.isSymbolicLink())
585
+ throw new Error(`Refusing to restore through symlink: ${item.relativePath}`)
586
+
587
+ if (item.action === 'remove') {
588
+ if (linkStat?.isFile()) {
589
+ assertRestoreTarget(root, canonicalRoot, item.relativePath)
590
+ const current = lstatSync(target)
591
+ if (current.isSymbolicLink()) {
592
+ throw new Error(`Refusing to restore through symlink: ${item.relativePath}`)
593
+ }
594
+ if (current.isFile()) rmSync(target, { force: true })
595
+ }
596
+ continue
597
+ }
598
+
599
+ const buffer = readFileSync(backUpFile)
600
+ ensureDir(dirname(target), 0o700)
601
+ assertRestoreTarget(root, canonicalRoot, item.relativePath)
602
+ writeFileAtomically(target, buffer)
603
+ const recorded = plan.manifest.entries.find((e) => e.relativePath === item.relativePath)
604
+ if (recorded && recorded.mode !== null) {
605
+ assertRestoreTarget(root, canonicalRoot, item.relativePath)
606
+ chmodBestEffort(target, recorded.mode)
607
+ }
608
+ }
609
+ return { entries: plan.entries }
610
+ }
611
+
612
+ /**
613
+ * Delete all but the `keep` most-recent backup sets under the config dir.
614
+ * Only directories that pass {@link isDeletableBackupDir} are ever removed;
615
+ * deletion failures are reported, never thrown.
616
+ *
617
+ * @param {string} configDir
618
+ * @param {number} [keep=DEFAULT_KEEP]
619
+ * @returns {{ removed: string[], failures: string[] }}
620
+ */
621
+ export function pruneBackups(configDir, keep = DEFAULT_KEEP) {
622
+ const removed = []
623
+ const failures = []
624
+ const sets = listBackupSets(configDir)
625
+ for (const set of sets.slice(keep)) {
626
+ if (!isDeletableBackupDir(configDir, set.dir)) continue
627
+ try {
628
+ rmSync(set.dir, { recursive: true, force: true })
629
+ removed.push(set.name)
630
+ } catch {
631
+ failures.push(set.name)
632
+ }
633
+ }
634
+ return { removed, failures }
635
+ }
636
+
637
+ /**
638
+ * The one-time, exact, secret-safe warning printed before the installer applies.
639
+ * @param {string} backupPath
640
+ * @returns {string}
641
+ */
642
+ export function backupWarning(backupPath) {
643
+ return (
644
+ `Backup created at ${backupPath} (mode 0600). It is an exact copy of your ` +
645
+ `current files and therefore also contains any API keys or tokens already ` +
646
+ `present in them. It is never printed. Restore with: pantheon-opencode restore`
647
+ )
648
+ }