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
@@ -6,6 +6,13 @@ import { homedir } from 'node:os'
6
6
  import { basename, join, relative, resolve } from 'node:path'
7
7
  import { createInterface } from 'node:readline'
8
8
  import { isatty } from 'node:tty'
9
+ import { backupWarning, createBackupSet } from './install/backup.mjs'
10
+ import { snapshot } from './install/plan.mjs'
11
+ import {
12
+ assertNoSymlinkAncestors,
13
+ verifyPathRemoved,
14
+ verifyRegularFile,
15
+ } from './install/verify.mjs'
9
16
 
10
17
  const AGENT_NAMES = new Set([
11
18
  'zeus',
@@ -26,12 +33,14 @@ const AGENT_NAMES = new Set([
26
33
  const OWNERSHIP_MARKER =
27
34
  /(?:pantheon-opencode|pantheon[- ]agent system|managed[- ]by[: ]+pantheon|pantheon-(?:resources|memory|persistence|code-mode|tui|hooks))/i
28
35
  const stats = { removed: 0, skipped: 0, errors: 0 }
36
+ let touchedPaths = new Set()
37
+ let quietPreview = false
29
38
 
30
39
  function showHelp() {
31
40
  console.log(`
32
41
 
33
42
  Usage:
34
- node scripts/uninstall.mjs [--project|--global] [--target /path] [--dry-run] [--force]
43
+ node scripts/uninstall.mjs [--project|--global] [--target /path] [--dry-run] [--force] [--purge-data]
35
44
 
36
45
  Target may be a project directory (containing .opencode), or a flat global
37
46
  OpenCode directory (~/.config/opencode or ~/.opencode). Symlinks are rejected.
@@ -39,7 +48,14 @@ OpenCode directory (~/.config/opencode or ~/.opencode). Symlinks are rejected.
39
48
  }
40
49
 
41
50
  function parseArgs(argv) {
42
- const args = { target: process.cwd(), dryRun: false, force: false, help: false, scope: 'project' }
51
+ const args = {
52
+ target: process.cwd(),
53
+ dryRun: false,
54
+ force: false,
55
+ purgeData: false,
56
+ help: false,
57
+ scope: 'project',
58
+ }
43
59
  let explicitTarget = false
44
60
  for (let index = 2; index < argv.length; index += 1) {
45
61
  const option = argv[index]
@@ -55,6 +71,7 @@ function parseArgs(argv) {
55
71
  args.scope = 'global'
56
72
  } else if (option === '--dry-run') args.dryRun = true
57
73
  else if (option === '--force') args.force = true
74
+ else if (option === '--purge-data') args.purgeData = true
58
75
  else if (option === '--help') args.help = true
59
76
  else if (option === '--platforms')
60
77
  throw new Error('--platforms was removed; this uninstaller supports OpenCode only')
@@ -131,8 +148,19 @@ function isPantheonRouting(path) {
131
148
  }
132
149
  }
133
150
 
134
- function removePath(path, dryRun, root, label = path) {
135
- if (!isInside(path, root) || isSymlink(path)) {
151
+ function assertReviewedPath(path, reviewedPaths) {
152
+ if (reviewedPaths && !reviewedPaths.has(path)) {
153
+ throw new Error(`Uninstall operation set drifted; refusing unreviewed path: ${path}`)
154
+ }
155
+ }
156
+
157
+ function removePath(path, dryRun, root, label = path, reviewedPaths = null) {
158
+ if (!isInside(path, root)) {
159
+ stats.skipped += 1
160
+ return false
161
+ }
162
+ assertNoSymlinkAncestors(path, { includeLeaf: false })
163
+ if (isSymlink(path)) {
136
164
  stats.skipped += 1
137
165
  return false
138
166
  }
@@ -141,10 +169,15 @@ function removePath(path, dryRun, root, label = path) {
141
169
  stats.skipped += 1
142
170
  return false
143
171
  }
144
- if (dryRun) console.log(` ~ Would remove ${label}`)
145
- else {
172
+ assertReviewedPath(path, reviewedPaths)
173
+ touchedPaths.add(path)
174
+ if (dryRun) {
175
+ if (!quietPreview) console.log(` ~ Would remove ${label}`)
176
+ } else {
146
177
  try {
178
+ assertNoSymlinkAncestors(path, { includeLeaf: false })
147
179
  rmSync(path, { recursive: info.isDirectory(), force: false })
180
+ verifyPathRemoved(path)
148
181
  } catch (error) {
149
182
  console.error(` ⚠️ Failed to remove ${path}: ${error.message}`)
150
183
  stats.errors += 1
@@ -155,7 +188,8 @@ function removePath(path, dryRun, root, label = path) {
155
188
  return true
156
189
  }
157
190
 
158
- function removeMatchingEntries(directory, predicate, dryRun, root) {
191
+ function removeMatchingEntries(directory, predicate, dryRun, root, reviewedPaths = null) {
192
+ assertNoSymlinkAncestors(directory, { includeLeaf: false })
159
193
  const info = lstat(directory)
160
194
  if (!info || info.isSymbolicLink() || !info.isDirectory()) {
161
195
  stats.skipped += 1
@@ -168,12 +202,19 @@ function removeMatchingEntries(directory, predicate, dryRun, root) {
168
202
  stats.skipped += 1
169
203
  continue
170
204
  }
171
- if (predicate(path, entry)) removePath(path, dryRun, root, path)
172
- else if (child.isDirectory()) removeMatchingEntries(path, predicate, dryRun, root)
205
+ if (predicate(path, entry)) removePath(path, dryRun, root, path, reviewedPaths)
206
+ else if (child.isDirectory())
207
+ removeMatchingEntries(path, predicate, dryRun, root, reviewedPaths)
173
208
  }
174
209
  const remaining = lstat(directory)
175
- if (remaining?.isDirectory() && readdirSync(directory).length === 0)
176
- removePath(directory, dryRun, root, directory)
210
+ const scheduledRemaining = readdirSync(directory).filter(
211
+ (entry) => !touchedPaths.has(join(directory, entry)),
212
+ )
213
+ if (
214
+ remaining?.isDirectory() &&
215
+ (dryRun ? scheduledRemaining.length === 0 : readdirSync(directory).length === 0)
216
+ )
217
+ removePath(directory, dryRun, root, directory, reviewedPaths)
177
218
  }
178
219
 
179
220
  function readJson(path) {
@@ -214,7 +255,7 @@ function ownedMcpEntry(name, value) {
214
255
  )
215
256
  }
216
257
 
217
- function cleanOpenCodeConfig(target, dryRun, ownedRoot = target) {
258
+ function cleanOpenCodeConfig(target, dryRun, ownedRoot = target, reviewedPaths = null) {
218
259
  const configPath = join(target, 'opencode.json')
219
260
  const config = readJson(configPath)
220
261
  if (!config) {
@@ -286,8 +327,16 @@ function cleanOpenCodeConfig(target, dryRun, ownedRoot = target) {
286
327
  stats.skipped += 1
287
328
  return
288
329
  }
289
- if (dryRun) console.log(' ~ Would update opencode.json (remove Pantheon-owned entries)')
290
- else writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
330
+ assertReviewedPath(configPath, reviewedPaths)
331
+ touchedPaths.add(configPath)
332
+ const content = `${JSON.stringify(config, null, 2)}\n`
333
+ if (dryRun) {
334
+ if (!quietPreview) console.log(' ~ Would update opencode.json (remove Pantheon-owned entries)')
335
+ } else {
336
+ assertNoSymlinkAncestors(configPath)
337
+ writeFileSync(configPath, content, 'utf8')
338
+ verifyRegularFile(configPath, content)
339
+ }
291
340
  stats.removed += 1
292
341
  }
293
342
 
@@ -299,9 +348,39 @@ function configDirFor(target) {
299
348
  : join(target, '.opencode')
300
349
  }
301
350
 
302
- function uninstallOpenCode(target, dryRun) {
351
+ function verifiedInstallState(root) {
352
+ const statePath = join(root, '.pantheon', 'install-state.json')
353
+ assertNoSymlinkAncestors(statePath)
354
+ const info = lstat(statePath)
355
+ if (!info) return null
356
+ if (info.isSymbolicLink() || !info.isFile()) {
357
+ throw new Error('Refusing to purge install state that is not a regular file')
358
+ }
359
+ let state
360
+ try {
361
+ state = JSON.parse(readFileSync(statePath, 'utf8'))
362
+ } catch {
363
+ throw new Error('Refusing to purge unverified install state')
364
+ }
365
+ if (
366
+ state?.schema_version !== 2 ||
367
+ typeof state.pantheon_version !== 'string' ||
368
+ !state.components ||
369
+ typeof state.components !== 'object' ||
370
+ Array.isArray(state.components)
371
+ )
372
+ throw new Error('Refusing to purge unverified install state')
373
+ return statePath
374
+ }
375
+
376
+ function runUninstallPass(target, dryRun, { purgeData = false, reviewedPaths = null } = {}) {
303
377
  const root = resolve(target)
304
- if (isSymlink(root)) throw new Error(`Target must not be a symlink: ${root}`)
378
+ assertNoSymlinkAncestors(root)
379
+ const rootInfo = lstat(root)
380
+ if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) {
381
+ throw new Error(`Target must be a directory and not a symlink: ${root}`)
382
+ }
383
+ const statePath = purgeData ? verifiedInstallState(root) : null
305
384
  const configDir = configDirFor(root)
306
385
  const configInfo = lstat(configDir)
307
386
  if (configInfo?.isSymbolicLink()) {
@@ -309,29 +388,56 @@ function uninstallOpenCode(target, dryRun) {
309
388
  return
310
389
  }
311
390
  const ownedRoot = root
312
- cleanOpenCodeConfig(root, dryRun, ownedRoot)
391
+ cleanOpenCodeConfig(root, dryRun, ownedRoot, reviewedPaths)
313
392
  removeMatchingEntries(
314
393
  join(configDir, 'agents'),
315
394
  (_path, name) => AGENT_NAMES.has(name.replace(/\.md$/, '')) && isPantheonContent(_path),
316
395
  dryRun,
317
396
  ownedRoot,
397
+ reviewedPaths,
318
398
  )
319
399
  for (const directory of ['skills', 'commands', 'plugins'])
320
- removeMatchingEntries(join(configDir, directory), isPantheonContent, dryRun, ownedRoot)
400
+ removeMatchingEntries(
401
+ join(configDir, directory),
402
+ isPantheonContent,
403
+ dryRun,
404
+ ownedRoot,
405
+ reviewedPaths,
406
+ )
321
407
  for (const file of ['package.json', 'tsconfig.json', 'routing.yml']) {
322
408
  const path = join(configDir, file)
323
409
  if (file === 'routing.yml' ? isPantheonRouting(path) : isPantheonContent(path))
324
- removePath(path, dryRun, ownedRoot, path)
410
+ removePath(path, dryRun, ownedRoot, path, reviewedPaths)
325
411
  }
326
412
  const tui = join(configDir, 'tui.json')
327
- cleanTuiConfig(tui, dryRun, ownedRoot)
413
+ cleanTuiConfig(tui, dryRun, ownedRoot, reviewedPaths)
328
414
  const agentsMd = join(root, 'AGENTS.md')
329
- if (isPantheonContent(agentsMd)) removePath(agentsMd, dryRun, ownedRoot, agentsMd)
330
- if (configInfo?.isDirectory() && readdirSync(configDir).length === 0)
331
- removePath(configDir, dryRun, ownedRoot, configDir)
415
+ if (isPantheonContent(agentsMd)) removePath(agentsMd, dryRun, ownedRoot, agentsMd, reviewedPaths)
416
+ const scheduledConfigEntries = configInfo?.isDirectory()
417
+ ? readdirSync(configDir).filter((entry) => !touchedPaths.has(join(configDir, entry)))
418
+ : []
419
+ // A flat global target is also the backup store's config root. Keep it so
420
+ // backups remain discoverable/restorable after uninstall; only nested
421
+ // project `.opencode` directories are eligible for removal here.
422
+ if (
423
+ configInfo?.isDirectory() &&
424
+ resolve(configDir) !== root &&
425
+ (dryRun ? scheduledConfigEntries.length === 0 : readdirSync(configDir).length === 0)
426
+ )
427
+ removePath(configDir, dryRun, ownedRoot, configDir, reviewedPaths)
428
+ if (statePath) {
429
+ assertReviewedPath(statePath, reviewedPaths)
430
+ touchedPaths.add(statePath)
431
+ if (dryRun) {
432
+ if (!quietPreview) console.log(` ~ Would purge verified install state ${statePath}`)
433
+ } else {
434
+ removePath(statePath, false, ownedRoot, statePath, reviewedPaths)
435
+ }
436
+ }
437
+ return [...touchedPaths]
332
438
  }
333
439
 
334
- function cleanTuiConfig(path, dryRun, root) {
440
+ function cleanTuiConfig(path, dryRun, root, reviewedPaths = null) {
335
441
  const config = readJson(path)
336
442
  if (!config || !Array.isArray(config.plugin)) {
337
443
  stats.skipped += 1
@@ -345,17 +451,116 @@ function cleanTuiConfig(path, dryRun, root) {
345
451
  return
346
452
  }
347
453
  config.plugin = plugin
348
- if (dryRun) console.log(' ~ Would update tui.json (remove Pantheon-owned plugin references)')
349
- else writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
454
+ assertReviewedPath(path, reviewedPaths)
455
+ touchedPaths.add(path)
456
+ const content = `${JSON.stringify(config, null, 2)}\n`
457
+ if (dryRun) {
458
+ if (!quietPreview)
459
+ console.log(' ~ Would update tui.json (remove Pantheon-owned plugin references)')
460
+ } else {
461
+ assertNoSymlinkAncestors(path)
462
+ writeFileSync(path, content, 'utf8')
463
+ verifyRegularFile(path, content)
464
+ }
350
465
  stats.removed += 1
351
466
  }
352
467
 
468
+ function resetRunState() {
469
+ stats.removed = 0
470
+ stats.skipped = 0
471
+ stats.errors = 0
472
+ touchedPaths = new Set()
473
+ }
474
+
475
+ function collectUninstallPlan(target, options = {}) {
476
+ resetRunState()
477
+ const previousQuiet = quietPreview
478
+ quietPreview = true
479
+ try {
480
+ const paths = runUninstallPass(target, true, options) ?? []
481
+ return {
482
+ paths,
483
+ snapshots: paths.map((path) => ({ path, ...snapshot(path) })),
484
+ }
485
+ } finally {
486
+ quietPreview = previousQuiet
487
+ }
488
+ }
489
+
490
+ function uninstallOpenCode(target, dryRun, options = {}) {
491
+ resetRunState()
492
+ if (dryRun) {
493
+ const paths = runUninstallPass(target, true, options) ?? []
494
+ return { paths, snapshots: paths.map((path) => ({ path, ...snapshot(path) })) }
495
+ }
496
+
497
+ const reviewed = options.reviewedPlan ?? collectUninstallPlan(target, options)
498
+ const root = resolve(target)
499
+ const reviewedPaths = new Set(reviewed.paths)
500
+ if (reviewedPaths.size !== reviewed.paths.length) {
501
+ throw new Error('Uninstall reviewed plan contains duplicate paths')
502
+ }
503
+ const reviewedSnapshotPaths = new Set(reviewed.snapshots.map((item) => item.path))
504
+ if (
505
+ reviewedSnapshotPaths.size !== reviewed.snapshots.length ||
506
+ reviewedSnapshotPaths.size !== reviewedPaths.size ||
507
+ [...reviewedPaths].some((path) => !reviewedSnapshotPaths.has(path))
508
+ ) {
509
+ throw new Error('Uninstall reviewed plan operation set is invalid')
510
+ }
511
+ const currentPlan = collectUninstallPlan(target, options)
512
+ const currentPaths = [...currentPlan.paths].sort()
513
+ const expectedPaths = [...reviewedPaths].sort()
514
+ if (JSON.stringify(currentPaths) !== JSON.stringify(expectedPaths)) {
515
+ throw new Error('Uninstall operation set drifted before backup')
516
+ }
517
+ for (const expected of reviewed.snapshots) {
518
+ if (!isInside(expected.path, root))
519
+ throw new Error(`Uninstall path escaped target: ${expected.path}`)
520
+ const current = { path: expected.path, ...snapshot(expected.path) }
521
+ if (JSON.stringify(current) !== JSON.stringify(expected)) {
522
+ throw new Error(`Uninstall target drifted before backup: ${expected.path}`)
523
+ }
524
+ }
525
+
526
+ const backupEntries = reviewed.paths.filter((path) => {
527
+ const info = lstat(path)
528
+ return info?.isFile() && !info.isSymbolicLink()
529
+ })
530
+ let backupDir = null
531
+ if (backupEntries.length > 0) {
532
+ const backup = createBackupSet({
533
+ configDir: root,
534
+ entries: backupEntries.map((path) => relative(root, path)),
535
+ packageVersion: 'uninstall',
536
+ keep: 0,
537
+ })
538
+ backupDir = backup.dir
539
+ console.warn(backupWarning(backup.dir))
540
+ }
541
+
542
+ const applyPlan = collectUninstallPlan(target, options)
543
+ if (JSON.stringify([...applyPlan.paths].sort()) !== JSON.stringify(expectedPaths)) {
544
+ throw new Error('Uninstall operation set drifted after backup')
545
+ }
546
+ for (const expected of reviewed.snapshots) {
547
+ const current = { path: expected.path, ...snapshot(expected.path) }
548
+ if (JSON.stringify(current) !== JSON.stringify(expected)) {
549
+ throw new Error(`Uninstall target drifted after backup: ${expected.path}`)
550
+ }
551
+ }
552
+
553
+ resetRunState()
554
+ const paths = runUninstallPass(target, false, { ...options, reviewedPaths }) ?? []
555
+ return { paths, backupDir }
556
+ }
557
+
353
558
  function dirname(path) {
354
559
  return path.slice(0, path.lastIndexOf('/'))
355
560
  }
356
561
 
357
562
  async function confirmPrompt(message) {
358
- if (!isatty(process.stdin.fd)) return true
563
+ if (!isatty(process.stdin.fd) || !isatty(process.stdout.fd)) return false
359
564
  const readline = createInterface({ input: process.stdin, output: process.stdout })
360
565
  return new Promise((answer) =>
361
566
  readline.question(message, (value) => {
@@ -371,14 +576,33 @@ async function main() {
371
576
  const info = lstat(args.target)
372
577
  if (!info?.isDirectory() || info.isSymbolicLink())
373
578
  throw new Error(`Target directory does not exist or is a symlink: ${args.target}`)
374
- if (
375
- !args.dryRun &&
376
- !args.force &&
377
- !(await confirmPrompt(`⚠️ Remove Pantheon OpenCode artifacts from ${args.target}? [y/N] `))
378
- )
379
- return console.log('❌ Uninstall cancelled.')
579
+ const reviewedPlan = collectUninstallPlan(args.target, { purgeData: args.purgeData })
580
+ const hasChanges = reviewedPlan.paths.length > 0
380
581
  console.log(`${args.dryRun ? '🔍 Dry-run' : '🗑️ Uninstall'}: OpenCode/Pantheon only`)
381
- uninstallOpenCode(args.target, args.dryRun)
582
+ for (const path of reviewedPlan.paths) {
583
+ const purge =
584
+ args.purgeData && path === join(resolve(args.target), '.pantheon', 'install-state.json')
585
+ const update = ['opencode.json', 'tui.json'].includes(basename(path)) && !purge
586
+ console.log(` ${purge ? 'PURGE DATA' : update ? 'UPDATE' : 'REMOVE'} ${path}`)
587
+ }
588
+ if (args.dryRun) {
589
+ console.log(
590
+ `✅ ${stats.removed} would change, ${stats.skipped} skipped, ${stats.errors} errors`,
591
+ )
592
+ return
593
+ }
594
+ if (hasChanges && !args.force) {
595
+ if (!isatty(process.stdin.fd) || !isatty(process.stdout.fd)) {
596
+ console.error('❌ Non-interactive uninstall requires explicit --force authorization.')
597
+ process.exitCode = 2
598
+ return
599
+ }
600
+ if (!(await confirmPrompt(`⚠️ Apply this uninstall plan for ${args.target}? [y/N] `))) {
601
+ console.log('❌ Uninstall cancelled; no files were changed.')
602
+ return
603
+ }
604
+ }
605
+ uninstallOpenCode(args.target, false, { purgeData: args.purgeData, reviewedPlan })
382
606
  console.log(`✅ ${stats.removed} removed, ${stats.skipped} skipped, ${stats.errors} errors`)
383
607
  if (stats.errors) process.exitCode = 1
384
608
  }
@@ -0,0 +1,164 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { assertNoSymlinkAncestors } from './install/verify.mjs'
5
+
6
+ function parseVersion(version) {
7
+ const match = String(version)
8
+ .trim()
9
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/)
10
+ if (!match) return null
11
+ return {
12
+ core: [+match[1], +match[2], +match[3]],
13
+ beta: match[4] === undefined ? Number.POSITIVE_INFINITY : +match[4],
14
+ }
15
+ }
16
+
17
+ function compareVersions(left, right) {
18
+ const a = parseVersion(left)
19
+ const b = parseVersion(right)
20
+ if (!a || !b) return 0
21
+ for (let index = 0; index < 3; index += 1) {
22
+ if (a.core[index] !== b.core[index]) return a.core[index] - b.core[index]
23
+ }
24
+ return a.beta - b.beta
25
+ }
26
+
27
+ /** Update only the authorized global installation, then refresh its config. */
28
+ export function runUpdate(
29
+ args,
30
+ {
31
+ root,
32
+ currentVersion,
33
+ runningScript = process.argv[1],
34
+ execPath = process.execPath,
35
+ spawnSync = nodeSpawnSync,
36
+ refresh,
37
+ },
38
+ ) {
39
+ const channel = args.includes('--stable') ? 'latest' : 'beta'
40
+ const globalRootResult = spawnSync('npm', ['root', '-g'], {
41
+ encoding: 'utf8',
42
+ timeout: 10_000,
43
+ })
44
+ let globalPackageRoot = null
45
+ let runningPackageRoot = null
46
+ let runningScriptPath = null
47
+ try {
48
+ const npmGlobalRoot = (globalRootResult.stdout || '').trim()
49
+ if (globalRootResult.status === 0 && npmGlobalRoot) {
50
+ // Check the lexical npm root before realpath: a linked ancestor can make
51
+ // a workspace path look identical to an actual global package after
52
+ // canonicalization.
53
+ assertNoSymlinkAncestors(npmGlobalRoot)
54
+ const actualGlobalRoot = fs.realpathSync(npmGlobalRoot)
55
+ const installedEntry = path.join(actualGlobalRoot, 'pantheon-opencode')
56
+ const installedEntryStat = fs.lstatSync(installedEntry)
57
+ const runningRootStat = fs.lstatSync(path.resolve(root))
58
+ const runningScriptStat = fs.lstatSync(path.resolve(runningScript))
59
+ // A global npm link into a checkout resolves to the same realpath as the
60
+ // running source. Do not treat that provenance as a global installation.
61
+ if (
62
+ installedEntryStat.isSymbolicLink() ||
63
+ !installedEntryStat.isDirectory() ||
64
+ runningRootStat.isSymbolicLink() ||
65
+ !runningRootStat.isDirectory() ||
66
+ runningScriptStat.isSymbolicLink() ||
67
+ !runningScriptStat.isFile()
68
+ ) {
69
+ throw new Error('Global package identity is not a regular installed package')
70
+ }
71
+ globalPackageRoot = fs.realpathSync(installedEntry)
72
+ runningPackageRoot = fs.realpathSync(path.resolve(root))
73
+ runningScriptPath = fs.realpathSync(path.resolve(runningScript))
74
+ }
75
+ } catch {
76
+ // Missing, linked, or uncertain package identity is not authorized.
77
+ }
78
+ if (
79
+ !globalPackageRoot ||
80
+ path.resolve(root) !== globalPackageRoot ||
81
+ globalPackageRoot !== runningPackageRoot ||
82
+ runningScriptPath !== path.join(globalPackageRoot, 'bin', 'pantheon-init.mjs')
83
+ ) {
84
+ console.error(
85
+ '❌ update is available only from the global Pantheon installation; local, workspace, and npx copies are refused. ' +
86
+ 'Install globally with `npm install -g pantheon-opencode@beta`, then run `pantheon-opencode update`.',
87
+ )
88
+ return 1
89
+ }
90
+
91
+ console.log(`Pantheon OpenCode v${currentVersion} — checking the "${channel}" channel...`)
92
+ const view = spawnSync('npm', ['view', `pantheon-opencode@${channel}`, 'version'], {
93
+ encoding: 'utf8',
94
+ timeout: 30_000,
95
+ })
96
+ const latest = (view.stdout || '').trim().split('\n').filter(Boolean).pop()
97
+ if (view.status !== 0 || !latest) {
98
+ console.error(
99
+ `❌ Could not reach the npm registry: ${(view.stderr || view.stdout || '').trim() || 'no version returned'}`,
100
+ )
101
+ return 1
102
+ }
103
+
104
+ if (compareVersions(latest, currentVersion) <= 0) {
105
+ console.log(
106
+ `✅ Already up to date (installed v${currentVersion}; ${channel} channel: v${latest}).`,
107
+ )
108
+ return 0
109
+ }
110
+
111
+ console.log(`Updating v${currentVersion} → v${latest} (channel: ${channel})...`)
112
+ const install = spawnSync('npm', ['install', '-g', `pantheon-opencode@${channel}`], {
113
+ stdio: 'inherit',
114
+ })
115
+ if (install.status !== 0) {
116
+ console.error(
117
+ '❌ npm install failed; npm global replacement is not atomic and may have partially changed the package. ' +
118
+ 'Verify the global install with `pantheon-opencode --version` or reinstall the package.',
119
+ )
120
+ return install.status ?? 1
121
+ }
122
+
123
+ let refreshedPackageRoot = null
124
+ let refreshedVersion = null
125
+ try {
126
+ const refreshedRootResult = spawnSync('npm', ['root', '-g'], {
127
+ encoding: 'utf8',
128
+ timeout: 10_000,
129
+ })
130
+ if (refreshedRootResult.status === 0) {
131
+ const refreshedGlobalRoot = fs.realpathSync((refreshedRootResult.stdout || '').trim())
132
+ const refreshedEntry = path.join(refreshedGlobalRoot, 'pantheon-opencode')
133
+ const refreshedEntryStat = fs.lstatSync(refreshedEntry)
134
+ if (refreshedEntryStat.isSymbolicLink() || !refreshedEntryStat.isDirectory()) {
135
+ throw new Error('Refreshed global package identity is not a regular installed package')
136
+ }
137
+ refreshedPackageRoot = fs.realpathSync(refreshedEntry)
138
+ refreshedVersion = JSON.parse(
139
+ fs.readFileSync(path.join(refreshedPackageRoot, 'package.json'), 'utf8'),
140
+ ).version
141
+ }
142
+ } catch {
143
+ // The post-install check reports an incomplete package refresh.
144
+ }
145
+ if (refreshedVersion !== latest || !refreshedPackageRoot) {
146
+ console.error(
147
+ `❌ npm reported success, but the global package is v${refreshedVersion ?? 'unknown'} (expected v${latest}); config refresh was not run.`,
148
+ )
149
+ return 1
150
+ }
151
+
152
+ const globalEntry = path.join(refreshedPackageRoot, 'bin', 'pantheon-init.mjs')
153
+ const init = refresh
154
+ ? refresh(globalEntry)
155
+ : spawnSync(execPath, [globalEntry, 'init', '--yes', '--headless'], { stdio: 'inherit' })
156
+ if (init.status !== 0 || init.error) {
157
+ console.error(
158
+ '⚠️ Updated, but the config refresh did not complete — run `npx pantheon-opencode init` manually.',
159
+ )
160
+ return init.status ?? 1
161
+ }
162
+ console.log(`✅ Updated to v${latest}.`)
163
+ return 0
164
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url'
3
+ import { validateNodeEnginePolicy } from './node-engine-policy.mjs'
4
+
5
+ const root = fileURLToPath(new URL('..', import.meta.url))
6
+ const errors = validateNodeEnginePolicy(root)
7
+ if (errors.length) {
8
+ console.error('Node engine policy validation failed:')
9
+ for (const error of errors) console.error(`- ${error}`)
10
+ process.exitCode = 1
11
+ } else {
12
+ console.log('Node engine policy is consistent across manifests and lockfiles.')
13
+ }
@@ -19,6 +19,14 @@ export const MANIFESTS = Object.freeze(
19
19
  }),
20
20
  )
21
21
 
22
+ const RELEASE_METADATA = Object.freeze(
23
+ VERSION_MANIFEST_INVENTORY.filter((item) => item.role === 'metadata').map((item) => ({
24
+ name: item.file,
25
+ file: item.file,
26
+ kind: item.kind,
27
+ })),
28
+ )
29
+
22
30
  function readLegacyVersion(root, manifest) {
23
31
  const path = join(root, manifest.file)
24
32
  const text = readFileSync(path, 'utf8')
@@ -27,6 +35,11 @@ function readLegacyVersion(root, manifest) {
27
35
  if (!match) throw new Error(`${manifest.file} is missing version`)
28
36
  return match[1]
29
37
  }
38
+ if (manifest.kind === 'cff') {
39
+ const match = text.match(/^version:\s*([^\s#]+)\s*(?:#.*)?$/m)
40
+ if (!match) throw new Error(`${manifest.file} is missing version`)
41
+ return match[1]
42
+ }
30
43
  const data = JSON.parse(text)
31
44
  if (typeof data.version !== 'string') throw new Error(`${manifest.file} is missing version`)
32
45
  return data.version
@@ -64,6 +77,15 @@ export function compareVersions(root) {
64
77
  }
65
78
  return { name: manifest.name, version, ok: version !== null && version === source }
66
79
  })
80
+ const releaseMetadata = RELEASE_METADATA.map((manifest) => {
81
+ let version = null
82
+ try {
83
+ version = readLegacyVersion(root, manifest)
84
+ } catch (error) {
85
+ errors.push(`${manifest.file}: ${error.message}`)
86
+ }
87
+ return { name: manifest.name, version, ok: version !== null && version === source }
88
+ })
67
89
  const locks = LOCK_INVENTORY.map((entry) => {
68
90
  let version = null
69
91
  try {
@@ -81,11 +103,13 @@ export function compareVersions(root) {
81
103
  return {
82
104
  source,
83
105
  manifests,
106
+ releaseMetadata,
84
107
  locks,
85
108
  errors: [...new Set(errors)],
86
109
  ok:
87
110
  source !== null &&
88
111
  manifests.every((item) => item.ok) &&
112
+ releaseMetadata.every((item) => item.ok) &&
89
113
  locks.every((item) => item.ok) &&
90
114
  errors.length === 0,
91
115
  }
@@ -100,7 +124,11 @@ export function syncToSource(root) {
100
124
  const source = inventory.sourceVersion
101
125
  const changed = inventory.versions.filter(({ version }) => version !== source).length
102
126
  const lockChanged = inventory.documents.filter(
103
- ({ entry, data }) => entry.role === 'lock' && data.version !== source,
127
+ ({ entry, data }) =>
128
+ entry.role === 'lock' &&
129
+ (data.version !== source ||
130
+ (entry.file === 'package-lock.json' &&
131
+ data.packages?.['src/plugins/tui']?.version !== source)),
104
132
  ).length
105
133
  if (changed || lockChanged) writeInventoryVersion(inventory, source)
106
134
  return changed + lockChanged
@@ -114,6 +142,11 @@ function printDivergenceTable(result) {
114
142
  item.version ?? 'MISSING',
115
143
  item.ok ? 'ok' : 'DIVERGENT',
116
144
  ]),
145
+ ...result.releaseMetadata.map((item) => [
146
+ item.name,
147
+ item.version ?? 'MISSING',
148
+ item.ok ? 'ok' : 'DIVERGENT',
149
+ ]),
117
150
  ...result.locks.map((item) => [
118
151
  item.name,
119
152
  item.version ?? 'MISSING',