pantheon-opencode 1.6.0-beta.11 → 1.6.0-beta.13

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 (53) hide show
  1. package/AGENTS.global.md +3 -3
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +85 -31
  5. package/README.pt-BR.md +7 -4
  6. package/bin/pantheon-init.mjs +278 -102
  7. package/package.json +5 -3
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +77 -13
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +191 -18
  14. package/scripts/install/cli-ui.mjs +73 -0
  15. package/scripts/install/file-inventory.mjs +555 -0
  16. package/scripts/install/interactive.mjs +69 -391
  17. package/scripts/install/migrate.mjs +45 -38
  18. package/scripts/install/model-picker.mjs +63 -22
  19. package/scripts/install/opencode-version.mjs +9 -1
  20. package/scripts/install/opencode.mjs +279 -79
  21. package/scripts/install/plan-artifact.mjs +517 -0
  22. package/scripts/install/plan.mjs +165 -3
  23. package/scripts/install/plugin.mjs +118 -14
  24. package/scripts/install/prompt-result.mjs +2 -0
  25. package/scripts/install/shared.mjs +188 -65
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/venv.mjs +15 -6
  28. package/scripts/install/verify.mjs +128 -0
  29. package/scripts/install/vision-prompt.mjs +2 -0
  30. package/scripts/install-mcp.mjs +1 -1
  31. package/scripts/manifest-inventory.mjs +49 -3
  32. package/scripts/node-engine-policy.mjs +85 -0
  33. package/scripts/package-evidence.mjs +6 -0
  34. package/scripts/postinstall.mjs +26 -26
  35. package/scripts/reconcile.mjs +325 -0
  36. package/scripts/sync-tui.mjs +5 -1
  37. package/scripts/uninstall.mjs +258 -34
  38. package/scripts/update.mjs +164 -0
  39. package/scripts/validate-node-engine-policy.mjs +13 -0
  40. package/scripts/version-check.mjs +34 -1
  41. package/scripts/versioning.mjs +9 -1
  42. package/skills-lock.json +3 -3
  43. package/src/agents/mnemosyne.md +7 -2
  44. package/src/instructions/memory-protocol.instructions.md +2 -2
  45. package/src/instructions/zeus-memory-operations.instructions.md +4 -3
  46. package/src/pantheon/v2-unsupported.mjs +2 -3
  47. package/src/plugins/tui/dist/tui-v2.js +2 -2
  48. package/src/plugins/tui/dist/tui.js +1 -1
  49. package/src/plugins/tui/package-lock.json +3 -3
  50. package/src/plugins/tui/package.json +2 -2
  51. package/src/skills/artifact-management/SKILL.md +5 -1
  52. package/src/skills/memory-bank/SKILL.md +5 -1
  53. package/src/skills/worktrees/SKILL.md +30 -11
@@ -4,9 +4,12 @@
4
4
 
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import {
7
+ chmodSync,
8
+ closeSync,
7
9
  existsSync,
8
10
  lstatSync,
9
11
  mkdirSync,
12
+ openSync,
10
13
  readdirSync,
11
14
  readFileSync,
12
15
  renameSync,
@@ -16,16 +19,34 @@ import {
16
19
  } from 'node:fs'
17
20
  import { dirname, join } from 'node:path'
18
21
  import { fileURLToPath } from 'node:url'
22
+ import {
23
+ isSupportedNodeVersion,
24
+ MIN_SUPPORTED_NODE_VERSION,
25
+ unsupportedNodeVersionMessage,
26
+ } from '../node-engine-policy.mjs'
19
27
 
20
- const REQUIRED_NODE_MAJOR = 18
21
- if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
22
- console.error(`❌ Node.js >= ${REQUIRED_NODE_MAJOR} required (current: ${process.versions.node})`)
28
+ if (!isSupportedNodeVersion(process.versions.node)) {
29
+ console.error(unsupportedNodeVersionMessage(process.versions.node))
23
30
  process.exit(1)
24
31
  }
25
32
 
26
33
  import { dump, load } from 'js-yaml'
27
34
 
28
- import { ACTIONS, isRecording, recordChange } from './plan.mjs'
35
+ import {
36
+ ACTIONS,
37
+ fingerprintOutput,
38
+ isRecording,
39
+ recordChange,
40
+ recordRemovalTree,
41
+ removeReviewedTree,
42
+ } from './plan.mjs'
43
+ import {
44
+ assertNoSymlinkAncestors,
45
+ assertPathWithin,
46
+ assertSafeRemoval,
47
+ verifyPathRemoved,
48
+ verifyRegularFile,
49
+ } from './verify.mjs'
29
50
 
30
51
  export const __dirname = dirname(fileURLToPath(import.meta.url))
31
52
  export const ROOT = join(__dirname, '..', '..')
@@ -170,6 +191,7 @@ export function sourceDirValid(dir) {
170
191
  }
171
192
 
172
193
  export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = false) {
194
+ assertNoSymlinkAncestors(dstDir)
173
195
  const entries = readdirSync(srcDir)
174
196
  let created = 0
175
197
  let skipped = 0
@@ -185,6 +207,8 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
185
207
  dstNames.add(dstName)
186
208
 
187
209
  const dstFile = join(dstDir, dstName)
210
+ assertPathWithin(dstDir, dstFile)
211
+ assertNoSymlinkAncestors(dstFile)
188
212
 
189
213
  if (statSync(srcFile).isDirectory()) continue
190
214
  const content = readFileSync(srcFile, 'utf8')
@@ -195,14 +219,10 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
195
219
  skipped++
196
220
  continue
197
221
  }
198
- if (!dryRun) {
199
- writeFileSync(dstFile, content, 'utf8')
200
- }
222
+ writeIfChanged(dstFile, content, dryRun)
201
223
  created++
202
224
  } else {
203
- if (!dryRun) {
204
- writeFileSync(dstFile, content, 'utf8')
205
- }
225
+ writeIfChanged(dstFile, content, dryRun)
206
226
  created++
207
227
  }
208
228
  }
@@ -212,17 +232,21 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
212
232
  // (lowercase name + .md/.mdc extension), regardless of AGENT_NAMES membership.
213
233
  // This handles deleted canonical agents (e.g., agora) whose source file is gone.
214
234
  if (clean && existsSync(dstDir)) {
235
+ assertNoSymlinkAncestors(dstDir)
215
236
  const dstEntries = readdirSync(dstDir)
216
237
  const agentPattern = /^[a-z-]+\.(md|mdc)$/
217
238
  for (const entry of dstEntries) {
218
239
  if (dstNames.has(entry)) continue // still in source
219
240
  const dstFile = join(dstDir, entry)
241
+ assertSafeRemoval(dstDir, dstFile)
220
242
  if (!statSync(dstFile).isFile()) continue
221
243
  // Safety: only remove files that match agent file naming pattern
222
244
  // (user custom files with different naming patterns are untouched)
223
245
  if (!agentPattern.test(entry)) continue
224
246
  if (!dryRun) {
247
+ assertSafeRemoval(dstDir, dstFile)
225
248
  rmSync(dstFile, { force: true })
249
+ verifyPathRemoved(dstFile)
226
250
  }
227
251
  created++
228
252
  }
@@ -289,21 +313,26 @@ function readFileOrNull(filePath) {
289
313
  * size only) so `--dry-run` can print the real per-file plan. Defaults to
290
314
  * the module-level no-op singleton, so an uninstrumented caller is
291
315
  * byte-identical to before.
316
+ * @param {number} [options.mode] - mode for a newly created file; existing
317
+ * destination modes are preserved.
292
318
  * @param {boolean} [options.scored] - set `false` when this write is not part
293
319
  * of the install summary (e.g. stale-ref cleanup), so it is shown in the plan
294
320
  * but excluded from the consistency guard.
295
321
  * @returns {'created'|'skipped'|'conflict'}
296
322
  */
297
323
  export function writeIfChanged(filePath, content, dryRun, options = {}) {
298
- const { expectedContent, beforeSwap, beforeRecheck, recorder, scored } = options
324
+ const { expectedContent, beforeSwap, beforeRecheck, recorder, scored, mode } = options
299
325
 
300
- // Refuse to write through/over a symlink. A dangling symlink is invisible to
301
- // existsSync, and rename-over-link would replace the link rather than its
302
- // target — both are silent redirections the installer must not perform.
326
+ // A linked parent redirects both the atomic temp write and the eventual
327
+ // rename, so validate the full path rather than only its final component.
328
+ assertNoSymlinkAncestors(filePath)
303
329
  const stat = lstatOrNull(filePath)
304
330
  if (stat?.isSymbolicLink()) {
305
331
  throw new Error(`Refusing to write through symlink: ${filePath}`)
306
332
  }
333
+ // Preserve the destination's access mode across the atomic rename. A mode
334
+ // supplied by the caller is used only for a newly created destination.
335
+ const targetMode = stat === null ? mode : stat.mode & 0o777
307
336
 
308
337
  const existing = stat === null ? null : readFileOrNull(filePath)
309
338
  const record = (outcome) => {
@@ -317,7 +346,13 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
317
346
  ? ACTIONS.CREATE
318
347
  : ACTIONS.UPDATE
319
348
  recordChange(
320
- { path: filePath, action, bytes: Buffer.byteLength(content, 'utf8'), scored },
349
+ {
350
+ path: filePath,
351
+ action,
352
+ bytes: Buffer.byteLength(content, 'utf8'),
353
+ outputFingerprint: fingerprintOutput(content),
354
+ scored,
355
+ },
321
356
  recorder,
322
357
  )
323
358
  }
@@ -340,23 +375,54 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
340
375
  // crash mid-write can never leave a truncated file behind (rename is
341
376
  // atomic within the same directory).
342
377
  const tmpPath = `${filePath}.tmp-${process.pid}`
343
- writeFileSync(tmpPath, content, 'utf8')
344
-
345
- // Last-moment re-read. The initial compare and this rename are separate
346
- // syscalls, so a concurrent writer can land in between; re-check the
347
- // expected bytes immediately before the swap. On a mismatch, drop the temp
348
- // and leave the target byte-identical.
349
- if (expectedContent !== undefined) {
350
- const current = readFileOrNull(filePath)
351
- if (current !== expectedContent) {
352
- rmSync(tmpPath, { force: true })
353
- record('conflict')
354
- return 'conflict'
378
+ assertNoSymlinkAncestors(tmpPath)
379
+ let fd
380
+ let createdTemporary = false
381
+ try {
382
+ // Exclusive creation establishes ownership. Cleanup below is limited to
383
+ // this exact path and only runs after this invocation opened it.
384
+ fd = openSync(tmpPath, 'wx', targetMode)
385
+ createdTemporary = true
386
+ writeFileSync(fd, content, { encoding: 'utf8' })
387
+ closeSync(fd)
388
+ fd = undefined
389
+ if (targetMode !== undefined) chmodSync(tmpPath, targetMode)
390
+
391
+ // Last-moment re-read. The initial compare and this rename are separate
392
+ // syscalls, so a concurrent writer can land in between; re-check the
393
+ // expected bytes immediately before the swap. On a mismatch, leave the
394
+ // target byte-identical; finally removes only the temp created above.
395
+ if (expectedContent !== undefined) {
396
+ const current = readFileOrNull(filePath)
397
+ if (current !== expectedContent) {
398
+ record('conflict')
399
+ return 'conflict'
400
+ }
355
401
  }
356
- }
357
402
 
358
- if (typeof beforeSwap === 'function') beforeSwap(existing)
359
- renameSync(tmpPath, filePath)
403
+ if (typeof beforeSwap === 'function') beforeSwap(existing)
404
+ assertNoSymlinkAncestors(filePath)
405
+ renameSync(tmpPath, filePath)
406
+ createdTemporary = false
407
+ verifyRegularFile(filePath, content)
408
+ } finally {
409
+ if (fd !== undefined) {
410
+ try {
411
+ closeSync(fd)
412
+ } catch {
413
+ // Preserve the write error; closing a failed descriptor is best-effort.
414
+ }
415
+ }
416
+ if (createdTemporary) {
417
+ try {
418
+ assertSafeRemoval(dirname(tmpPath), tmpPath)
419
+ rmSync(tmpPath, { force: true })
420
+ verifyPathRemoved(tmpPath)
421
+ } catch {
422
+ // Preserve the write error and never follow an unsafe temp path.
423
+ }
424
+ }
425
+ }
360
426
  }
361
427
  record('created')
362
428
  return 'created'
@@ -372,6 +438,8 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
372
438
  * A caller may still supply `beforeSwap` for a file the exact set does NOT
373
439
  * cover: the GLOBAL `opencode-vision.json` lives outside the config dir, so
374
440
  * vision-prompt passes a `beforeSwap` that keeps its `<file>.bak` recovery.
441
+ * New config files default to mode `0600`; existing destination modes survive
442
+ * replacement.
375
443
  *
376
444
  * @param {string} filePath
377
445
  * @param {string} content
@@ -385,6 +453,7 @@ export function writeConfigWithBackup(filePath, content, dryRun, options = {}) {
385
453
  beforeSwap: options.beforeSwap,
386
454
  recorder: options.recorder,
387
455
  scored: options.scored,
456
+ mode: options.mode ?? 0o600,
388
457
  })
389
458
  }
390
459
 
@@ -398,7 +467,10 @@ export function checkRuntimePrerequisites(env = process.env) {
398
467
  const problems = []
399
468
  const checks = [
400
469
  { cmd: 'python3', hint: 'install Python 3.10+ (https://www.python.org/downloads/)' },
401
- { cmd: 'npm', hint: 'install Node.js 22+ (https://nodejs.org) — npm ships with it' },
470
+ {
471
+ cmd: 'npm',
472
+ hint: `install Node.js >= ${MIN_SUPPORTED_NODE_VERSION} (https://nodejs.org) — npm ships with it`,
473
+ },
402
474
  ]
403
475
  for (const { cmd, hint } of checks) {
404
476
  const probe = spawnSync(cmd, ['--version'], { encoding: 'utf8', timeout: 10_000, env })
@@ -450,9 +522,27 @@ export function serializeFm(fm) {
450
522
  })
451
523
  }
452
524
 
453
- export function installSkills(skills, target, dryRun, subDir = '.opencode', recorder = null) {
525
+ function recordDirectoryOutcomes(srcDir, dstDir, recorder) {
526
+ recordChange({ path: dstDir, action: ACTIONS.UNCHANGED, bytes: 0, scored: false }, recorder)
527
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
528
+ if (!entry.isDirectory()) continue
529
+ recordDirectoryOutcomes(join(srcDir, entry.name), join(dstDir, entry.name), recorder)
530
+ }
531
+ }
532
+
533
+ export function installSkills(
534
+ skills,
535
+ target,
536
+ dryRun,
537
+ subDir = '.opencode',
538
+ recorder = null,
539
+ reviewedRemovals = null,
540
+ reviewedSnapshots = null,
541
+ clean = false,
542
+ ) {
454
543
  const srcSkillsDir = join(ROOT, 'src', 'skills')
455
544
  const dstSkillsDir = join(target, subDir, 'skills')
545
+ assertNoSymlinkAncestors(dstSkillsDir)
456
546
 
457
547
  let created = 0
458
548
  let skipped = 0
@@ -461,7 +551,12 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode', reco
461
551
  const src = join(srcSkillsDir, skill)
462
552
  const dst = join(dstSkillsDir, skill)
463
553
 
464
- if (!dryRun) mkdirSync(dst, { recursive: true })
554
+ if (!dryRun) {
555
+ assertPathWithin(dstSkillsDir, dst)
556
+ assertNoSymlinkAncestors(dst)
557
+ mkdirSync(dst, { recursive: true })
558
+ assertNoSymlinkAncestors(dst)
559
+ }
465
560
 
466
561
  function copyDirRecursive(from, to) {
467
562
  const entries = readdirSync(from)
@@ -469,11 +564,20 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode', reco
469
564
  const srcPath = join(from, entry)
470
565
  const dstPath = join(to, entry)
471
566
  if (statSync(srcPath).isDirectory()) {
472
- if (!dryRun) mkdirSync(dstPath, { recursive: true })
567
+ if (!dryRun) {
568
+ assertPathWithin(dstSkillsDir, dstPath)
569
+ assertNoSymlinkAncestors(dstPath)
570
+ mkdirSync(dstPath, { recursive: true })
571
+ assertNoSymlinkAncestors(dstPath)
572
+ }
473
573
  copyDirRecursive(srcPath, dstPath)
474
574
  } else {
475
575
  const content = readFileSync(srcPath, 'utf8')
476
- const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf8') : null
576
+ const dstStat = lstatOrNull(dstPath)
577
+ if (dstStat?.isSymbolicLink()) {
578
+ throw new Error(`Refusing to read through symlink: ${dstPath}`)
579
+ }
580
+ const existing = dstStat === null ? null : readFileSync(dstPath, 'utf8')
477
581
  if (existing === content) {
478
582
  skipped++
479
583
  if (isRecording(recorder)) {
@@ -482,40 +586,49 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode', reco
482
586
  path: dstPath,
483
587
  action: ACTIONS.UNCHANGED,
484
588
  bytes: Buffer.byteLength(content, 'utf8'),
589
+ outputFingerprint: fingerprintOutput(content),
485
590
  },
486
591
  recorder,
487
592
  )
488
593
  }
489
594
  } else {
490
- if (!dryRun) writeFileSync(dstPath, content, 'utf8')
491
- created++
492
- if (isRecording(recorder)) {
595
+ if (!dryRun) {
596
+ writeIfChanged(dstPath, content, false, { recorder })
597
+ } else if (isRecording(recorder)) {
493
598
  recordChange(
494
599
  {
495
600
  path: dstPath,
496
601
  action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
497
602
  bytes: Buffer.byteLength(content, 'utf8'),
603
+ outputFingerprint: fingerprintOutput(content),
498
604
  },
499
605
  recorder,
500
606
  )
501
607
  }
608
+ created++
502
609
  }
503
610
  }
504
611
  }
505
612
  }
506
613
 
507
614
  copyDirRecursive(src, dst)
615
+ if (clean && existsSync(dst)) recordDirectoryOutcomes(src, dst, recorder)
508
616
  }
509
617
 
510
618
  // Remove stale skills that exist in dst but not in skills list
511
619
  if (existsSync(dstSkillsDir)) {
620
+ assertNoSymlinkAncestors(dstSkillsDir)
512
621
  const dstEntries = readdirSync(dstSkillsDir)
513
622
  for (const entry of dstEntries) {
514
623
  const dstPath = join(dstSkillsDir, entry)
624
+ assertSafeRemoval(dstSkillsDir, dstPath)
515
625
  if (!statSync(dstPath).isDirectory()) continue
516
626
  if (!skills.includes(entry)) {
517
- if (!dryRun) {
518
- rmSync(dstPath, { recursive: true, force: true })
627
+ if (dryRun) {
628
+ recordRemovalTree(dstPath, recorder)
629
+ } else {
630
+ assertSafeRemoval(dstSkillsDir, dstPath)
631
+ removeReviewedTree(dstPath, reviewedRemovals, reviewedSnapshots)
519
632
  console.log(` 🗑️ Removed stale skill: ${entry}`)
520
633
  }
521
634
  created++
@@ -529,18 +642,35 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode', reco
529
642
  return { created, skipped }
530
643
  }
531
644
 
532
- export function syncDir(src, dst, dryRun, clean = false, filter = null, recorder = null) {
645
+ export function syncDir(
646
+ src,
647
+ dst,
648
+ dryRun,
649
+ clean = false,
650
+ filter = null,
651
+ recorder = null,
652
+ reviewedRemovals = null,
653
+ reviewedSnapshots = null,
654
+ ) {
533
655
  if (!existsSync(src)) return { created: 0, skipped: 0 }
534
656
  let created = 0
535
657
  let skipped = 0
658
+ const cleanTargetExisted = clean && existsSync(dst)
536
659
 
537
- if (clean && existsSync(dst)) {
538
- if (!dryRun) {
539
- rmSync(dst, { recursive: true, force: true })
660
+ if (cleanTargetExisted) {
661
+ if (dryRun) {
662
+ recordRemovalTree(dst, recorder)
663
+ } else {
664
+ assertSafeRemoval(dirname(dst), dst)
665
+ removeReviewedTree(dst, reviewedRemovals, reviewedSnapshots)
540
666
  }
541
667
  }
542
668
 
543
- if (!dryRun) mkdirSync(dst, { recursive: true })
669
+ if (!dryRun) {
670
+ assertNoSymlinkAncestors(dst)
671
+ mkdirSync(dst, { recursive: true })
672
+ assertNoSymlinkAncestors(dst)
673
+ }
544
674
 
545
675
  const entries = readdirSync(src)
546
676
  for (const entry of entries) {
@@ -548,36 +678,29 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null, recorder
548
678
  const srcPath = join(src, entry)
549
679
  const dstPath = join(dst, entry)
550
680
  if (statSync(srcPath).isDirectory()) {
551
- const sub = syncDir(srcPath, dstPath, dryRun, false, filter, recorder)
681
+ const sub = syncDir(
682
+ srcPath,
683
+ dstPath,
684
+ dryRun,
685
+ false,
686
+ filter,
687
+ recorder,
688
+ reviewedRemovals,
689
+ reviewedSnapshots,
690
+ )
552
691
  created += sub.created
553
692
  skipped += sub.skipped
554
693
  } else {
555
694
  const content = readFileSync(srcPath, 'utf8')
556
- const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf8') : null
557
- if (existing === content) {
695
+ const status = writeIfChanged(dstPath, content, dryRun, { recorder })
696
+ if (status === 'skipped') {
558
697
  skipped++
559
- if (isRecording(recorder)) {
560
- recordChange(
561
- { path: dstPath, action: ACTIONS.UNCHANGED, bytes: Buffer.byteLength(content, 'utf8') },
562
- recorder,
563
- )
564
- }
565
698
  } else {
566
- if (!dryRun) writeFileSync(dstPath, content, 'utf8')
567
699
  created++
568
- if (isRecording(recorder)) {
569
- recordChange(
570
- {
571
- path: dstPath,
572
- action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
573
- bytes: Buffer.byteLength(content, 'utf8'),
574
- },
575
- recorder,
576
- )
577
- }
578
700
  }
579
701
  }
580
702
  }
703
+ if (cleanTargetExisted) recordDirectoryOutcomes(src, dst, recorder)
581
704
  return { created, skipped }
582
705
  }
583
706
 
@@ -20,27 +20,57 @@
20
20
  * "skills": { "version": 1, "status": "installed" },
21
21
  * "plugins": { "version": 1, "status": "installed" },
22
22
  * "runtime": { "version": 1, "status": "installed" }
23
+ * },
24
+ * "file_inventory": {
25
+ * "schema_version": 1, "target_root": "/absolute/target",
26
+ * "legacy_unknown": false, "entries": []
23
27
  * }
24
28
  * }
25
29
  */
26
30
 
27
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
28
- import { join } from 'node:path'
31
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
32
+ import { join, resolve } from 'node:path'
33
+ import { assertNoSymlinkAncestors, writeFileAtomically } from './verify.mjs'
29
34
 
30
35
  const STATE_FILE = 'install-state.json'
31
36
  const STATE_DIR = '.pantheon'
32
37
 
38
+ function emptyFileInventory(legacyUnknown = true) {
39
+ return {
40
+ schema_version: 1,
41
+ target_root: null,
42
+ legacy_unknown: legacyUnknown,
43
+ entries: [],
44
+ }
45
+ }
46
+
47
+ function invalidFileInventory() {
48
+ return { schema_version: 0, target_root: null, legacy_unknown: true, entries: [] }
49
+ }
50
+
51
+ function normalizeFileInventory(state) {
52
+ if (!Object.hasOwn(state, 'file_inventory')) return emptyFileInventory(true)
53
+ if (
54
+ !state.file_inventory ||
55
+ typeof state.file_inventory !== 'object' ||
56
+ Array.isArray(state.file_inventory)
57
+ ) {
58
+ return invalidFileInventory()
59
+ }
60
+ return state.file_inventory
61
+ }
62
+
33
63
  /**
34
64
  * Create a fresh v2 state object for a new install.
35
65
  * @param {string} version - Pantheon version string
36
66
  * @returns {object} v2 state
37
67
  */
38
- export function createInitialState(version) {
68
+ export function createInitialState(version, updatedAt = new Date().toISOString()) {
39
69
  return {
40
70
  schema_version: 2,
41
71
  pantheon_version: version || '0.0.0',
42
72
  previous_version: null,
43
- updated_at: new Date().toISOString(),
73
+ updated_at: updatedAt,
44
74
  migration_checkpoint: null,
45
75
  applied_migrations: [],
46
76
  components: {
@@ -49,6 +79,7 @@ export function createInitialState(version) {
49
79
  plugins: { version: 1, status: 'pending' },
50
80
  runtime: { version: 1, status: 'pending' },
51
81
  },
82
+ file_inventory: emptyFileInventory(false),
52
83
  }
53
84
  }
54
85
 
@@ -104,6 +135,7 @@ export function upgradeStateV1toV2(state) {
104
135
  plugins: { version: 1, status: 'unknown' },
105
136
  runtime: { version: 1, status: 'unknown' },
106
137
  },
138
+ file_inventory: normalizeFileInventory(state),
107
139
  }
108
140
  }
109
141
 
@@ -129,6 +161,14 @@ function ensureV2Shape(state) {
129
161
  ) {
130
162
  state.components = upgradeStateV1toV2(state).components
131
163
  }
164
+ if (!Object.hasOwn(state, 'file_inventory')) state.file_inventory = emptyFileInventory(true)
165
+ else if (
166
+ !state.file_inventory ||
167
+ typeof state.file_inventory !== 'object' ||
168
+ Array.isArray(state.file_inventory)
169
+ ) {
170
+ state.file_inventory = invalidFileInventory()
171
+ }
132
172
  return state
133
173
  }
134
174
 
@@ -149,15 +189,30 @@ function normalizeState(state) {
149
189
  * @param {string} target - Installation directory
150
190
  * @param {object} state - v2 state object
151
191
  */
152
- export function writeState(target, state) {
192
+ export function writeState(target, state, options = {}) {
153
193
  const stateDir = join(target, STATE_DIR)
194
+ const stateFile = join(stateDir, STATE_FILE)
195
+ assertNoSymlinkAncestors(stateFile)
154
196
  if (!existsSync(stateDir)) {
155
197
  mkdirSync(stateDir, { recursive: true })
156
198
  }
157
- const stateFile = join(stateDir, STATE_FILE)
158
- state.updated_at = new Date().toISOString()
199
+ assertNoSymlinkAncestors(stateFile)
200
+ state.updated_at = options.updatedAt ?? new Date().toISOString()
159
201
  state.schema_version = 2
160
- writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
202
+ if (!Object.hasOwn(state, 'file_inventory')) {
203
+ state.file_inventory = emptyFileInventory(true)
204
+ } else if (
205
+ !state.file_inventory ||
206
+ typeof state.file_inventory !== 'object' ||
207
+ Array.isArray(state.file_inventory)
208
+ ) {
209
+ state.file_inventory = invalidFileInventory()
210
+ }
211
+ if (state.file_inventory.target_root == null) {
212
+ state.file_inventory.target_root = resolve(target)
213
+ }
214
+ const content = `${JSON.stringify(state, null, 2)}\n`
215
+ writeFileAtomically(stateFile, content)
161
216
  }
162
217
 
163
218
  /**
@@ -10,11 +10,11 @@
10
10
  */
11
11
 
12
12
  import { spawnSync } from 'node:child_process'
13
- import { existsSync, rmSync } from 'node:fs'
13
+ import { existsSync } from 'node:fs'
14
14
  import { dirname, join } from 'node:path'
15
15
  import { fileURLToPath } from 'node:url'
16
16
 
17
- import { ACTIONS, recordChange } from './plan.mjs'
17
+ import { ACTIONS, recordChange, recordRemovalTree, removeReviewedTree } from './plan.mjs'
18
18
 
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url))
20
20
  const ROOT = join(__dirname, '..', '..')
@@ -42,21 +42,30 @@ export function venvPythonPath(target) {
42
42
  * Set up a Python virtual environment and install MCP dependencies.
43
43
  *
44
44
  * @param {string} target - Target installation directory (e.g. ~/.config/opencode)
45
- * @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean, recorder?: (change: object) => void }} [options]
45
+ * @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean, recorder?: (change: object) => void, reviewedRemovals?: Set<string>|null, reviewedSnapshots?: Map<string, object>|null }} [options]
46
46
  * @returns {{ venvPath: string, python: string }}
47
47
  */
48
48
  export function setupVenv(
49
49
  target,
50
- { dryRun = false, skipInstall = false, force = false, recorder = null } = {},
50
+ {
51
+ dryRun = false,
52
+ skipInstall = false,
53
+ force = false,
54
+ recorder = null,
55
+ reviewedRemovals = null,
56
+ reviewedSnapshots = null,
57
+ } = {},
51
58
  ) {
52
59
  const venvPath = join(target, '.venv')
53
60
  const pythonBin = venvPythonPath(target)
54
61
 
55
62
  // Step 1: Force recreate venv if --force
56
63
  if (force && existsSync(venvPath)) {
57
- if (!dryRun) {
64
+ if (dryRun) {
65
+ recordRemovalTree(venvPath, recorder)
66
+ } else {
58
67
  console.log(' Removing existing .venv (--force)...')
59
- rmSync(venvPath, { recursive: true })
68
+ removeReviewedTree(venvPath, reviewedRemovals, reviewedSnapshots)
60
69
  }
61
70
  console.log(' ✅ .venv removed')
62
71
  }