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
@@ -4,11 +4,12 @@
4
4
 
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import {
7
- copyFileSync,
8
- cpSync,
7
+ chmodSync,
8
+ closeSync,
9
9
  existsSync,
10
10
  lstatSync,
11
11
  mkdirSync,
12
+ openSync,
12
13
  readdirSync,
13
14
  readFileSync,
14
15
  renameSync,
@@ -16,17 +17,35 @@ import {
16
17
  statSync,
17
18
  writeFileSync,
18
19
  } from 'node:fs'
19
- import { basename, dirname, join } from 'node:path'
20
+ import { dirname, join } from 'node:path'
20
21
  import { fileURLToPath } from 'node:url'
22
+ import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_VERSION } from '../node-engine-policy.mjs'
21
23
 
22
- const REQUIRED_NODE_MAJOR = 18
23
- if (parseInt(process.versions.node.split('.')[0], 10) < REQUIRED_NODE_MAJOR) {
24
- 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
+ )
25
28
  process.exit(1)
26
29
  }
27
30
 
28
31
  import { dump, load } from 'js-yaml'
29
32
 
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'
48
+
30
49
  export const __dirname = dirname(fileURLToPath(import.meta.url))
31
50
  export const ROOT = join(__dirname, '..', '..')
32
51
  export const AGENTS_DIR = join(ROOT, 'src', 'agents')
@@ -141,33 +160,6 @@ export function resolveTarget(target) {
141
160
  return join(process.cwd(), target)
142
161
  }
143
162
 
144
- export function createBackup(target) {
145
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
146
- const backupDir = join(target, '..', `.pantheon-bak-${basename(target)}-${timestamp}`)
147
-
148
- console.log(`\n 💾 Creating backup: ${backupDir}`)
149
- mkdirSync(backupDir, { recursive: true })
150
-
151
- // Copy only relevant Pantheon directories/files
152
- const items = ['agents', 'skills', 'commands', 'instructions', 'opencode.json', 'tui.json']
153
- let count = 0
154
- for (const item of items) {
155
- const src = join(target, item)
156
- try {
157
- cpSync(src, join(backupDir, item), {
158
- recursive: true,
159
- errorOnExist: false,
160
- })
161
- count++
162
- } catch {
163
- // File or directory doesn't exist in target — skip silently
164
- }
165
- }
166
-
167
- console.log(` ✅ Backed up ${count} items to: ${backupDir}`)
168
- return backupDir
169
- }
170
-
171
163
  export function detectAndReport(target) {
172
164
  const found = detectPlatforms(target).length > 0
173
165
 
@@ -197,6 +189,7 @@ export function sourceDirValid(dir) {
197
189
  }
198
190
 
199
191
  export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = false) {
192
+ assertNoSymlinkAncestors(dstDir)
200
193
  const entries = readdirSync(srcDir)
201
194
  let created = 0
202
195
  let skipped = 0
@@ -212,6 +205,8 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
212
205
  dstNames.add(dstName)
213
206
 
214
207
  const dstFile = join(dstDir, dstName)
208
+ assertPathWithin(dstDir, dstFile)
209
+ assertNoSymlinkAncestors(dstFile)
215
210
 
216
211
  if (statSync(srcFile).isDirectory()) continue
217
212
  const content = readFileSync(srcFile, 'utf8')
@@ -222,14 +217,10 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
222
217
  skipped++
223
218
  continue
224
219
  }
225
- if (!dryRun) {
226
- writeFileSync(dstFile, content, 'utf8')
227
- }
220
+ writeIfChanged(dstFile, content, dryRun)
228
221
  created++
229
222
  } else {
230
- if (!dryRun) {
231
- writeFileSync(dstFile, content, 'utf8')
232
- }
223
+ writeIfChanged(dstFile, content, dryRun)
233
224
  created++
234
225
  }
235
226
  }
@@ -239,17 +230,21 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
239
230
  // (lowercase name + .md/.mdc extension), regardless of AGENT_NAMES membership.
240
231
  // This handles deleted canonical agents (e.g., agora) whose source file is gone.
241
232
  if (clean && existsSync(dstDir)) {
233
+ assertNoSymlinkAncestors(dstDir)
242
234
  const dstEntries = readdirSync(dstDir)
243
235
  const agentPattern = /^[a-z-]+\.(md|mdc)$/
244
236
  for (const entry of dstEntries) {
245
237
  if (dstNames.has(entry)) continue // still in source
246
238
  const dstFile = join(dstDir, entry)
239
+ assertSafeRemoval(dstDir, dstFile)
247
240
  if (!statSync(dstFile).isFile()) continue
248
241
  // Safety: only remove files that match agent file naming pattern
249
242
  // (user custom files with different naming patterns are untouched)
250
243
  if (!agentPattern.test(entry)) continue
251
244
  if (!dryRun) {
245
+ assertSafeRemoval(dstDir, dstFile)
252
246
  rmSync(dstFile, { force: true })
247
+ verifyPathRemoved(dstFile)
253
248
  }
254
249
  created++
255
250
  }
@@ -302,35 +297,71 @@ function readFileOrNull(filePath) {
302
297
  * filesystem CAS: the final re-read and the rename are still two syscalls, so
303
298
  * a writer that lands between them is not prevented. What it does close is
304
299
  * the window the installer actually races with — a config the caller parsed
305
- * going stale during the same run — and it keeps the target and the `.bak`
306
- * consistent with the bytes that were checked.
300
+ * going stale during the same run — and it keeps the target consistent with
301
+ * the bytes that were checked.
307
302
  * @param {(existing: string|null) => void} [options.beforeRecheck] - runs after
308
303
  * the initial content check and before the last-moment re-read. Injection
309
304
  * point so tests can simulate a concurrent writer landing in the window;
310
305
  * production callers omit it (undefined is a no-op).
311
306
  * @param {(existing: string|null) => void} [options.beforeSwap] - runs after the
312
- * expected-content checks pass and immediately before the rename, so side
313
- * effects that must not fire on a conflict (e.g. the `.bak` copy) live here.
307
+ * expected-content checks pass and immediately before the rename, for side
308
+ * effects that must not fire on a conflict.
309
+ * @param {(change: object) => void} [options.recorder] - optional change
310
+ * recorder. When supplied, the outcome is recorded (path + action + byte
311
+ * size only) so `--dry-run` can print the real per-file plan. Defaults to
312
+ * the module-level no-op singleton, so an uninstrumented caller is
313
+ * byte-identical to before.
314
+ * @param {number} [options.mode] - mode for a newly created file; existing
315
+ * destination modes are preserved.
316
+ * @param {boolean} [options.scored] - set `false` when this write is not part
317
+ * of the install summary (e.g. stale-ref cleanup), so it is shown in the plan
318
+ * but excluded from the consistency guard.
314
319
  * @returns {'created'|'skipped'|'conflict'}
315
320
  */
316
321
  export function writeIfChanged(filePath, content, dryRun, options = {}) {
317
- const { expectedContent, beforeSwap, beforeRecheck } = options
322
+ const { expectedContent, beforeSwap, beforeRecheck, recorder, scored, mode } = options
318
323
 
319
- // Refuse to write through/over a symlink. A dangling symlink is invisible to
320
- // existsSync, and rename-over-link would replace the link rather than its
321
- // 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)
322
327
  const stat = lstatOrNull(filePath)
323
328
  if (stat?.isSymbolicLink()) {
324
329
  throw new Error(`Refusing to write through symlink: ${filePath}`)
325
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
326
334
 
327
335
  const existing = stat === null ? null : readFileOrNull(filePath)
336
+ const record = (outcome) => {
337
+ if (!isRecording(recorder)) return
338
+ const action =
339
+ outcome === 'conflict'
340
+ ? ACTIONS.CONFLICT
341
+ : outcome === 'skipped'
342
+ ? ACTIONS.UNCHANGED
343
+ : existing === null
344
+ ? ACTIONS.CREATE
345
+ : ACTIONS.UPDATE
346
+ recordChange(
347
+ {
348
+ path: filePath,
349
+ action,
350
+ bytes: Buffer.byteLength(content, 'utf8'),
351
+ outputFingerprint: fingerprintOutput(content),
352
+ scored,
353
+ },
354
+ recorder,
355
+ )
356
+ }
328
357
  if (existing === content) {
358
+ record('skipped')
329
359
  return 'skipped'
330
360
  }
331
361
  if (expectedContent !== undefined && existing !== expectedContent) {
332
362
  // The file changed since the caller read it (or appeared/disappeared).
333
363
  // Do not clobber the newer content; the caller decides how to react.
364
+ record('conflict')
334
365
  return 'conflict'
335
366
  }
336
367
  if (!dryRun) {
@@ -342,36 +373,71 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
342
373
  // crash mid-write can never leave a truncated file behind (rename is
343
374
  // atomic within the same directory).
344
375
  const tmpPath = `${filePath}.tmp-${process.pid}`
345
- writeFileSync(tmpPath, content, 'utf8')
346
-
347
- // Last-moment re-read. The initial compare and this rename are separate
348
- // syscalls, so a concurrent writer can land in between; re-check the
349
- // expected bytes immediately before the swap. On a mismatch, drop the temp
350
- // and leave the target AND any existing `.bak` byte-identical.
351
- if (expectedContent !== undefined) {
352
- const current = readFileOrNull(filePath)
353
- if (current !== expectedContent) {
354
- rmSync(tmpPath, { force: true })
355
- 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
+ }
356
399
  }
357
- }
358
400
 
359
- if (typeof beforeSwap === 'function') beforeSwap(existing)
360
- 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
+ }
361
424
  }
425
+ record('created')
362
426
  return 'created'
363
427
  }
364
428
 
365
429
  /**
366
- * beta.5: write the user's opencode.json with a same-directory backup of the
367
- * previous content. The installer rewrites the whole config; a `.bak` gives
368
- * the user a one-step recovery if a merge ever goes wrong.
430
+ * Write a config file atomically, honoring the compare-before-write guard.
431
+ * This used to always keep a `<config>.bak` sidecar; the main `opencode.json`
432
+ * no longer does — the target-scoped exact backup set
433
+ * (scripts/install/backup.mjs) preserves it byte-for-byte and
434
+ * `pantheon-opencode restore` reverts it.
369
435
  *
370
- * The backup is created INSIDE the `beforeSwap` hook, so it fires only after
371
- * both expected-content checks pass (the initial compare and the last-moment
372
- * re-read before the rename). A conflict observed at either point leaves the
373
- * target AND any pre-existing `.bak` untouched, rather than overwriting the
374
- * user's last known-good backup with a stale copy on a no-op write.
436
+ * A caller may still supply `beforeSwap` for a file the exact set does NOT
437
+ * cover: the GLOBAL `opencode-vision.json` lives outside the config dir, so
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
@@ -382,14 +448,10 @@ export function writeConfigWithBackup(filePath, content, dryRun, options = {}) {
382
448
  return writeIfChanged(filePath, content, dryRun, {
383
449
  expectedContent: options.expectedContent,
384
450
  beforeRecheck: options.beforeRecheck,
385
- beforeSwap: (existing) => {
386
- if (existing === null) return
387
- try {
388
- copyFileSync(filePath, `${filePath}.bak`)
389
- } catch {
390
- // Best-effort: a backup failure must not block the install.
391
- }
392
- },
451
+ beforeSwap: options.beforeSwap,
452
+ recorder: options.recorder,
453
+ scored: options.scored,
454
+ mode: options.mode ?? 0o600,
393
455
  })
394
456
  }
395
457
 
@@ -403,7 +465,10 @@ export function checkRuntimePrerequisites(env = process.env) {
403
465
  const problems = []
404
466
  const checks = [
405
467
  { cmd: 'python3', hint: 'install Python 3.10+ (https://www.python.org/downloads/)' },
406
- { 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
+ },
407
472
  ]
408
473
  for (const { cmd, hint } of checks) {
409
474
  const probe = spawnSync(cmd, ['--version'], { encoding: 'utf8', timeout: 10_000, env })
@@ -455,9 +520,27 @@ export function serializeFm(fm) {
455
520
  })
456
521
  }
457
522
 
458
- export function installSkills(skills, target, dryRun, subDir = '.opencode') {
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
+ ) {
459
541
  const srcSkillsDir = join(ROOT, 'src', 'skills')
460
542
  const dstSkillsDir = join(target, subDir, 'skills')
543
+ assertNoSymlinkAncestors(dstSkillsDir)
461
544
 
462
545
  let created = 0
463
546
  let skipped = 0
@@ -466,7 +549,12 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
466
549
  const src = join(srcSkillsDir, skill)
467
550
  const dst = join(dstSkillsDir, skill)
468
551
 
469
- 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
+ }
470
558
 
471
559
  function copyDirRecursive(from, to) {
472
560
  const entries = readdirSync(from)
@@ -474,15 +562,47 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
474
562
  const srcPath = join(from, entry)
475
563
  const dstPath = join(to, entry)
476
564
  if (statSync(srcPath).isDirectory()) {
477
- 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
+ }
478
571
  copyDirRecursive(srcPath, dstPath)
479
572
  } else {
480
573
  const content = readFileSync(srcPath, 'utf8')
481
- 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')
482
579
  if (existing === content) {
483
580
  skipped++
581
+ if (isRecording(recorder)) {
582
+ recordChange(
583
+ {
584
+ path: dstPath,
585
+ action: ACTIONS.UNCHANGED,
586
+ bytes: Buffer.byteLength(content, 'utf8'),
587
+ outputFingerprint: fingerprintOutput(content),
588
+ },
589
+ recorder,
590
+ )
591
+ }
484
592
  } else {
485
- if (!dryRun) writeFileSync(dstPath, content, 'utf8')
593
+ if (!dryRun) {
594
+ writeIfChanged(dstPath, content, false, { recorder })
595
+ } else if (isRecording(recorder)) {
596
+ recordChange(
597
+ {
598
+ path: dstPath,
599
+ action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
600
+ bytes: Buffer.byteLength(content, 'utf8'),
601
+ outputFingerprint: fingerprintOutput(content),
602
+ },
603
+ recorder,
604
+ )
605
+ }
486
606
  created++
487
607
  }
488
608
  }
@@ -490,20 +610,29 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
490
610
  }
491
611
 
492
612
  copyDirRecursive(src, dst)
613
+ if (clean && existsSync(dst)) recordDirectoryOutcomes(src, dst, recorder)
493
614
  }
494
615
 
495
616
  // Remove stale skills that exist in dst but not in skills list
496
617
  if (existsSync(dstSkillsDir)) {
618
+ assertNoSymlinkAncestors(dstSkillsDir)
497
619
  const dstEntries = readdirSync(dstSkillsDir)
498
620
  for (const entry of dstEntries) {
499
621
  const dstPath = join(dstSkillsDir, entry)
622
+ assertSafeRemoval(dstSkillsDir, dstPath)
500
623
  if (!statSync(dstPath).isDirectory()) continue
501
624
  if (!skills.includes(entry)) {
502
- if (!dryRun) {
503
- rmSync(dstPath, { recursive: true, force: true })
625
+ if (dryRun) {
626
+ recordRemovalTree(dstPath, recorder)
627
+ } else {
628
+ assertSafeRemoval(dstSkillsDir, dstPath)
629
+ removeReviewedTree(dstPath, reviewedRemovals, reviewedSnapshots)
504
630
  console.log(` 🗑️ Removed stale skill: ${entry}`)
505
631
  }
506
632
  created++
633
+ // A stale removal increments `created` (a change), so it must also be
634
+ // recorded or the preview would under-report on a non-fresh target.
635
+ recordChange({ path: dstPath, action: ACTIONS.REMOVE, bytes: 0 }, recorder)
507
636
  }
508
637
  }
509
638
  }
@@ -511,18 +640,35 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
511
640
  return { created, skipped }
512
641
  }
513
642
 
514
- export function syncDir(src, dst, dryRun, clean = false, filter = 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
+ ) {
515
653
  if (!existsSync(src)) return { created: 0, skipped: 0 }
516
654
  let created = 0
517
655
  let skipped = 0
656
+ const cleanTargetExisted = clean && existsSync(dst)
518
657
 
519
- if (clean && existsSync(dst)) {
520
- if (!dryRun) {
521
- 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)
522
664
  }
523
665
  }
524
666
 
525
- if (!dryRun) mkdirSync(dst, { recursive: true })
667
+ if (!dryRun) {
668
+ assertNoSymlinkAncestors(dst)
669
+ mkdirSync(dst, { recursive: true })
670
+ assertNoSymlinkAncestors(dst)
671
+ }
526
672
 
527
673
  const entries = readdirSync(src)
528
674
  for (const entry of entries) {
@@ -530,20 +676,29 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null) {
530
676
  const srcPath = join(src, entry)
531
677
  const dstPath = join(dst, entry)
532
678
  if (statSync(srcPath).isDirectory()) {
533
- const sub = syncDir(srcPath, dstPath, dryRun, false, filter)
679
+ const sub = syncDir(
680
+ srcPath,
681
+ dstPath,
682
+ dryRun,
683
+ false,
684
+ filter,
685
+ recorder,
686
+ reviewedRemovals,
687
+ reviewedSnapshots,
688
+ )
534
689
  created += sub.created
535
690
  skipped += sub.skipped
536
691
  } else {
537
692
  const content = readFileSync(srcPath, 'utf8')
538
- const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf8') : null
539
- if (existing === content) {
693
+ const status = writeIfChanged(dstPath, content, dryRun, { recorder })
694
+ if (status === 'skipped') {
540
695
  skipped++
541
696
  } else {
542
- if (!dryRun) writeFileSync(dstPath, content, 'utf8')
543
697
  created++
544
698
  }
545
699
  }
546
700
  }
701
+ if (cleanTargetExisted) recordDirectoryOutcomes(src, dst, recorder)
547
702
  return { created, skipped }
548
703
  }
549
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
  /**