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
@@ -41,11 +41,13 @@ import {
41
41
  mkdirSync,
42
42
  readdirSync,
43
43
  readFileSync,
44
+ realpathSync,
44
45
  rmSync,
45
46
  statSync,
46
47
  writeFileSync,
47
48
  } from 'node:fs'
48
49
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
50
+ import { assertNoSymlinkAncestors, verifyRegularFile, writeFileAtomically } from './verify.mjs'
49
51
 
50
52
  /** Directory (relative to the config dir) that holds the backup sets. */
51
53
  export const BACKUP_SUBDIR = join('.pantheon', 'backups')
@@ -179,13 +181,37 @@ function chmodBestEffort(path, mode) {
179
181
  * the exact backup (see `notRevertible`).
180
182
  */
181
183
  function ensureDir(path, mode) {
182
- if (existsSync(path)) return
184
+ assertNoSymlinkAncestors(path)
185
+ let info = null
183
186
  try {
184
- mkdirSync(path, { recursive: true, mode })
185
- chmodBestEffort(path, mode)
186
- } catch {
187
- // A concurrent creator won the race; leave it untouched.
187
+ info = lstatSync(path)
188
+ } catch (error) {
189
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error
190
+ }
191
+ if (info) {
192
+ if (!info.isDirectory()) throw new Error(`Backup path is not a directory: ${path}`)
193
+ return
194
+ }
195
+ mkdirSync(path, { recursive: true, mode })
196
+ assertNoSymlinkAncestors(path)
197
+ info = lstatSync(path)
198
+ if (!info.isDirectory() || info.isSymbolicLink()) {
199
+ throw new Error(`Backup path is not a regular directory: ${path}`)
188
200
  }
201
+ chmodBestEffort(path, mode)
202
+ }
203
+
204
+ function assertBackupEntry(configDir, relativePath) {
205
+ const rel = String(relativePath)
206
+ if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).some((part) => part === '..' || part === '')) {
207
+ throw new Error(`Unsafe backup entry path: ${rel}`)
208
+ }
209
+ const source = resolve(configDir, rel)
210
+ const fromRoot = relative(resolve(configDir), source)
211
+ if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
212
+ throw new Error(`Backup entry escapes config directory: ${rel}`)
213
+ }
214
+ return { rel, source }
189
215
  }
190
216
 
191
217
  /**
@@ -216,6 +242,7 @@ export function createBackupSet({
216
242
  clock = () => new Date(),
217
243
  keep = DEFAULT_KEEP,
218
244
  } = {}) {
245
+ assertNoSymlinkAncestors(configDir)
219
246
  const base = clock()
220
247
  const name = nextBackupName(configDir, base)
221
248
  const dir = backupSetPath(configDir, name)
@@ -225,10 +252,10 @@ export function createBackupSet({
225
252
  const seen = new Set()
226
253
  const manifestEntries = []
227
254
  for (const relativePath of entries ?? []) {
228
- const rel = String(relativePath)
255
+ const { rel, source } = assertBackupEntry(configDir, relativePath)
229
256
  if (rel === '' || seen.has(rel)) continue
230
257
  seen.add(rel)
231
- const source = join(configDir, rel)
258
+ assertNoSymlinkAncestors(source, { includeLeaf: false })
232
259
  let stat = null
233
260
  try {
234
261
  stat = lstatSync(source)
@@ -239,8 +266,10 @@ export function createBackupSet({
239
266
  const buffer = readFileSync(source)
240
267
  const dest = join(dir, rel)
241
268
  ensureDir(dirname(dest), 0o700)
242
- writeFileSync(dest, buffer, { mode: 0o600 })
269
+ assertNoSymlinkAncestors(dest)
270
+ writeFileSync(dest, buffer, { mode: 0o600, flag: 'wx' })
243
271
  chmodBestEffort(dest, 0o600)
272
+ verifyRegularFile(dest, buffer)
244
273
  manifestEntries.push({
245
274
  relativePath: rel,
246
275
  existed: true,
@@ -267,13 +296,82 @@ export function createBackupSet({
267
296
  entries: manifestEntries,
268
297
  }
269
298
  const manifestPath = join(dir, 'manifest.json')
270
- writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 })
299
+ assertNoSymlinkAncestors(manifestPath)
300
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
301
+ mode: 0o600,
302
+ flag: 'wx',
303
+ })
271
304
  chmodBestEffort(manifestPath, 0o600)
305
+ verifyRegularFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
272
306
 
273
307
  const pruned = keep > 0 ? pruneBackups(configDir, keep) : { removed: [], failures: [] }
274
308
  return { dir, name, manifest, pruned }
275
309
  }
276
310
 
311
+ /** Build the exact backup outputs without writing files. */
312
+ export function previewBackupSet({
313
+ configDir,
314
+ entries,
315
+ notRevertible = [],
316
+ packageVersion = 'unknown',
317
+ clock = () => new Date(),
318
+ } = {}) {
319
+ const base = clock()
320
+ const name = nextBackupName(configDir, base)
321
+ const dir = backupSetPath(configDir, name)
322
+ const seen = new Set()
323
+ const manifestEntries = []
324
+ const files = []
325
+ for (const relativePath of entries ?? []) {
326
+ const rel = String(relativePath)
327
+ if (rel === '' || seen.has(rel)) continue
328
+ seen.add(rel)
329
+ const source = join(configDir, rel)
330
+ let stat = null
331
+ try {
332
+ stat = lstatSync(source)
333
+ } catch {}
334
+ if (stat?.isFile()) {
335
+ const buffer = readFileSync(source)
336
+ files.push({
337
+ path: join(dir, rel),
338
+ bytes: buffer.length,
339
+ outputFingerprint: sha256(buffer),
340
+ })
341
+ manifestEntries.push({
342
+ relativePath: rel,
343
+ existed: true,
344
+ sha256: sha256(buffer),
345
+ bytes: buffer.length,
346
+ mode: stat.mode & 0o777,
347
+ })
348
+ } else {
349
+ manifestEntries.push({
350
+ relativePath: rel,
351
+ existed: false,
352
+ sha256: null,
353
+ bytes: 0,
354
+ mode: null,
355
+ })
356
+ }
357
+ }
358
+ const manifest = {
359
+ createdAt: base.toISOString(),
360
+ packageVersion,
361
+ configDir,
362
+ notRevertible: [...new Set((notRevertible ?? []).map(String))],
363
+ entries: manifestEntries,
364
+ }
365
+ const manifestContent = `${JSON.stringify(manifest, null, 2)}\n`
366
+ return {
367
+ dir,
368
+ files,
369
+ manifestPath: join(dir, 'manifest.json'),
370
+ manifestBytes: Buffer.byteLength(manifestContent, 'utf8'),
371
+ manifestFingerprint: sha256(Buffer.from(manifestContent)),
372
+ }
373
+ }
374
+
277
375
  /**
278
376
  * A manifest entry path must be a plain relative path inside the config dir —
279
377
  * no absolute path and no `..` segment (the backup mirrors the config dir).
@@ -285,7 +383,52 @@ export function createBackupSet({
285
383
  function isSafeRelativePath(rel) {
286
384
  if (typeof rel !== 'string' || rel === '') return false
287
385
  if (isAbsolute(rel)) return false
288
- return !rel.split(/[\\/]/).includes('..')
386
+ return !rel.split(/[\\/]/).some((part) => part === '' || part === '.' || part === '..')
387
+ }
388
+
389
+ function assertRestoreRoot(configDir) {
390
+ const root = resolve(configDir)
391
+ assertNoSymlinkAncestors(root)
392
+ const info = lstatSync(root)
393
+ if (!info.isDirectory() || info.isSymbolicLink()) {
394
+ throw new Error('Restore config root is not a regular directory')
395
+ }
396
+ return { root, canonicalRoot: realpathSync(root) }
397
+ }
398
+
399
+ function assertRestoreTarget(root, canonicalRoot, relativePath) {
400
+ if (!isSafeRelativePath(relativePath)) throw new Error('Unsafe restore target path')
401
+ const target = resolve(root, relativePath)
402
+ const lexical = relative(root, target)
403
+ if (!lexical || lexical === '..' || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) {
404
+ throw new Error('Restore target escapes config directory')
405
+ }
406
+
407
+ assertNoSymlinkAncestors(target)
408
+
409
+ let existing = target
410
+ let canonicalExisting
411
+ while (true) {
412
+ try {
413
+ canonicalExisting = realpathSync(existing)
414
+ break
415
+ } catch (error) {
416
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error
417
+ const parent = dirname(existing)
418
+ if (parent === existing) throw error
419
+ existing = parent
420
+ }
421
+ }
422
+ const canonicalTarget = resolve(canonicalExisting, relative(existing, target))
423
+ const canonicalRelative = relative(canonicalRoot, canonicalTarget)
424
+ if (
425
+ canonicalRelative === '..' ||
426
+ canonicalRelative.startsWith(`..${sep}`) ||
427
+ isAbsolute(canonicalRelative)
428
+ ) {
429
+ throw new Error('Restore target escapes config directory')
430
+ }
431
+ return target
289
432
  }
290
433
 
291
434
  /**
@@ -359,19 +502,35 @@ function restoreOrder(a, b) {
359
502
  * @returns {{ entries: Array<{relativePath: string, action: string}>, manifest: object } | { error: string }}
360
503
  */
361
504
  export function planRestore(dir, configDir) {
505
+ let root
506
+ let canonicalRoot
507
+ try {
508
+ ;({ root, canonicalRoot } = assertRestoreRoot(configDir))
509
+ } catch {
510
+ return { error: 'unsafe-target-root' }
511
+ }
362
512
  const read = readManifest(dir)
363
513
  if (!read.ok) return { error: read.reason }
364
514
  const verify = verifyBackupSet(dir)
365
515
  if (!verify.ok) return { error: verify.reason ?? 'integrity', mismatches: verify.mismatches }
366
516
 
517
+ try {
518
+ for (const entry of read.manifest.entries) {
519
+ assertRestoreTarget(root, canonicalRoot, entry.relativePath)
520
+ }
521
+ } catch {
522
+ return { error: 'unsafe-target-path' }
523
+ }
524
+
367
525
  const entries = []
368
526
  for (const entry of [...read.manifest.entries].sort(restoreOrder)) {
369
- const target = join(configDir, entry.relativePath)
527
+ const target = assertRestoreTarget(root, canonicalRoot, entry.relativePath)
370
528
  if (entry.existed) {
371
529
  let action = 'create'
372
530
  try {
373
531
  const st = lstatSync(target)
374
532
  if (st.isFile()) {
533
+ assertRestoreTarget(root, canonicalRoot, entry.relativePath)
375
534
  const current = readFileSync(target)
376
535
  action = sha256(current) === entry.sha256 ? 'unchanged' : 'update'
377
536
  } else {
@@ -382,6 +541,7 @@ export function planRestore(dir, configDir) {
382
541
  }
383
542
  entries.push({ relativePath: entry.relativePath, action })
384
543
  } else {
544
+ assertRestoreTarget(root, canonicalRoot, entry.relativePath)
385
545
  const exists = existsSync(target)
386
546
  entries.push({ relativePath: entry.relativePath, action: exists ? 'remove' : 'unchanged' })
387
547
  }
@@ -404,34 +564,47 @@ export function restoreBackupSet(dir, configDir, { dryRun = false } = {}) {
404
564
  const detail = plan.mismatches?.length ? ` (${plan.mismatches.length} mismatch(es))` : ''
405
565
  throw new Error(`Backup is not restorable: ${plan.error}${detail}`)
406
566
  }
567
+ const { root, canonicalRoot } = assertRestoreRoot(configDir)
568
+ for (const item of plan.entries) {
569
+ assertRestoreTarget(root, canonicalRoot, item.relativePath)
570
+ }
407
571
  if (dryRun) return { entries: plan.entries }
408
572
 
409
573
  for (const item of plan.entries) {
410
574
  const backUpFile = join(dir, item.relativePath)
411
- const target = join(configDir, item.relativePath)
575
+ const target = assertRestoreTarget(root, canonicalRoot, item.relativePath)
412
576
  if (item.action === 'unchanged') continue
413
577
 
414
- // Refuse to write through/over a symlink (mirrors writeIfChanged).
415
578
  let linkStat = null
416
579
  try {
417
580
  linkStat = lstatSync(target)
418
581
  } catch {
419
582
  linkStat = null
420
583
  }
421
- if (linkStat?.isSymbolicLink()) {
584
+ if (linkStat?.isSymbolicLink())
422
585
  throw new Error(`Refusing to restore through symlink: ${item.relativePath}`)
423
- }
424
586
 
425
587
  if (item.action === 'remove') {
426
- if (linkStat?.isFile()) rmSync(target, { force: true })
588
+ if (linkStat?.isFile()) {
589
+ assertRestoreTarget(root, canonicalRoot, item.relativePath)
590
+ const current = lstatSync(target)
591
+ if (current.isSymbolicLink()) {
592
+ throw new Error(`Refusing to restore through symlink: ${item.relativePath}`)
593
+ }
594
+ if (current.isFile()) rmSync(target, { force: true })
595
+ }
427
596
  continue
428
597
  }
429
598
 
430
599
  const buffer = readFileSync(backUpFile)
431
600
  ensureDir(dirname(target), 0o700)
432
- writeFileSync(target, buffer, { mode: 0o600 })
601
+ assertRestoreTarget(root, canonicalRoot, item.relativePath)
602
+ writeFileAtomically(target, buffer)
433
603
  const recorded = plan.manifest.entries.find((e) => e.relativePath === item.relativePath)
434
- if (recorded && recorded.mode !== null) chmodBestEffort(target, recorded.mode)
604
+ if (recorded && recorded.mode !== null) {
605
+ assertRestoreTarget(root, canonicalRoot, item.relativePath)
606
+ chmodBestEffort(target, recorded.mode)
607
+ }
435
608
  }
436
609
  return { entries: plan.entries }
437
610
  }
@@ -1,3 +1,4 @@
1
+ import { basename, dirname, join, resolve } from 'node:path'
1
2
  import { strings } from './strings.mjs'
2
3
 
3
4
  export const icons = {
@@ -196,3 +197,75 @@ export function printSummary(target, _platforms, stats) {
196
197
  console.log(tag(S.summaryNextMcp))
197
198
  console.log(tag(S.summaryNextDoctor))
198
199
  }
200
+
201
+ /**
202
+ * Build a persistent worktree location outside the project directory.
203
+ *
204
+ * @param {string} projectRoot - Project directory whose exact basename, including spaces, is retained.
205
+ * @returns {{ pattern: string, example: string }} Recommended paths for future worktrees.
206
+ */
207
+ export function getWorktreeRecommendation(projectRoot = process.cwd()) {
208
+ const resolvedProject = resolve(projectRoot)
209
+ const projectName = basename(resolvedProject)
210
+ const repositoryWorktreeRoot = resolve(
211
+ dirname(resolvedProject),
212
+ '.pantheon-worktrees',
213
+ projectName || 'project',
214
+ )
215
+
216
+ return {
217
+ pattern: join(repositoryWorktreeRoot, '<task>'),
218
+ example: join(repositoryWorktreeRoot, 'feature-name'),
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Show the suggested external worktree location in a real interactive TTY setup.
224
+ * Pantheon provides guidance only; it never creates a worktree or its directory.
225
+ *
226
+ * @param {object} [options]
227
+ * @param {string} [options.projectRoot=process.cwd()] - Project path for the recommendation.
228
+ * @param {boolean} [options.interactive=false] - Whether setup is interactive.
229
+ * @param {boolean} [options.dryRun=false] - Whether setup is preview-only.
230
+ * @returns {void}
231
+ */
232
+ export function printWorktreeGuidance({
233
+ projectRoot = process.cwd(),
234
+ interactive = false,
235
+ dryRun = false,
236
+ } = {}) {
237
+ if (!interactive || dryRun || !tty()) return
238
+
239
+ const recommendation = getWorktreeRecommendation(projectRoot)
240
+ const rows = [
241
+ { label: 'Default: ', value: recommendation.pattern, color: colors.green },
242
+ { label: 'Example: ', value: recommendation.example, color: colors.cyan },
243
+ {
244
+ label: 'Use: ',
245
+ value:
246
+ 'Persistent location; preserve the exact project directory basename, including spaces.',
247
+ color: colors.dim,
248
+ },
249
+ {
250
+ label: 'Note: ',
251
+ value: 'Pantheon does not create Git worktrees or directories automatically.',
252
+ color: colors.yellow,
253
+ },
254
+ ]
255
+ const contentWidth = Math.max(
256
+ 'Worktrees'.length,
257
+ ...rows.map(({ label, value }) => label.length + value.length),
258
+ )
259
+ const border = colors.cyan
260
+
261
+ console.log('')
262
+ console.log(` ${border(`┌${'─'.repeat(contentWidth + 2)}┐`)}`)
263
+ console.log(` ${border('│')} ${colors.bold('Worktrees'.padEnd(contentWidth))} ${border('│')}`)
264
+ console.log(` ${border(`├${'─'.repeat(contentWidth + 2)}┤`)}`)
265
+ for (const { label, value, color } of rows) {
266
+ const padding = ' '.repeat(contentWidth - label.length - value.length)
267
+ console.log(` ${border('│')} ${colors.bold(label)}${color(value)}${padding} ${border('│')}`)
268
+ }
269
+ console.log(` ${border(`└${'─'.repeat(contentWidth + 2)}┘`)}`)
270
+ console.log('')
271
+ }