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.
- package/AGENTS.global.md +42 -18
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +122 -38
- package/README.pt-BR.md +5 -4
- package/bin/pantheon-init.mjs +322 -101
- package/package.json +7 -6
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +128 -10
- package/scripts/check-plugin-v2-coverage.mjs +7 -7
- package/scripts/doctor.mjs +242 -19
- package/scripts/install/backup.mjs +648 -0
- package/scripts/install/cli-ui.mjs +79 -1
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/file-inventory.mjs +555 -0
- package/scripts/install/interactive.mjs +15 -1
- package/scripts/install/migrate.mjs +32 -6
- package/scripts/install/model-picker.mjs +63 -22
- package/scripts/install/opencode-version.mjs +9 -1
- package/scripts/install/opencode.mjs +761 -63
- package/scripts/install/plan-artifact.mjs +517 -0
- package/scripts/install/plan.mjs +449 -0
- package/scripts/install/plugin.mjs +136 -18
- package/scripts/install/shared.mjs +249 -94
- package/scripts/install/state.mjs +63 -8
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +25 -6
- package/scripts/install/verify.mjs +128 -0
- package/scripts/install/vision-prompt.mjs +13 -1
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/manifest-inventory.mjs +49 -3
- package/scripts/mcp_persistence.py +50 -15
- package/scripts/node-engine-policy.mjs +74 -0
- package/scripts/package-evidence.mjs +6 -0
- package/scripts/postinstall.mjs +3 -4
- package/scripts/prune.mjs +9 -3
- package/scripts/reconcile.mjs +325 -0
- package/scripts/restore.mjs +222 -0
- package/scripts/uninstall.mjs +258 -34
- package/scripts/update.mjs +164 -0
- package/scripts/validate-node-engine-policy.mjs +13 -0
- package/scripts/version-check.mjs +34 -1
- package/scripts/versioning.mjs +9 -1
- package/skills-lock.json +4 -4
- package/src/agents/iris.md +6 -0
- package/src/agents/mnemosyne.md +7 -2
- package/src/agents/zeus.md +19 -62
- package/src/instructions/agent-return-format.instructions.md +17 -15
- package/src/instructions/memory-protocol.instructions.md +3 -2
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +6 -6
- package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
- package/src/instructions/zeus-memory-operations.instructions.md +11 -6
- package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
- package/src/mcp/mcp_persistence.py +50 -15
- package/src/pantheon/cost-command.ts +5 -2
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +18 -29
- package/src/pantheon/v2-unsupported.mjs +2 -3
- package/src/plugin-v2.ts +13 -0
- package/src/plugins/tui/dist/tui-v2.js +2 -2
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +3 -3
- package/src/plugins/tui/package.json +2 -2
- package/src/skills/artifact-management/SKILL.md +5 -1
- package/src/skills/auto-continue/SKILL.md +14 -6
- package/src/skills/memory-bank/SKILL.md +5 -1
- package/src/skills/worktrees/SKILL.md +30 -11
package/scripts/uninstall.mjs
CHANGED
|
@@ -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 = {
|
|
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
|
|
135
|
-
if (
|
|
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
|
-
|
|
145
|
-
|
|
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())
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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 }) =>
|
|
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',
|