pantheon-opencode 1.5.0-beta.4 → 1.5.0-beta.5
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/CHANGELOG.md +36 -0
- package/README.md +39 -1
- package/README.pt-BR.md +37 -0
- package/bin/pantheon-init.mjs +178 -21
- package/package.json +13 -10
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/code_mode_server.py +35 -20
- package/scripts/doctor.mjs +178 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +80 -35
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/release-beta-version.mjs +2 -7
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +50 -4
- package/src/mcp/code_mode_server.py +35 -20
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/shared-board.ts +37 -0
- package/src/plugin.ts +6 -8
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
package/scripts/doctor.mjs
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { spawn as spawnAsync, spawnSync } from 'node:child_process'
|
|
25
|
-
import {
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
26
27
|
import { homedir } from 'node:os'
|
|
27
28
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
28
29
|
import { fileURLToPath } from 'node:url'
|
|
@@ -389,7 +390,7 @@ function checkMcpConfig(args) {
|
|
|
389
390
|
// ENOENT-on-spawn failure class (dead hardcoded path, ambiguous relative
|
|
390
391
|
// path) before opencode tries to spawn the server.
|
|
391
392
|
for (const [name, mcpEntry] of Object.entries(mcp)) {
|
|
392
|
-
if (
|
|
393
|
+
if (mcpEntry?.type !== 'local') continue
|
|
393
394
|
const cmd = mcpEntry.command
|
|
394
395
|
if (!Array.isArray(cmd) || cmd.length === 0) {
|
|
395
396
|
warn(`${cfg.label}: MCP "${name}" has no command array`)
|
|
@@ -530,7 +531,7 @@ async function checkMcpRuntimeSmoke(args) {
|
|
|
530
531
|
const local = []
|
|
531
532
|
for (const cfg of collectMcpConfigs(args)) {
|
|
532
533
|
for (const [name, entry] of Object.entries(cfg.data.mcp ?? {})) {
|
|
533
|
-
if (
|
|
534
|
+
if (entry?.type !== 'local') continue
|
|
534
535
|
if (!Array.isArray(entry.command) || entry.command.length === 0) continue
|
|
535
536
|
const cwd = resolveMcpCwd(entry, cfg.path)
|
|
536
537
|
const command = entry.command.map((part, index) =>
|
|
@@ -966,12 +967,20 @@ function checkVenvLayer(args) {
|
|
|
966
967
|
*/
|
|
967
968
|
export function checkCodeModeDir(args) {
|
|
968
969
|
section('F.2 Code-Mode Scripts')
|
|
969
|
-
const
|
|
970
|
-
const
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
970
|
+
const env = args.env ?? process.env
|
|
971
|
+
const target = resolve(env.PANTHEON_PROJECT ?? args.target)
|
|
972
|
+
const projectCandidates = [
|
|
973
|
+
join(target, '.opencode', '.pantheon', 'code-mode'),
|
|
974
|
+
join(target, '.pantheon', 'code-mode'),
|
|
975
|
+
]
|
|
976
|
+
// Seeding semantics: an existing project candidate wins; a project
|
|
977
|
+
// install (.opencode present) gets its runtime dir created when missing.
|
|
978
|
+
// Only targets with no project layout fall back to the global config dir.
|
|
979
|
+
const codeModeDir =
|
|
980
|
+
projectCandidates.find((candidate) => existsSync(candidate)) ??
|
|
981
|
+
(existsSync(join(target, '.opencode'))
|
|
982
|
+
? projectCandidates[0]
|
|
983
|
+
: join(resolveOpenCodeConfigDir(env), '.pantheon', 'code-mode'))
|
|
975
984
|
if (existsSync(codeModeDir)) {
|
|
976
985
|
pass(`Code-mode scripts directory exists: ${codeModeDir}`)
|
|
977
986
|
return codeModeDir
|
|
@@ -986,6 +995,83 @@ export function checkCodeModeDir(args) {
|
|
|
986
995
|
return codeModeDir
|
|
987
996
|
}
|
|
988
997
|
|
|
998
|
+
/** Resolve code-mode using the same project-first rule as the MCP server. */
|
|
999
|
+
export function resolveCodeModeDir(args) {
|
|
1000
|
+
const env = args.env ?? process.env
|
|
1001
|
+
const target = resolve(env.PANTHEON_PROJECT ?? args.target)
|
|
1002
|
+
const projectCandidates = [
|
|
1003
|
+
join(target, '.opencode', '.pantheon', 'code-mode'),
|
|
1004
|
+
join(target, '.pantheon', 'code-mode'),
|
|
1005
|
+
]
|
|
1006
|
+
const projectDir = projectCandidates.find((candidate) => existsSync(candidate))
|
|
1007
|
+
if (projectDir) return projectDir
|
|
1008
|
+
return join(resolveOpenCodeConfigDir(env), '.pantheon', 'code-mode')
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Validate a code-mode manifest without modifying it. */
|
|
1012
|
+
export function validateCodeModeManifest(codeModeDir) {
|
|
1013
|
+
const manifestPath = join(codeModeDir, 'manifest.json')
|
|
1014
|
+
if (!existsSync(manifestPath)) {
|
|
1015
|
+
return { ok: false, message: `Code-mode manifest missing: ${manifestPath}` }
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
let data
|
|
1019
|
+
try {
|
|
1020
|
+
data = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
1021
|
+
} catch {
|
|
1022
|
+
return { ok: false, message: `Code-mode manifest malformed: ${manifestPath}` }
|
|
1023
|
+
}
|
|
1024
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
1025
|
+
return { ok: false, message: 'Code-mode manifest malformed: root must be an object' }
|
|
1026
|
+
}
|
|
1027
|
+
if (data.version !== 1) {
|
|
1028
|
+
return { ok: false, message: 'Code-mode manifest malformed: version must be 1' }
|
|
1029
|
+
}
|
|
1030
|
+
if (!data.scripts || typeof data.scripts !== 'object' || Array.isArray(data.scripts)) {
|
|
1031
|
+
return { ok: false, message: 'Code-mode manifest malformed: scripts must be an object' }
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const listed = Object.keys(data.scripts)
|
|
1035
|
+
const invalid = listed.filter(
|
|
1036
|
+
(name) => typeof data.scripts[name] !== 'string' || !/^[a-f0-9]{64}$/i.test(data.scripts[name]),
|
|
1037
|
+
)
|
|
1038
|
+
if (invalid.length) {
|
|
1039
|
+
return { ok: false, message: `Code-mode manifest invalid digest: ${invalid.join(', ')}` }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const actualScripts = readdirSync(codeModeDir).filter(
|
|
1043
|
+
(name) =>
|
|
1044
|
+
(name.endsWith('.py') || name.endsWith('.sh')) && statSync(join(codeModeDir, name)).isFile(),
|
|
1045
|
+
)
|
|
1046
|
+
const missing = listed.filter((name) => !existsSync(join(codeModeDir, name)))
|
|
1047
|
+
const extra = actualScripts.filter((name) => !Object.hasOwn(data.scripts, name))
|
|
1048
|
+
const differing = listed.filter((name) => {
|
|
1049
|
+
const path = join(codeModeDir, name)
|
|
1050
|
+
if (!existsSync(path)) return false
|
|
1051
|
+
const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
1052
|
+
return digest !== data.scripts[name].toLowerCase()
|
|
1053
|
+
})
|
|
1054
|
+
if (missing.length || extra.length || differing.length) {
|
|
1055
|
+
const names = [...new Set([...missing, ...extra, ...differing])]
|
|
1056
|
+
return {
|
|
1057
|
+
ok: false,
|
|
1058
|
+
message: `Code-mode manifest mismatch: ${differing.length + missing.length + extra.length}/${listed.length} scripts differ — ${names.join(', ')}`,
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return { ok: true, count: listed.length, total: actualScripts.length }
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** Run the diagnostic manifest check after directory validation. */
|
|
1065
|
+
export function checkCodeModeManifest(args) {
|
|
1066
|
+
const codeModeDir = resolveCodeModeDir(args)
|
|
1067
|
+
const result = validateCodeModeManifest(codeModeDir)
|
|
1068
|
+
if (!result.ok) {
|
|
1069
|
+
error(result.message)
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
pass(`✅ Code-mode manifest valid: ${result.count}/${result.total} scripts verified`)
|
|
1073
|
+
}
|
|
1074
|
+
|
|
989
1075
|
/** Resolve the venv created by init for either a project or global install. */
|
|
990
1076
|
export function resolveRuntimePython(args) {
|
|
991
1077
|
const target = resolve(args.target)
|
|
@@ -1018,6 +1104,87 @@ export function resolveMcpCwd(entry, configPath = '') {
|
|
|
1018
1104
|
return dirname(configPath)
|
|
1019
1105
|
}
|
|
1020
1106
|
|
|
1107
|
+
// ---------------------------------------------------------------------------
|
|
1108
|
+
// Check H2: installed-version drift (beta.5)
|
|
1109
|
+
// ---------------------------------------------------------------------------
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Prerelease-aware comparison for pantheon versions (X.Y.Z or X.Y.Z-beta.N).
|
|
1113
|
+
* A stable release outranks any beta of the same core version. Returns
|
|
1114
|
+
* <0, 0, >0 (a - b). The shared compareVersions/parseVersion above ignores
|
|
1115
|
+
* the `-beta.N` suffix (it only reads 4 numeric groups), which would make
|
|
1116
|
+
* every beta pair compare equal.
|
|
1117
|
+
*/
|
|
1118
|
+
function comparePantheonVersions(a, b) {
|
|
1119
|
+
const parse = (v) => {
|
|
1120
|
+
const m = String(v)
|
|
1121
|
+
.trim()
|
|
1122
|
+
.match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/)
|
|
1123
|
+
if (!m) return null
|
|
1124
|
+
return {
|
|
1125
|
+
core: [+m[1], +m[2], +m[3]],
|
|
1126
|
+
beta: m[4] === undefined ? Number.POSITIVE_INFINITY : +m[4],
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
const pa = parse(a)
|
|
1130
|
+
const pb = parse(b)
|
|
1131
|
+
if (!pa || !pb) return 0
|
|
1132
|
+
for (let i = 0; i < 3; i++) {
|
|
1133
|
+
if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]
|
|
1134
|
+
}
|
|
1135
|
+
return pa.beta - pb.beta
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Compare the version recorded in the installation marker
|
|
1140
|
+
* (<config>/.pantheon/install-state.json, written by the postinstall sync)
|
|
1141
|
+
* against this package's version. When the package is newer, the config
|
|
1142
|
+
* merge/venv/MCP wiring may be stale — point the user at `update`.
|
|
1143
|
+
* Advisory only: a missing marker (pre-beta.5 install) is not an error.
|
|
1144
|
+
*/
|
|
1145
|
+
export function checkInstallVersionDrift(args) {
|
|
1146
|
+
section('H2 Installed Version')
|
|
1147
|
+
const env = args.env ?? process.env
|
|
1148
|
+
let pkgVersion = null
|
|
1149
|
+
try {
|
|
1150
|
+
pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
1151
|
+
} catch {
|
|
1152
|
+
return 'no-marker'
|
|
1153
|
+
}
|
|
1154
|
+
if (!pkgVersion) return 'no-marker'
|
|
1155
|
+
|
|
1156
|
+
const statePath = join(resolveOpenCodeConfigDir(env), '.pantheon', 'install-state.json')
|
|
1157
|
+
let installed = null
|
|
1158
|
+
if (existsSync(statePath)) {
|
|
1159
|
+
try {
|
|
1160
|
+
installed = JSON.parse(readFileSync(statePath, 'utf8')).pantheon_version ?? null
|
|
1161
|
+
} catch {
|
|
1162
|
+
installed = null
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
if (!installed) {
|
|
1166
|
+
info('No installation version marker found (pre-beta.5 install) — skipping drift check')
|
|
1167
|
+
return 'no-marker'
|
|
1168
|
+
}
|
|
1169
|
+
const drift = comparePantheonVersions(pkgVersion, installed)
|
|
1170
|
+
if (drift > 0) {
|
|
1171
|
+
warn(
|
|
1172
|
+
`Package v${pkgVersion} is newer than the last-synced installation (v${installed}) — ` +
|
|
1173
|
+
'run `npx pantheon-opencode update` (or re-run init) to refresh config, venv and MCP entries',
|
|
1174
|
+
)
|
|
1175
|
+
return 'package-newer'
|
|
1176
|
+
}
|
|
1177
|
+
if (drift < 0) {
|
|
1178
|
+
warn(
|
|
1179
|
+
`Installation marker (v${installed}) is newer than this package (v${pkgVersion}) — ` +
|
|
1180
|
+
'the doctor is running from a different install than the last sync',
|
|
1181
|
+
)
|
|
1182
|
+
return 'install-newer'
|
|
1183
|
+
}
|
|
1184
|
+
pass(`Installation in sync with package v${pkgVersion}`)
|
|
1185
|
+
return 'sync'
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1021
1188
|
// ---------------------------------------------------------------------------
|
|
1022
1189
|
// Check G: AGENTS.md freshness
|
|
1023
1190
|
// ---------------------------------------------------------------------------
|
|
@@ -1314,6 +1481,8 @@ async function main() {
|
|
|
1314
1481
|
checkMcpConfig(args)
|
|
1315
1482
|
checkVenvLayer(args)
|
|
1316
1483
|
checkCodeModeDir(args)
|
|
1484
|
+
checkCodeModeManifest(args)
|
|
1485
|
+
checkInstallVersionDrift(args)
|
|
1317
1486
|
await checkMcpRuntimeSmoke(args)
|
|
1318
1487
|
checkPermissionMismatches(args)
|
|
1319
1488
|
checkSyncStatus(args)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { strings } from './strings.mjs'
|
|
2
|
+
|
|
1
3
|
export const icons = {
|
|
2
4
|
success: '\u2705',
|
|
3
5
|
warning: '\u26a0\ufe0f ',
|
|
@@ -8,9 +10,9 @@ export const icons = {
|
|
|
8
10
|
arrow: '\u2192',
|
|
9
11
|
bullet: '\u2022',
|
|
10
12
|
star: '\u2b50',
|
|
11
|
-
wrench: '\
|
|
12
|
-
rocket: '\
|
|
13
|
-
package: '\
|
|
13
|
+
wrench: '\u{1F527}',
|
|
14
|
+
rocket: '\u{1F680}',
|
|
15
|
+
package: '\u{1F4E6}',
|
|
14
16
|
gear: '\u2699\ufe0f',
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -160,28 +162,32 @@ export function spinner(message) {
|
|
|
160
162
|
export function printSummary(target, platforms, stats) {
|
|
161
163
|
if (_quiet) return
|
|
162
164
|
|
|
165
|
+
const S = strings()
|
|
163
166
|
const line = '\u2500'.repeat(Math.min(process.stdout.columns || 60, 60))
|
|
164
167
|
const hasErrors = stats && stats.errors > 0
|
|
165
168
|
console.log(
|
|
166
169
|
tag(
|
|
167
170
|
hasErrors
|
|
168
|
-
? `${icons.warning}
|
|
169
|
-
: `${icons.rocket}
|
|
171
|
+
? `${icons.warning} ${S.summaryWithErrors(target)}`
|
|
172
|
+
: `${icons.rocket} ${S.summaryInstalled(target)}`,
|
|
170
173
|
),
|
|
171
174
|
)
|
|
172
175
|
console.log(tag(line))
|
|
173
176
|
console.log('')
|
|
174
177
|
|
|
175
178
|
if (stats) {
|
|
176
|
-
console.log(tag(
|
|
179
|
+
console.log(tag(S.summaryComponents(stats.created, stats.skipped)))
|
|
177
180
|
if (stats.errors > 0) {
|
|
178
|
-
console.warn(tag(
|
|
181
|
+
console.warn(tag(S.summaryErrors(stats.errors)))
|
|
182
|
+
}
|
|
183
|
+
if (stats.warnings > 0) {
|
|
184
|
+
console.warn(tag(S.summaryWarnings(stats.warnings)))
|
|
179
185
|
}
|
|
180
186
|
console.log('')
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
console.log(tag(` ${icons.star}
|
|
184
|
-
console.log(tag(
|
|
185
|
-
console.log(tag(
|
|
186
|
-
console.log(tag(
|
|
189
|
+
console.log(tag(` ${icons.star} ${S.summaryNext.trim()}`))
|
|
190
|
+
console.log(tag(S.summaryNextAgents))
|
|
191
|
+
console.log(tag(S.summaryNextMcp))
|
|
192
|
+
console.log(tag(S.summaryNextDoctor))
|
|
187
193
|
}
|
|
@@ -73,7 +73,7 @@ function convertPermissionsV2toV1(permArray) {
|
|
|
73
73
|
const result = {}
|
|
74
74
|
// Reverse action mapping: V2 action → V1 key
|
|
75
75
|
const ACTION_TO_V1 = Object.fromEntries(
|
|
76
|
-
Object.entries(V1_PERMISSION_TO_ACTION).map(([k, v]) => [v, k])
|
|
76
|
+
Object.entries(V1_PERMISSION_TO_ACTION).map(([k, v]) => [v, k]),
|
|
77
77
|
)
|
|
78
78
|
|
|
79
79
|
for (const { action, resource, effect } of permArray) {
|
|
@@ -104,7 +104,7 @@ const PROVIDER_RENAME = {
|
|
|
104
104
|
|
|
105
105
|
/** Reverse: V2 → V1 */
|
|
106
106
|
const PROVIDER_RENAME_REVERSE = Object.fromEntries(
|
|
107
|
-
Object.entries(PROVIDER_RENAME).map(([k, v]) => [v, k])
|
|
107
|
+
Object.entries(PROVIDER_RENAME).map(([k, v]) => [v, k]),
|
|
108
108
|
)
|
|
109
109
|
|
|
110
110
|
/**
|
|
@@ -176,9 +176,7 @@ function convertProviderV2toV1(name, config) {
|
|
|
176
176
|
|
|
177
177
|
// package → npm (strip aisdk: prefix)
|
|
178
178
|
if (result.package !== undefined) {
|
|
179
|
-
result.npm = result.package.startsWith('aisdk:')
|
|
180
|
-
? result.package.slice(6)
|
|
181
|
-
: result.package
|
|
179
|
+
result.npm = result.package.startsWith('aisdk:') ? result.package.slice(6) : result.package
|
|
182
180
|
delete result.package
|
|
183
181
|
}
|
|
184
182
|
|
|
@@ -303,7 +301,7 @@ const V1_TO_V2_RENAMES = {
|
|
|
303
301
|
|
|
304
302
|
/** Build reverse map */
|
|
305
303
|
const V2_TO_V1_RENAMES = Object.fromEntries(
|
|
306
|
-
Object.entries(V1_TO_V2_RENAMES).map(([k, v]) => [v, k])
|
|
304
|
+
Object.entries(V1_TO_V2_RENAMES).map(([k, v]) => [v, k]),
|
|
307
305
|
)
|
|
308
306
|
|
|
309
307
|
// ---------------------------------------------------------------------------
|
|
@@ -35,6 +35,7 @@ import { detectVersion, runMigrations } from './migrate.mjs'
|
|
|
35
35
|
import { resolveOpenCodeVersion } from './opencode-version.mjs'
|
|
36
36
|
import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
37
37
|
import {
|
|
38
|
+
checkRuntimePrerequisites,
|
|
38
39
|
collectSkillNames,
|
|
39
40
|
copyFiles,
|
|
40
41
|
installSkills,
|
|
@@ -43,8 +44,10 @@ import {
|
|
|
43
44
|
sourceDirValid,
|
|
44
45
|
summary,
|
|
45
46
|
syncDir,
|
|
47
|
+
writeConfigWithBackup,
|
|
46
48
|
writeIfChanged,
|
|
47
49
|
} from './shared.mjs'
|
|
50
|
+
import { strings } from './strings.mjs'
|
|
48
51
|
|
|
49
52
|
import { setupVenv, venvPythonPath } from './venv.mjs'
|
|
50
53
|
|
|
@@ -372,6 +375,45 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
|
|
|
372
375
|
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
|
|
373
376
|
}
|
|
374
377
|
|
|
378
|
+
/**
|
|
379
|
+
* beta.5: set up the Python venv + run the health check. Never throws —
|
|
380
|
+
* failures are reported as warnings and return false so the caller omits
|
|
381
|
+
* MCP entries from the config (entries pointing at a missing venv would
|
|
382
|
+
* make every MCP fail to launch). `deps` is injectable for tests.
|
|
383
|
+
*/
|
|
384
|
+
export function setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats, deps = {}) {
|
|
385
|
+
const S = strings()
|
|
386
|
+
const setup = deps.setupVenv ?? setupVenv
|
|
387
|
+
const check = deps.healthCheck ?? healthCheck
|
|
388
|
+
try {
|
|
389
|
+
const venvSpinner = spinner(S.settingUpRuntime)
|
|
390
|
+
setup(target, { dryRun, force: clean })
|
|
391
|
+
venvSpinner(true)
|
|
392
|
+
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
393
|
+
const health = check(runtimeTarget, { dryRun, pythonTarget: target })
|
|
394
|
+
|
|
395
|
+
section(`\uD83D\uDD0D ${S.healthSection}`)
|
|
396
|
+
const healthStep = step(S.runningHealthChecks)
|
|
397
|
+
for (const p of health.passed) success(`${p.check}: ${p.detail}`)
|
|
398
|
+
for (const w of health.warnings) warning(`${w.check}: ${w.detail}`)
|
|
399
|
+
for (const f of health.failed) error(`${f.check}: ${f.detail}`)
|
|
400
|
+
|
|
401
|
+
if (health.failed.length > 0) {
|
|
402
|
+
healthStep(false)
|
|
403
|
+
warning(`${health.failed.length} check(s) failed — review above`)
|
|
404
|
+
stats.warnings += health.failed.length
|
|
405
|
+
return false
|
|
406
|
+
}
|
|
407
|
+
healthStep(true)
|
|
408
|
+
return true
|
|
409
|
+
} catch (err) {
|
|
410
|
+
error(S.runtimeFailed(err.message))
|
|
411
|
+
warning(S.runtimeSkippedMcp)
|
|
412
|
+
stats.warnings += 1
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
375
417
|
export async function installOpenCode(
|
|
376
418
|
target,
|
|
377
419
|
dryRun = false,
|
|
@@ -386,6 +428,7 @@ export async function installOpenCode(
|
|
|
386
428
|
|
|
387
429
|
const componentSet = new Set(components)
|
|
388
430
|
const stats = summary.opencode
|
|
431
|
+
const S = strings()
|
|
389
432
|
|
|
390
433
|
// V1/V2 dual-version awareness (Phase 3): the installer builds a unified
|
|
391
434
|
// config, then applies V2-native migration (migrateV1toV2) when
|
|
@@ -434,6 +477,17 @@ export async function installOpenCode(
|
|
|
434
477
|
const config = readJsonConfig(targetConfigPath)
|
|
435
478
|
const pantheonConfig = readJsonConfig(pantheonConfigPath)
|
|
436
479
|
|
|
480
|
+
// beta.5 preflight: fail BEFORE any files are written when the runtime
|
|
481
|
+
// component needs toolchain pieces that are missing. Nothing has been
|
|
482
|
+
// copied yet at this point, so the failure is clean.
|
|
483
|
+
if (componentSet.has('runtime') && !dryRun) {
|
|
484
|
+
const missing = checkRuntimePrerequisites()
|
|
485
|
+
if (missing.length > 0) {
|
|
486
|
+
for (const problem of missing) error(problem)
|
|
487
|
+
throw new Error(S.prereqMissing)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
437
491
|
// Determine layout based on install scope.
|
|
438
492
|
// Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
|
|
439
493
|
// agents/ skills/ commands/ plugins/
|
|
@@ -446,7 +500,7 @@ export async function installOpenCode(
|
|
|
446
500
|
configure({ dryRun })
|
|
447
501
|
|
|
448
502
|
if (isGlobal) {
|
|
449
|
-
info(
|
|
503
|
+
info(S.globalLayout)
|
|
450
504
|
}
|
|
451
505
|
|
|
452
506
|
// -----------------------------------------------------------------------
|
|
@@ -459,7 +513,7 @@ export async function installOpenCode(
|
|
|
459
513
|
warning(`Agent source directory not found: ${srcDir}`)
|
|
460
514
|
stats.errors++
|
|
461
515
|
} else {
|
|
462
|
-
const agentStep = step(
|
|
516
|
+
const agentStep = step(S.installingAgents)
|
|
463
517
|
const dstDir = isGlobal ? join(target, 'agents') : join(target, '.opencode', 'agents')
|
|
464
518
|
if (!dryRun) mkdirSync(dstDir, { recursive: true })
|
|
465
519
|
if (clean && existsSync(dstDir) && !dryRun) {
|
|
@@ -712,6 +766,17 @@ export async function installOpenCode(
|
|
|
712
766
|
}
|
|
713
767
|
}
|
|
714
768
|
|
|
769
|
+
// -----------------------------------------------------------------------
|
|
770
|
+
// 2.11 Setup virtual environment + health check (--components runtime)
|
|
771
|
+
// beta.5: runs BEFORE the config write and is no longer fatal — on failure
|
|
772
|
+
// the install completes without MCP entries (which would point at a venv
|
|
773
|
+
// that does not exist) instead of aborting a half-done installation.
|
|
774
|
+
// -----------------------------------------------------------------------
|
|
775
|
+
let runtimeHealthy = !componentSet.has('runtime')
|
|
776
|
+
if (componentSet.has('runtime')) {
|
|
777
|
+
runtimeHealthy = setupRuntimePhase(target, { dryRun, clean, isGlobal }, stats)
|
|
778
|
+
}
|
|
779
|
+
|
|
715
780
|
// -----------------------------------------------------------------------
|
|
716
781
|
// 3. Create/update opencode.json (always runs)
|
|
717
782
|
// Reads TARGET's existing config first, then merges Pantheon settings
|
|
@@ -1053,8 +1118,10 @@ export async function installOpenCode(
|
|
|
1053
1118
|
|
|
1054
1119
|
// --------------------------------------------------------------------
|
|
1055
1120
|
// F.5 MCP server entries (for runtime-deployed scripts)
|
|
1121
|
+
// beta.5: skipped when the Python runtime setup failed — entries pointing
|
|
1122
|
+
// at a venv that does not exist would make every MCP fail to launch.
|
|
1056
1123
|
// --------------------------------------------------------------------
|
|
1057
|
-
if (componentSet.has('runtime')) {
|
|
1124
|
+
if (componentSet.has('runtime') && runtimeHealthy) {
|
|
1058
1125
|
config.mcp = config.mcp || {}
|
|
1059
1126
|
// MCP stays a flat named-server map for BOTH V1 and V2: OpenCode 1.18.x
|
|
1060
1127
|
// has no `mcp.servers` wrapper and rejects one with
|
|
@@ -1138,12 +1205,12 @@ export async function installOpenCode(
|
|
|
1138
1205
|
// before writing. V1 path is untouched (zero regression).
|
|
1139
1206
|
let configToWrite = config
|
|
1140
1207
|
if (version === 'v2') {
|
|
1141
|
-
info(
|
|
1208
|
+
info(S.v2Migrating)
|
|
1142
1209
|
configToWrite = migrateV1toV2(config)
|
|
1143
1210
|
}
|
|
1144
1211
|
|
|
1145
1212
|
const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
|
|
1146
|
-
const status =
|
|
1213
|
+
const status = writeConfigWithBackup(targetConfigPath, configContent, dryRun)
|
|
1147
1214
|
if (status === 'created') stats.created++
|
|
1148
1215
|
else stats.skipped++
|
|
1149
1216
|
|
|
@@ -1164,40 +1231,15 @@ export async function installOpenCode(
|
|
|
1164
1231
|
}
|
|
1165
1232
|
|
|
1166
1233
|
// -----------------------------------------------------------------------
|
|
1167
|
-
// 4.
|
|
1234
|
+
// 4. (runtime setup moved to 2.11 — before the config write — so MCP
|
|
1235
|
+
// entries are only added when the Python runtime is actually healthy)
|
|
1168
1236
|
// -----------------------------------------------------------------------
|
|
1169
|
-
if (componentSet.has('runtime')) {
|
|
1170
|
-
try {
|
|
1171
|
-
const venvSpinner = spinner('Setting up Python virtual environment')
|
|
1172
|
-
setupVenv(target, { dryRun, force: clean })
|
|
1173
|
-
venvSpinner(true)
|
|
1174
|
-
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
1175
|
-
const health = healthCheck(runtimeTarget, { dryRun, pythonTarget: target })
|
|
1176
|
-
|
|
1177
|
-
// Print health summary
|
|
1178
|
-
section('\uD83D\uDD0D Health Check')
|
|
1179
|
-
const healthStep = step('Running health checks')
|
|
1180
|
-
for (const p of health.passed) success(`${p.check}: ${p.detail}`)
|
|
1181
|
-
for (const w of health.warnings) warning(`${w.check}: ${w.detail}`)
|
|
1182
|
-
for (const f of health.failed) error(`${f.check}: ${f.detail}`)
|
|
1183
|
-
|
|
1184
|
-
if (health.failed.length > 0) {
|
|
1185
|
-
healthStep(false)
|
|
1186
|
-
warning(`${health.failed.length} check(s) failed — review above`)
|
|
1187
|
-
stats.errors += health.failed.length
|
|
1188
|
-
} else {
|
|
1189
|
-
healthStep(true)
|
|
1190
|
-
}
|
|
1191
|
-
} catch (err) {
|
|
1192
|
-
error(`Setup failed: ${err.message}`)
|
|
1193
|
-
throw err // Fatal — abort installation
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
1237
|
|
|
1197
1238
|
if (interactive && !dryRun) {
|
|
1198
1239
|
const created = stats.created
|
|
1199
1240
|
const skipped = stats.skipped
|
|
1200
1241
|
const errors = stats.errors
|
|
1242
|
+
const warnings = stats.warnings ?? 0
|
|
1201
1243
|
|
|
1202
1244
|
process.stdout.write('\n')
|
|
1203
1245
|
process.stdout.write(
|
|
@@ -1215,11 +1257,14 @@ export async function installOpenCode(
|
|
|
1215
1257
|
process.stdout.write(` ${colors.dim('\u2014')} ${skipped} already up-to-date\n`)
|
|
1216
1258
|
if (errors > 0)
|
|
1217
1259
|
process.stdout.write(` ${colors.yellow('\u26a0')} ${errors} error(s) encountered\n`)
|
|
1260
|
+
if (warnings > 0)
|
|
1261
|
+
process.stdout.write(
|
|
1262
|
+
` ${colors.yellow('\u26a0')} ${warnings} warning(s) — run 'pantheon-opencode doctor' for details\n`,
|
|
1263
|
+
)
|
|
1218
1264
|
process.stdout.write('\n')
|
|
1219
1265
|
process.stdout.write(` ${colors.bold('Next steps:')}\n`)
|
|
1220
1266
|
process.stdout.write(` ${colors.dim('\u2022')} Configure agents in opencode.json\n`)
|
|
1221
|
-
process.stdout.write(` ${colors.dim('\u2022')}
|
|
1222
|
-
process.stdout.write(` ${colors.dim('\u2022')} Run 'opencode doctor' to verify\n`)
|
|
1267
|
+
process.stdout.write(` ${colors.dim('\u2022')} Run 'pantheon-opencode doctor' to verify\n`)
|
|
1223
1268
|
process.stdout.write(
|
|
1224
1269
|
` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
|
|
1225
1270
|
)
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* shared.mjs — Shared utilities for the OpenCode installer
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
5
6
|
import {
|
|
7
|
+
copyFileSync,
|
|
6
8
|
cpSync,
|
|
7
9
|
existsSync,
|
|
8
10
|
mkdirSync,
|
|
9
11
|
readdirSync,
|
|
10
12
|
readFileSync,
|
|
13
|
+
renameSync,
|
|
11
14
|
rmSync,
|
|
12
15
|
statSync,
|
|
13
16
|
writeFileSync,
|
|
@@ -41,7 +44,7 @@ export function getAgentNames() {
|
|
|
41
44
|
// Cached constant for backward compatibility
|
|
42
45
|
export const AGENT_NAMES = getAgentNames()
|
|
43
46
|
|
|
44
|
-
export const summary = { opencode: { created: 0, skipped: 0, errors: 0 } }
|
|
47
|
+
export const summary = { opencode: { created: 0, skipped: 0, errors: 0, warnings: 0 } }
|
|
45
48
|
|
|
46
49
|
export function showHelp() {
|
|
47
50
|
console.log(`
|
|
@@ -260,11 +263,49 @@ export function writeIfChanged(filePath, content, dryRun) {
|
|
|
260
263
|
return 'skipped'
|
|
261
264
|
}
|
|
262
265
|
if (!dryRun) {
|
|
263
|
-
|
|
266
|
+
// Atomic write: write a temp sibling and rename over the target so a
|
|
267
|
+
// crash mid-write can never leave a truncated file behind (rename is
|
|
268
|
+
// atomic within the same directory).
|
|
269
|
+
const tmpPath = `${filePath}.tmp-${process.pid}`
|
|
270
|
+
writeFileSync(tmpPath, content, 'utf8')
|
|
271
|
+
renameSync(tmpPath, filePath)
|
|
264
272
|
}
|
|
265
273
|
return 'created'
|
|
266
274
|
}
|
|
267
275
|
|
|
276
|
+
/**
|
|
277
|
+
* beta.5: write the user's opencode.json with a same-directory backup of the
|
|
278
|
+
* previous content. The installer rewrites the whole config; a `.bak` gives
|
|
279
|
+
* the user a one-step recovery if a merge ever goes wrong.
|
|
280
|
+
*/
|
|
281
|
+
export function writeConfigWithBackup(filePath, content, dryRun) {
|
|
282
|
+
if (!dryRun && existsSync(filePath)) {
|
|
283
|
+
copyFileSync(filePath, `${filePath}.bak`)
|
|
284
|
+
}
|
|
285
|
+
return writeIfChanged(filePath, content, dryRun)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* beta.5 preflight: verify the toolchain pieces the runtime component needs
|
|
290
|
+
* BEFORE any files are written, so a missing python3/npm fails in seconds
|
|
291
|
+
* with a clear message instead of aborting a half-done install.
|
|
292
|
+
* Returns a list of human-readable problems (empty = all good).
|
|
293
|
+
*/
|
|
294
|
+
export function checkRuntimePrerequisites(env = process.env) {
|
|
295
|
+
const problems = []
|
|
296
|
+
const checks = [
|
|
297
|
+
{ cmd: 'python3', hint: 'install Python 3.10+ (https://www.python.org/downloads/)' },
|
|
298
|
+
{ cmd: 'npm', hint: 'install Node.js 22+ (https://nodejs.org) — npm ships with it' },
|
|
299
|
+
]
|
|
300
|
+
for (const { cmd, hint } of checks) {
|
|
301
|
+
const probe = spawnSync(cmd, ['--version'], { encoding: 'utf8', timeout: 10_000, env })
|
|
302
|
+
if (probe.error || probe.status !== 0) {
|
|
303
|
+
problems.push(`${cmd} not found or not runnable — ${hint}`)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return problems
|
|
307
|
+
}
|
|
308
|
+
|
|
268
309
|
export function collectSkillNames() {
|
|
269
310
|
const skillsDir = join(ROOT, 'src', 'skills')
|
|
270
311
|
if (!existsSync(skillsDir)) return []
|