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