pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.10
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/.pantheon/code-mode/manifest.json +21 -0
- package/CHANGELOG.md +1026 -0
- package/README.md +171 -16
- package/README.pt-BR.md +99 -13
- package/bin/pantheon-init.mjs +178 -21
- package/package.json +23 -14
- package/plugin.json +1 -1
- package/pyproject.toml +65 -0
- package/scripts/code_mode_server.py +431 -55
- package/scripts/cost.mjs +30 -14
- 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 +137 -35
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +537 -128
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +121 -53
- package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +431 -55
- package/src/mcp/mcp_persistence_server.py +537 -128
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +43 -1
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +345 -62
- package/src/pantheon/delegate-manager.ts +390 -8
- package/src/pantheon/delegation-finalize.ts +32 -0
- package/src/pantheon/delegation.ts +235 -18
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +146 -12
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +18 -1
- package/src/plugin-v2.ts +2 -14
- package/src/plugin.ts +28 -17
- 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 +116 -27
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +414 -150
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +447 -84
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +447 -84
- package/.pantheon/code-mode/.gitkeep +0 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
- package/src/pantheon/delegate-task-adapter.ts +0 -444
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
import { execSync } from 'node:child_process'
|
|
6
6
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
7
7
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
8
|
-
import { warning } from './cli-ui.mjs'
|
|
9
8
|
import { ROOT, writeIfChanged } from './shared.mjs'
|
|
10
9
|
|
|
11
10
|
/**
|
|
@@ -95,31 +94,9 @@ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false }
|
|
|
95
94
|
result.skipped += copyResult.skipped
|
|
96
95
|
|
|
97
96
|
// Install plugin dependencies (@opentui/core, @opentui/solid, solid-js).
|
|
98
|
-
//
|
|
99
|
-
// fallback is opt-in because npm install may rewrite the dependency graph.
|
|
97
|
+
// Lockfile installation is the only accepted dependency path.
|
|
100
98
|
if (!dryRun) {
|
|
101
|
-
|
|
102
|
-
execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: dstDir, stdio: 'pipe' })
|
|
103
|
-
} catch (e) {
|
|
104
|
-
if (process.env.PANTHEON_ALLOW_NPM_INSTALL_FALLBACK === '1') {
|
|
105
|
-
warning(`npm ci failed; explicit fallback enabled: ${e.message}`)
|
|
106
|
-
try {
|
|
107
|
-
execSync('npm install --omit=dev --no-audit --no-fund --package-lock=false', {
|
|
108
|
-
cwd: dstDir,
|
|
109
|
-
stdio: 'pipe',
|
|
110
|
-
})
|
|
111
|
-
} catch (fallbackError) {
|
|
112
|
-
warning(`Failed to install TUI plugin dependencies: ${fallbackError.message}`)
|
|
113
|
-
result.errors++
|
|
114
|
-
}
|
|
115
|
-
} else {
|
|
116
|
-
warning(
|
|
117
|
-
`npm ci failed; dependencies were not changed. Set ` +
|
|
118
|
-
`PANTHEON_ALLOW_NPM_INSTALL_FALLBACK=1 to explicitly allow npm install: ${e.message}`,
|
|
119
|
-
)
|
|
120
|
-
result.errors++
|
|
121
|
-
}
|
|
122
|
-
}
|
|
99
|
+
execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: dstDir, stdio: 'pipe' })
|
|
123
100
|
}
|
|
124
101
|
|
|
125
102
|
return result
|
|
@@ -133,14 +110,7 @@ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false }
|
|
|
133
110
|
*/
|
|
134
111
|
export function installPluginDependencies(pluginDir, { dryRun = false } = {}) {
|
|
135
112
|
if (dryRun) return
|
|
136
|
-
|
|
137
|
-
execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: pluginDir, stdio: 'pipe' })
|
|
138
|
-
} catch (e) {
|
|
139
|
-
warning(
|
|
140
|
-
`npm ci failed; dependencies were not changed. Set ` +
|
|
141
|
-
`PANTHEON_ALLOW_NPM_INSTALL_FALLBACK=1 to explicitly allow npm install: ${e.message}`,
|
|
142
|
-
)
|
|
143
|
-
}
|
|
113
|
+
execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: pluginDir, stdio: 'pipe' })
|
|
144
114
|
}
|
|
145
115
|
|
|
146
116
|
/**
|
|
@@ -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 []
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* strings.mjs — installer strings with locale auto-detection.
|
|
3
|
+
*
|
|
4
|
+
* LANG/LC_ALL/LC_MESSAGES starting with "pt" selects the PT-BR table;
|
|
5
|
+
* everything else (and the absence of a locale) selects EN. A plain string
|
|
6
|
+
* table — deliberately no i18n framework (YAGNI).
|
|
7
|
+
*
|
|
8
|
+
* Scope: the user journey (banner → install steps → health → summary →
|
|
9
|
+
* next steps). Deep diagnostics (per-file copy detail, doctor output) and
|
|
10
|
+
* the model-picker wizard remain single-language by design.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const TABLE = {
|
|
14
|
+
en: {
|
|
15
|
+
installing: 'Installing...',
|
|
16
|
+
dryRun: 'DRY RUN',
|
|
17
|
+
canceled: 'Installation canceled — nothing was broken; run init again anytime.',
|
|
18
|
+
canceledShort: 'Installation canceled.',
|
|
19
|
+
installFailed: (msg) => `❌ Installation failed: ${msg}`,
|
|
20
|
+
failHintNoMcp:
|
|
21
|
+
' Run with --no-mcp to skip Python dependencies:\n npx pantheon-opencode init --no-mcp',
|
|
22
|
+
failHintForce:
|
|
23
|
+
' Or retry with --force to recreate the venv:\n npx pantheon-opencode init --force',
|
|
24
|
+
runningHealthCheck: 'Running health check...',
|
|
25
|
+
installedTitle: (v) => `✅ Pantheon OpenCode v${v} installed!`,
|
|
26
|
+
nextSteps: 'Next steps:',
|
|
27
|
+
nextVerify: '1. Verify installation:\n npx pantheon-opencode doctor',
|
|
28
|
+
nextLaunch: '2. Launch OpenCode',
|
|
29
|
+
nextAgents: '3. Invoke agents with @agent-name in chat',
|
|
30
|
+
nextProject: '4. For project-local install:\n npx pantheon-opencode init --project',
|
|
31
|
+
// install steps
|
|
32
|
+
globalLayout:
|
|
33
|
+
'Global config directory detected — using flat layout (agents/, skills/, commands/)',
|
|
34
|
+
installingAgents: 'Installing agents',
|
|
35
|
+
installingSkills: 'Installing skills',
|
|
36
|
+
installingInstructions: 'Installing instructions',
|
|
37
|
+
installingPrompts: 'Installing prompts',
|
|
38
|
+
installingCommands: 'Installing commands',
|
|
39
|
+
installingPlugins: 'Installing plugins',
|
|
40
|
+
settingUpRuntime: 'Setting up Python virtual environment',
|
|
41
|
+
healthSection: 'Health Check',
|
|
42
|
+
runningHealthChecks: 'Running health checks',
|
|
43
|
+
runtimeFailed: (msg) => `Python runtime setup failed: ${msg}`,
|
|
44
|
+
runtimeSkippedMcp:
|
|
45
|
+
'MCP servers were NOT configured because the Python runtime is unavailable. ' +
|
|
46
|
+
'Fix python3 (or free disk space) and re-run init to add them; everything else was installed.',
|
|
47
|
+
prereqMissing:
|
|
48
|
+
'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
|
|
49
|
+
v2Migrating: 'Migrating config to V2 native format...',
|
|
50
|
+
// summary (printSummary)
|
|
51
|
+
summaryInstalled: (target) => `OpenCode installed in ${target}`,
|
|
52
|
+
summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
|
|
53
|
+
summaryComponents: (created, skipped) =>
|
|
54
|
+
` Components:\t${created} installed, ${skipped} skipped`,
|
|
55
|
+
summaryErrors: (n) => ` ${n} error(s) found`,
|
|
56
|
+
summaryWarnings: (n) => ` ${n} warning(s) — run 'doctor' for details`,
|
|
57
|
+
summaryNext: ' Next steps:',
|
|
58
|
+
summaryNextAgents: ' • Configure your agents in opencode.json',
|
|
59
|
+
summaryNextMcp: ' • Add MCP servers in opencode.json (mcp section)',
|
|
60
|
+
summaryNextDoctor: " • Run 'opencode doctor' to verify the installation",
|
|
61
|
+
},
|
|
62
|
+
pt: {
|
|
63
|
+
installing: 'Instalando...',
|
|
64
|
+
dryRun: 'SIMULAÇÃO',
|
|
65
|
+
canceled: 'Instalação cancelada — nada foi quebrado; rode init de novo quando quiser.',
|
|
66
|
+
canceledShort: 'Instalação cancelada.',
|
|
67
|
+
installFailed: (msg) => `❌ Instalação falhou: ${msg}`,
|
|
68
|
+
failHintNoMcp:
|
|
69
|
+
' Rode com --no-mcp para pular dependências Python:\n npx pantheon-opencode init --no-mcp',
|
|
70
|
+
failHintForce:
|
|
71
|
+
' Ou tente de novo com --force para recriar a venv:\n npx pantheon-opencode init --force',
|
|
72
|
+
runningHealthCheck: 'Rodando verificação de saúde...',
|
|
73
|
+
installedTitle: (v) => `✅ Pantheon OpenCode v${v} instalado!`,
|
|
74
|
+
nextSteps: 'Próximos passos:',
|
|
75
|
+
nextVerify: '1. Verifique a instalação:\n npx pantheon-opencode doctor',
|
|
76
|
+
nextLaunch: '2. Abra o OpenCode',
|
|
77
|
+
nextAgents: '3. Invoc agentes com @nome-do-agente no chat',
|
|
78
|
+
nextProject: '4. Para instalar no projeto:\n npx pantheon-opencode init --project',
|
|
79
|
+
// install steps
|
|
80
|
+
globalLayout:
|
|
81
|
+
'Diretório de config global detectado — layout plano (agents/, skills/, commands/)',
|
|
82
|
+
installingAgents: 'Instalando agentes',
|
|
83
|
+
installingSkills: 'Instalando skills',
|
|
84
|
+
installingInstructions: 'Instalando instruções',
|
|
85
|
+
installingPrompts: 'Instalando prompts',
|
|
86
|
+
installingCommands: 'Instalando comandos',
|
|
87
|
+
installingPlugins: 'Instalando plugins',
|
|
88
|
+
settingUpRuntime: 'Configurando ambiente virtual Python',
|
|
89
|
+
healthSection: 'Verificação de saúde',
|
|
90
|
+
runningHealthChecks: 'Rodando verificações de saúde',
|
|
91
|
+
runtimeFailed: (msg) => `Falha ao configurar o runtime Python: ${msg}`,
|
|
92
|
+
runtimeSkippedMcp:
|
|
93
|
+
'Os servidores MCP NÃO foram configurados porque o runtime Python está indisponível. ' +
|
|
94
|
+
'Corrija o python3 (ou libere espaço em disco) e rode init de novo para adicioná-los; todo o resto foi instalado.',
|
|
95
|
+
prereqMissing:
|
|
96
|
+
'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
|
|
97
|
+
v2Migrating: 'Migrando config para o formato nativo V2...',
|
|
98
|
+
// summary (printSummary)
|
|
99
|
+
summaryInstalled: (target) => `OpenCode instalado em ${target}`,
|
|
100
|
+
summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
|
|
101
|
+
summaryComponents: (created, skipped) =>
|
|
102
|
+
` Componentes:\t${created} instalados, ${skipped} pulados`,
|
|
103
|
+
summaryErrors: (n) => ` ${n} erro(s) encontrados`,
|
|
104
|
+
summaryWarnings: (n) => ` ${n} aviso(s) — rode 'doctor' para detalhes`,
|
|
105
|
+
summaryNext: ' Próximos passos:',
|
|
106
|
+
summaryNextAgents: ' • Configure seus agentes em opencode.json',
|
|
107
|
+
summaryNextMcp: ' • Adicione MCP servers em opencode.json (seção mcp)',
|
|
108
|
+
summaryNextDoctor: " • Rode 'opencode doctor' para verificar a instalação",
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Pure locale resolution (exported for tests). */
|
|
113
|
+
export function detectLocale(env = process.env) {
|
|
114
|
+
const raw = env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG ?? ''
|
|
115
|
+
return /^pt/i.test(raw) ? 'pt' : 'en'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Pure table access (exported for tests and explicit-locale callers). */
|
|
119
|
+
export function stringsFor(locale) {
|
|
120
|
+
return TABLE[locale] ?? TABLE.en
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let cached = null
|
|
124
|
+
|
|
125
|
+
/** Locale-resolved string table for the current process. */
|
|
126
|
+
export function strings(env = process.env) {
|
|
127
|
+
if (cached === null) cached = stringsFor(detectLocale(env))
|
|
128
|
+
return cached
|
|
129
|
+
}
|
package/scripts/install-mcp.mjs
CHANGED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The only authoritative list of versioned manifests and lockfiles.
|
|
4
|
+
*
|
|
5
|
+
* Keep this list deliberately boring: release/version scripts import it rather
|
|
6
|
+
* than maintaining their own, subtly different lists.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process'
|
|
9
|
+
import { randomBytes } from 'node:crypto'
|
|
10
|
+
import {
|
|
11
|
+
closeSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
fchmodSync,
|
|
14
|
+
fsyncSync,
|
|
15
|
+
openSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
statSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from 'node:fs'
|
|
22
|
+
import { basename, dirname, join } from 'node:path'
|
|
23
|
+
|
|
24
|
+
export const SEMVER_PATTERN =
|
|
25
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
|
26
|
+
|
|
27
|
+
export const MANIFEST_INVENTORY = Object.freeze([
|
|
28
|
+
Object.freeze({
|
|
29
|
+
id: 'root-manifest',
|
|
30
|
+
role: 'manifest',
|
|
31
|
+
file: 'package.json',
|
|
32
|
+
kind: 'json',
|
|
33
|
+
name: 'pantheon-opencode',
|
|
34
|
+
valuePath: ['name'],
|
|
35
|
+
}),
|
|
36
|
+
Object.freeze({
|
|
37
|
+
id: 'plugin-manifest',
|
|
38
|
+
role: 'manifest',
|
|
39
|
+
file: 'plugin.json',
|
|
40
|
+
kind: 'json',
|
|
41
|
+
name: 'pantheon',
|
|
42
|
+
valuePath: ['name'],
|
|
43
|
+
}),
|
|
44
|
+
Object.freeze({
|
|
45
|
+
id: 'python-manifest',
|
|
46
|
+
role: 'manifest',
|
|
47
|
+
file: 'pyproject.toml',
|
|
48
|
+
kind: 'toml',
|
|
49
|
+
name: 'pantheon',
|
|
50
|
+
valuePath: ['project', 'name'],
|
|
51
|
+
}),
|
|
52
|
+
Object.freeze({
|
|
53
|
+
id: 'tui-manifest',
|
|
54
|
+
role: 'manifest',
|
|
55
|
+
file: 'src/plugins/tui/package.json',
|
|
56
|
+
kind: 'json',
|
|
57
|
+
name: 'pantheon-tui',
|
|
58
|
+
valuePath: ['name'],
|
|
59
|
+
}),
|
|
60
|
+
Object.freeze({
|
|
61
|
+
id: 'root-lock',
|
|
62
|
+
role: 'lock',
|
|
63
|
+
file: 'package-lock.json',
|
|
64
|
+
kind: 'json',
|
|
65
|
+
name: 'pantheon-opencode',
|
|
66
|
+
packageFile: 'package.json',
|
|
67
|
+
}),
|
|
68
|
+
Object.freeze({
|
|
69
|
+
id: 'tui-lock',
|
|
70
|
+
role: 'lock',
|
|
71
|
+
file: 'src/plugins/tui/package-lock.json',
|
|
72
|
+
kind: 'json',
|
|
73
|
+
name: 'pantheon-tui',
|
|
74
|
+
packageFile: 'src/plugins/tui/package.json',
|
|
75
|
+
}),
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
export const VERSION_MANIFEST_INVENTORY = Object.freeze(
|
|
79
|
+
MANIFEST_INVENTORY.filter(({ role }) => role === 'manifest'),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
export const LOCK_INVENTORY = Object.freeze(
|
|
83
|
+
MANIFEST_INVENTORY.filter(({ role }) => role === 'lock'),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
function readToml(file, text) {
|
|
87
|
+
const result = execFileSync(
|
|
88
|
+
'python3',
|
|
89
|
+
['-c', 'import json,sys,tomllib; print(json.dumps(tomllib.loads(sys.stdin.read())))'],
|
|
90
|
+
{ input: text, encoding: 'utf8' },
|
|
91
|
+
)
|
|
92
|
+
let parsed
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(result)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw new Error(`invalid TOML in ${file}: parser returned invalid JSON (${error.message})`)
|
|
97
|
+
}
|
|
98
|
+
if (!parsed || typeof parsed !== 'object') throw new Error(`invalid TOML in ${file}`)
|
|
99
|
+
return parsed
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readDocument(root, entry) {
|
|
103
|
+
const path = join(root, entry.file)
|
|
104
|
+
if (!existsSync(path)) throw new Error(`manifest inventory file is missing: ${entry.file}`)
|
|
105
|
+
const text = readFileSync(path, 'utf8')
|
|
106
|
+
if (entry.kind === 'toml') return { entry, path, text, data: readToml(entry.file, text) }
|
|
107
|
+
let data
|
|
108
|
+
try {
|
|
109
|
+
data = JSON.parse(text)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw new Error(`invalid JSON in ${entry.file}: ${error.message}`)
|
|
112
|
+
}
|
|
113
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
114
|
+
throw new Error(`invalid JSON document in ${entry.file}`)
|
|
115
|
+
}
|
|
116
|
+
return { entry, path, text, data }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function valueAt(data, path) {
|
|
120
|
+
return path.reduce((value, key) => value?.[key], data)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function assertSemver(version, label) {
|
|
124
|
+
if (typeof version !== 'string' || !SEMVER_PATTERN.test(version)) {
|
|
125
|
+
throw new Error(`${label} must be a valid semver`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function manifestVersion(document) {
|
|
130
|
+
const { entry, data } = document
|
|
131
|
+
const version =
|
|
132
|
+
entry.role === 'manifest'
|
|
133
|
+
? (data.version ?? valueAt(data, ['project', 'version']))
|
|
134
|
+
: data.version
|
|
135
|
+
if (typeof version !== 'string' || version.length === 0) {
|
|
136
|
+
throw new Error(`${entry.file} is missing version`)
|
|
137
|
+
}
|
|
138
|
+
assertSemver(version, `${entry.file} version`)
|
|
139
|
+
return version
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function manifestName(document) {
|
|
143
|
+
const { entry, data } = document
|
|
144
|
+
const name = entry.role === 'manifest' ? valueAt(data, entry.valuePath) : data.name
|
|
145
|
+
if (name !== entry.name) {
|
|
146
|
+
throw new Error(`${entry.file} name must be ${entry.name}`)
|
|
147
|
+
}
|
|
148
|
+
return name
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateLock(document, manifestsByFile, allowVersionDivergence) {
|
|
152
|
+
const { entry, data } = document
|
|
153
|
+
if (!Number.isInteger(data.lockfileVersion) || data.lockfileVersion < 1) {
|
|
154
|
+
throw new Error(`${entry.file} has an invalid lockfileVersion`)
|
|
155
|
+
}
|
|
156
|
+
if (data.name !== entry.name) throw new Error(`${entry.file} name must be ${entry.name}`)
|
|
157
|
+
assertSemver(data.version, `${entry.file} version`)
|
|
158
|
+
const manifest = manifestsByFile.get(entry.packageFile)
|
|
159
|
+
if (!manifest) throw new Error(`${entry.file} has no inventory manifest partner`)
|
|
160
|
+
const manifestVersionValue = manifestVersion(manifest)
|
|
161
|
+
if (!allowVersionDivergence && data.version !== manifestVersionValue) {
|
|
162
|
+
throw new Error(`${entry.file} version diverges from ${entry.packageFile}`)
|
|
163
|
+
}
|
|
164
|
+
const rootPackage = data.packages?.['']
|
|
165
|
+
if (!rootPackage || typeof rootPackage !== 'object' || Array.isArray(rootPackage)) {
|
|
166
|
+
throw new Error(`${entry.file} is missing packages[""]`)
|
|
167
|
+
}
|
|
168
|
+
if (rootPackage.name !== entry.name) {
|
|
169
|
+
throw new Error(`${entry.file} packages[""] name must be ${entry.name}`)
|
|
170
|
+
}
|
|
171
|
+
assertSemver(rootPackage.version, `${entry.file} packages[""] version`)
|
|
172
|
+
if (data.version !== rootPackage.version) {
|
|
173
|
+
throw new Error(`${entry.file} top-level version diverges from packages[""]`)
|
|
174
|
+
}
|
|
175
|
+
if (!allowVersionDivergence && rootPackage.version !== manifestVersionValue) {
|
|
176
|
+
throw new Error(`${entry.file} packages[""] version diverges from ${entry.packageFile}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read and validate every entry before a caller is allowed to write anything. */
|
|
181
|
+
export function validateInventory(root, { allowVersionDivergence = false } = {}) {
|
|
182
|
+
const documents = MANIFEST_INVENTORY.map((entry) => readDocument(root, entry))
|
|
183
|
+
const manifestsByFile = new Map(
|
|
184
|
+
documents
|
|
185
|
+
.filter(({ entry }) => entry.role === 'manifest')
|
|
186
|
+
.map((document) => [document.entry.file, document]),
|
|
187
|
+
)
|
|
188
|
+
const versions = documents
|
|
189
|
+
.filter(({ entry }) => entry.role === 'manifest')
|
|
190
|
+
.map((document) => ({ file: document.entry.file, version: manifestVersion(document) }))
|
|
191
|
+
documents.filter(({ entry }) => entry.role === 'manifest').forEach(manifestName)
|
|
192
|
+
const sourceVersion = versions[0].version
|
|
193
|
+
if (!allowVersionDivergence) {
|
|
194
|
+
for (const item of versions) {
|
|
195
|
+
if (item.version !== sourceVersion)
|
|
196
|
+
throw new Error(`${item.file} version diverges from package.json`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
documents
|
|
200
|
+
.filter(({ entry }) => entry.role === 'lock')
|
|
201
|
+
.forEach((document) => {
|
|
202
|
+
manifestName({
|
|
203
|
+
...document,
|
|
204
|
+
entry: { ...document.entry, role: 'manifest', valuePath: ['name'] },
|
|
205
|
+
})
|
|
206
|
+
validateLock(document, manifestsByFile, allowVersionDivergence)
|
|
207
|
+
})
|
|
208
|
+
return { documents, sourceVersion, versions }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function entryVersion(document) {
|
|
212
|
+
return document.entry.role === 'manifest' ? manifestVersion(document) : document.data.version
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function skipJsonWhitespace(text, offset) {
|
|
216
|
+
let cursor = offset
|
|
217
|
+
while (cursor < text.length && /\s/.test(text[cursor])) cursor += 1
|
|
218
|
+
return cursor
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function jsonStringEnd(text, start) {
|
|
222
|
+
if (text[start] !== '"') throw new Error('JSON string expected')
|
|
223
|
+
for (let cursor = start + 1; cursor < text.length; cursor += 1) {
|
|
224
|
+
if (text[cursor] === '\\') {
|
|
225
|
+
cursor += 1
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
if (text[cursor] === '"') return cursor
|
|
229
|
+
}
|
|
230
|
+
throw new Error('unterminated JSON string')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function jsonValueEnd(text, start, path, targetPath, ranges) {
|
|
234
|
+
const cursor = skipJsonWhitespace(text, start)
|
|
235
|
+
if (
|
|
236
|
+
path.length === targetPath.length &&
|
|
237
|
+
path.every((part, index) => part === targetPath[index])
|
|
238
|
+
) {
|
|
239
|
+
const end = jsonStringEnd(text, cursor)
|
|
240
|
+
ranges.push({ start: cursor + 1, end })
|
|
241
|
+
return end + 1
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (text[cursor] === '"') return jsonStringEnd(text, cursor) + 1
|
|
245
|
+
if (text[cursor] === '{') {
|
|
246
|
+
let next = skipJsonWhitespace(text, cursor + 1)
|
|
247
|
+
if (text[next] === '}') return next + 1
|
|
248
|
+
while (next < text.length) {
|
|
249
|
+
const keyEnd = jsonStringEnd(text, next)
|
|
250
|
+
const key = JSON.parse(text.slice(next, keyEnd + 1))
|
|
251
|
+
next = skipJsonWhitespace(text, keyEnd + 1)
|
|
252
|
+
if (text[next] !== ':') throw new Error('JSON object colon expected')
|
|
253
|
+
next = jsonValueEnd(text, next + 1, [...path, key], targetPath, ranges)
|
|
254
|
+
next = skipJsonWhitespace(text, next)
|
|
255
|
+
if (text[next] === '}') return next + 1
|
|
256
|
+
if (text[next] !== ',') throw new Error('JSON object separator expected')
|
|
257
|
+
next = skipJsonWhitespace(text, next + 1)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (text[cursor] === '[') {
|
|
261
|
+
let next = skipJsonWhitespace(text, cursor + 1)
|
|
262
|
+
let index = 0
|
|
263
|
+
if (text[next] === ']') return next + 1
|
|
264
|
+
while (next < text.length) {
|
|
265
|
+
next = jsonValueEnd(text, next, [...path, String(index)], targetPath, ranges)
|
|
266
|
+
index += 1
|
|
267
|
+
next = skipJsonWhitespace(text, next)
|
|
268
|
+
if (text[next] === ']') return next + 1
|
|
269
|
+
if (text[next] !== ',') throw new Error('JSON array separator expected')
|
|
270
|
+
next = skipJsonWhitespace(text, next + 1)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let next = cursor
|
|
275
|
+
while (next < text.length && !',]}'.includes(text[next])) next += 1
|
|
276
|
+
return next
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function replaceJsonStringValues(text, paths, version, file) {
|
|
280
|
+
const ranges = []
|
|
281
|
+
for (const path of paths) jsonValueEnd(text, 0, [], path, ranges)
|
|
282
|
+
if (ranges.length !== paths.length)
|
|
283
|
+
throw new Error(`${file} is missing writable version metadata`)
|
|
284
|
+
|
|
285
|
+
let updated = text
|
|
286
|
+
for (const range of ranges.sort((left, right) => right.start - left.start)) {
|
|
287
|
+
updated = `${updated.slice(0, range.start)}${version}${updated.slice(range.end)}`
|
|
288
|
+
}
|
|
289
|
+
return updated
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function replaceTomlProjectVersion(text, version, file) {
|
|
293
|
+
const projectHeader = /^\[project\]\s*$/m.exec(text)
|
|
294
|
+
if (!projectHeader) throw new Error(`${file} is missing [project] metadata`)
|
|
295
|
+
const projectStart = projectHeader.index + projectHeader[0].length
|
|
296
|
+
const nextTable = text.slice(projectStart).search(/^\s*\[/m)
|
|
297
|
+
const projectEnd = nextTable === -1 ? text.length : projectStart + nextTable
|
|
298
|
+
const project = text.slice(projectStart, projectEnd)
|
|
299
|
+
const versionPattern = /^(\s*version\s*=\s*)(["'])([^"'\r\n]+)\2([^\r\n]*)$/m
|
|
300
|
+
const match = versionPattern.exec(project)
|
|
301
|
+
if (!match) throw new Error(`${file} is missing project version metadata`)
|
|
302
|
+
const replacement = `${match[1]}${match[2]}${version}${match[2]}${match[4]}`
|
|
303
|
+
return (
|
|
304
|
+
text.slice(0, projectStart) +
|
|
305
|
+
project.replace(versionPattern, replacement) +
|
|
306
|
+
text.slice(projectEnd)
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function prepareDocumentVersion(document, version) {
|
|
311
|
+
const { entry, text } = document
|
|
312
|
+
if (entry.kind === 'toml') return replaceTomlProjectVersion(text, version, entry.file)
|
|
313
|
+
const paths = entry.role === 'lock' ? [['version'], ['packages', '', 'version']] : [['version']]
|
|
314
|
+
return replaceJsonStringValues(text, paths, version, entry.file)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Prepare all version metadata without changing any file. */
|
|
318
|
+
export function prepareInventoryVersion(inventory, version) {
|
|
319
|
+
assertSemver(version, 'new version')
|
|
320
|
+
if (!inventory || !Array.isArray(inventory.documents))
|
|
321
|
+
throw new Error('validated inventory is required')
|
|
322
|
+
return inventory.documents.map((document) => ({
|
|
323
|
+
path: document.path,
|
|
324
|
+
content: prepareDocumentVersion(document, version),
|
|
325
|
+
}))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function temporaryPath(path, label) {
|
|
329
|
+
return join(
|
|
330
|
+
dirname(path),
|
|
331
|
+
`.${basename(path)}.${label}.${process.pid}.${randomBytes(8).toString('hex')}`,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function cleanup(paths) {
|
|
336
|
+
for (const path of paths) {
|
|
337
|
+
try {
|
|
338
|
+
unlinkSync(path)
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error.code !== 'ENOENT') throw error
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Commit prepared files as one rollback-safe temp+rename transaction. */
|
|
346
|
+
export function commitPreparedUpdates(updates) {
|
|
347
|
+
const unique = new Set()
|
|
348
|
+
const prepared = []
|
|
349
|
+
try {
|
|
350
|
+
for (const { path, content } of updates) {
|
|
351
|
+
if (unique.has(path)) throw new Error(`duplicate prepared update: ${path}`)
|
|
352
|
+
unique.add(path)
|
|
353
|
+
if (typeof content !== 'string') throw new Error(`prepared content is not text: ${path}`)
|
|
354
|
+
const temp = temporaryPath(path, 'tmp')
|
|
355
|
+
try {
|
|
356
|
+
const mode = statSync(path).mode & 0o777
|
|
357
|
+
writeFileSync(temp, content, { encoding: 'utf8', flag: 'wx', mode })
|
|
358
|
+
const fd = openSync(temp, 'r+')
|
|
359
|
+
try {
|
|
360
|
+
fchmodSync(fd, mode)
|
|
361
|
+
fsyncSync(fd)
|
|
362
|
+
} finally {
|
|
363
|
+
closeSync(fd)
|
|
364
|
+
}
|
|
365
|
+
prepared.push({
|
|
366
|
+
path,
|
|
367
|
+
temp,
|
|
368
|
+
backup: temporaryPath(path, 'backup'),
|
|
369
|
+
moved: false,
|
|
370
|
+
installed: false,
|
|
371
|
+
})
|
|
372
|
+
} catch (error) {
|
|
373
|
+
cleanup([temp])
|
|
374
|
+
throw error
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch (error) {
|
|
378
|
+
cleanup(prepared.map(({ temp }) => temp))
|
|
379
|
+
throw error
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
for (const item of prepared) {
|
|
384
|
+
renameSync(item.path, item.backup)
|
|
385
|
+
item.moved = true
|
|
386
|
+
renameSync(item.temp, item.path)
|
|
387
|
+
item.installed = true
|
|
388
|
+
}
|
|
389
|
+
} catch (error) {
|
|
390
|
+
for (const item of [...prepared].reverse()) {
|
|
391
|
+
try {
|
|
392
|
+
if (item.installed) unlinkSync(item.path)
|
|
393
|
+
if (item.moved) renameSync(item.backup, item.path)
|
|
394
|
+
} catch {
|
|
395
|
+
// Preserve the original commit error; a failed rollback is still
|
|
396
|
+
// surfaced by the post-write validation performed by the caller.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
cleanup(prepared.map(({ temp }) => temp))
|
|
400
|
+
cleanup(prepared.map(({ backup }) => backup))
|
|
401
|
+
throw error
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
cleanup(prepared.map(({ backup }) => backup))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Update only version metadata in the already validated inventory. */
|
|
408
|
+
export function writeInventoryVersion(inventory, version) {
|
|
409
|
+
commitPreparedUpdates(prepareInventoryVersion(inventory, version))
|
|
410
|
+
}
|