pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11
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 +206 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +350 -0
- package/README.md +561 -52
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +70 -4
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +24 -17
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +428 -56
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +16 -2
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +1175 -215
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +62 -14
- package/scripts/install/shared.mjs +190 -48
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +171 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +7 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -172
- package/src/instructions/agent-return-format.instructions.md +34 -17
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -110
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +20 -67
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
- package/src/instructions/zeus-memory-operations.instructions.md +30 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
- package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +403 -102
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +418 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1158 -219
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +52 -35
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/opencode.json +0 -58
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Run `node --test` over an explicit file list, and FAIL when zero tests are collected.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: `node --test <glob>` exits 0 when the glob matches nothing (Node >= 22).
|
|
6
|
+
* A test script whose file set is absent from the checkout -- or absent from the published
|
|
7
|
+
* package, because the `files` allow-list omits it -- therefore reports success while
|
|
8
|
+
* collecting nothing. That is the worst possible failure mode for a release gate: it
|
|
9
|
+
* manufactures confidence.
|
|
10
|
+
*
|
|
11
|
+
* This runner closes that hole from two independent sides:
|
|
12
|
+
* 1. It expands the globs ITSELF and refuses to start the runner with zero files, so the
|
|
13
|
+
* runner is never handed an unexpanded pattern that silently matches nothing.
|
|
14
|
+
* 2. After a run that exited 0, it re-reads the collected count from the machine-readable
|
|
15
|
+
* TAP summary and fails closed when that count is 0, unparseable, or missing.
|
|
16
|
+
*
|
|
17
|
+
* Side 2 is deliberately independent of side 1: a future Node change to `--test` glob
|
|
18
|
+
* handling, a glob that expands to a directory of empty files, or an emptied test file all
|
|
19
|
+
* still get caught.
|
|
20
|
+
*
|
|
21
|
+
* Human-readable output is unchanged: the default `spec` reporter still goes to stdout, and
|
|
22
|
+
* the TAP reporter is diverted to a scratch file that is read once and deleted.
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* node scripts/run-node-tests.mjs [--label <name>] [-- <node flags...>] <glob>...
|
|
26
|
+
*
|
|
27
|
+
* Examples:
|
|
28
|
+
* node scripts/run-node-tests.mjs 'tests/*.mjs' 'tests/pantheon/*.mjs'
|
|
29
|
+
* node scripts/run-node-tests.mjs -- --experimental-test-coverage 'tests/*.mjs'
|
|
30
|
+
*/
|
|
31
|
+
import { spawnSync } from 'node:child_process'
|
|
32
|
+
import { globSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
33
|
+
import { tmpdir } from 'node:os'
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
import { fileURLToPath } from 'node:url'
|
|
36
|
+
|
|
37
|
+
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..')
|
|
38
|
+
|
|
39
|
+
function usage(message) {
|
|
40
|
+
console.error(`zero-test-guard: ${message}`)
|
|
41
|
+
console.error(
|
|
42
|
+
'usage: node scripts/run-node-tests.mjs [--label <name>] [-- <node flags...>] <glob>...',
|
|
43
|
+
)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse `[--label <name>] [-- <node flags...>] <glob>...`. */
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const separator = argv.indexOf('--')
|
|
50
|
+
const own = separator === -1 ? argv : argv.slice(0, separator)
|
|
51
|
+
const nodeFlags = separator === -1 ? [] : argv.slice(separator + 1)
|
|
52
|
+
|
|
53
|
+
let label = 'node tests'
|
|
54
|
+
const globs = []
|
|
55
|
+
for (let i = 0; i < own.length; i++) {
|
|
56
|
+
if (own[i] === '--label') {
|
|
57
|
+
const value = own[i + 1]
|
|
58
|
+
if (value === undefined) usage('--label requires a value')
|
|
59
|
+
label = value
|
|
60
|
+
i += 1
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (own[i].startsWith('-')) usage(`unknown option ${own[i]}`)
|
|
64
|
+
globs.push(own[i])
|
|
65
|
+
}
|
|
66
|
+
if (globs.length === 0) usage('at least one glob is required')
|
|
67
|
+
return { label, globs, nodeFlags }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Expand globs to a deduped, ordered file list.
|
|
72
|
+
* Order follows the positional order of the globs so that the suite runs in the same order
|
|
73
|
+
* as the shell used to; within a glob, matches are sorted for determinism.
|
|
74
|
+
*/
|
|
75
|
+
function expand(globs) {
|
|
76
|
+
const files = []
|
|
77
|
+
const seen = new Set()
|
|
78
|
+
for (const pattern of globs) {
|
|
79
|
+
const matches = globSync(pattern, { cwd: ROOT })
|
|
80
|
+
.filter((match) => !match.includes('node_modules') && !match.includes('__pycache__'))
|
|
81
|
+
.sort()
|
|
82
|
+
for (const match of matches) {
|
|
83
|
+
if (seen.has(match)) continue
|
|
84
|
+
seen.add(match)
|
|
85
|
+
files.push(match)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return files
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function reportMissingFiles(label, globs) {
|
|
92
|
+
console.error(`zero-test-guard: ${label} matched 0 test files — refusing to report success.`)
|
|
93
|
+
console.error(` globs: ${globs.join(' ')}`)
|
|
94
|
+
console.error(' This is the "test suite silently passes when it collects nothing" failure.')
|
|
95
|
+
console.error(' Either the test files are missing from this checkout, or they are missing')
|
|
96
|
+
console.error(' from the published package (check the "files" allow-list in package.json).')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function reportZeroCollected(label, files) {
|
|
100
|
+
console.error(`zero-test-guard: ${label} collected 0 tests — refusing to report success.`)
|
|
101
|
+
console.error(` ${files.length} file(s) matched, but not one test case was collected:`)
|
|
102
|
+
for (const file of files) console.error(` ${file}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Count the real test cases collected in a TAP summary.
|
|
107
|
+
*
|
|
108
|
+
* Node counts a test FILE that declares no test cases as one passing "test"
|
|
109
|
+
* (`ok 1 - tests/foo.test.mjs`), so the summary's `# tests N` is >= the file count and
|
|
110
|
+
* can never reach 0 once any file matched. Relying on it alone would let a suite whose
|
|
111
|
+
* every test file was emptied out report success.
|
|
112
|
+
*
|
|
113
|
+
* The reliable discriminator is the subtest NAME: a file-level placeholder is named with
|
|
114
|
+
* the file path that was passed to the runner, a real test case is named with its test
|
|
115
|
+
* name. So a top-level subtest whose name is one of our matched paths is a placeholder
|
|
116
|
+
* and is not counted as a collected case.
|
|
117
|
+
*
|
|
118
|
+
* Returns null when the summary cannot be read or parsed -- the caller fails closed.
|
|
119
|
+
*/
|
|
120
|
+
function countCollectedTests(tapPath, files) {
|
|
121
|
+
let tap = ''
|
|
122
|
+
try {
|
|
123
|
+
tap = readFileSync(tapPath, 'utf8')
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
// The summary count is still asserted to exist: without it the stream is not TAP.
|
|
128
|
+
if (!/^#\s*tests\s+\d+\s*$/m.test(tap)) return null
|
|
129
|
+
|
|
130
|
+
const placeholders = new Set(files)
|
|
131
|
+
let collected = 0
|
|
132
|
+
for (const match of tap.matchAll(/^# Subtest: (.+)$/gm)) {
|
|
133
|
+
if (placeholders.has(match[1].trim())) continue
|
|
134
|
+
collected += 1
|
|
135
|
+
}
|
|
136
|
+
return collected
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { label, globs, nodeFlags } = parseArgs(process.argv.slice(2))
|
|
140
|
+
const files = expand(globs)
|
|
141
|
+
|
|
142
|
+
if (files.length === 0) {
|
|
143
|
+
reportMissingFiles(label, globs)
|
|
144
|
+
process.exit(1)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const scratch = mkdtempSync(join(tmpdir(), 'pantheon-node-tests-'))
|
|
148
|
+
const tapPath = join(scratch, 'summary.tap')
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const run = spawnSync(
|
|
152
|
+
process.execPath,
|
|
153
|
+
[
|
|
154
|
+
'--test',
|
|
155
|
+
'--test-reporter=spec',
|
|
156
|
+
'--test-reporter-destination=stdout',
|
|
157
|
+
'--test-reporter=tap',
|
|
158
|
+
`--test-reporter-destination=${tapPath}`,
|
|
159
|
+
...nodeFlags,
|
|
160
|
+
...files,
|
|
161
|
+
],
|
|
162
|
+
{ cwd: ROOT, stdio: 'inherit' },
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if (run.error) {
|
|
166
|
+
console.error(`zero-test-guard: could not start the test runner: ${run.error.message}`)
|
|
167
|
+
process.exit(1)
|
|
168
|
+
}
|
|
169
|
+
if (run.signal) {
|
|
170
|
+
console.error(`zero-test-guard: the test runner was killed by ${run.signal}`)
|
|
171
|
+
process.exit(1)
|
|
172
|
+
}
|
|
173
|
+
// Real failures keep the runner's own exit code; the guard only speaks up when the
|
|
174
|
+
// runner claimed success it cannot justify.
|
|
175
|
+
if (run.status !== 0) process.exit(run.status)
|
|
176
|
+
|
|
177
|
+
const collected = countCollectedTests(tapPath, files)
|
|
178
|
+
if (collected === null) {
|
|
179
|
+
console.error('zero-test-guard: could not read the collected test count from the TAP summary.')
|
|
180
|
+
console.error(' Failing closed: an unreadable summary cannot certify that any test ran.')
|
|
181
|
+
process.exit(1)
|
|
182
|
+
}
|
|
183
|
+
if (collected === 0) {
|
|
184
|
+
reportZeroCollected(label, files)
|
|
185
|
+
process.exit(1)
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
189
|
+
}
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
21
21
|
import { join } from 'node:path'
|
|
22
|
+
import { copyAgentPrompts, writeAgentsMdManaged } from './build-agents-md.mjs'
|
|
22
23
|
|
|
23
24
|
import {
|
|
24
25
|
collectSkillNames,
|
|
25
|
-
copyFiles,
|
|
26
26
|
installSkills,
|
|
27
27
|
ROOT,
|
|
28
28
|
syncDir,
|
|
@@ -31,15 +31,16 @@ import {
|
|
|
31
31
|
|
|
32
32
|
/** Canonical MCP script sources — mirrors installOpenCode phase 2.10. */
|
|
33
33
|
const MCP_SCRIPTS = {
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
34
|
+
'mcp_resources.py': () => join(ROOT, 'src', 'mcp', 'mcp_resources.py'),
|
|
35
|
+
'code_mode.py': () => join(ROOT, 'src', 'mcp', 'code_mode.py'),
|
|
36
|
+
'memory_mcp.py': () => join(ROOT, 'src', 'mcp', 'memory_mcp.py'),
|
|
37
|
+
'mcp_persistence.py': () => join(ROOT, 'src', 'mcp', 'mcp_persistence.py'),
|
|
38
38
|
'_pantheon_paths.py': () => join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
|
|
39
39
|
'mcp_codemap_module.py': () => join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
|
|
40
|
-
'
|
|
40
|
+
'pantheon_vision.py': () => join(ROOT, 'src', 'mcp', 'pantheon_vision.py'),
|
|
41
41
|
'eval_store.py': () => join(ROOT, 'src', 'mcp', 'eval_store.py'),
|
|
42
42
|
'scrub-secrets.py': () => join(ROOT, 'scripts', 'scrub-secrets.py'),
|
|
43
|
+
'themis_heuristic_scan.py': () => join(ROOT, 'scripts', 'themis_heuristic_scan.py'),
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -59,7 +60,7 @@ export function syncCopyArtifacts(configDir) {
|
|
|
59
60
|
const dstAgents = join(configDir, 'agents')
|
|
60
61
|
if (existsSync(join(ROOT, 'src', 'agents'))) {
|
|
61
62
|
mkdirSync(dstAgents, { recursive: true })
|
|
62
|
-
bump(
|
|
63
|
+
bump(copyAgentPrompts(join(ROOT, 'src', 'agents'), dstAgents, false))
|
|
63
64
|
const routing = join(ROOT, 'src', 'routing.yml')
|
|
64
65
|
if (existsSync(routing)) {
|
|
65
66
|
if (
|
|
@@ -82,15 +83,27 @@ export function syncCopyArtifacts(configDir) {
|
|
|
82
83
|
result.errors.push(`skills: ${err.message}`)
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
// ── instructions (AGENTS.md) ──
|
|
86
|
+
// ── instructions (global AGENTS.md artifact) ──
|
|
87
|
+
// The config dir is GLOBAL context: it receives the repo-agnostic global
|
|
88
|
+
// artifact, never the repository artifact. Managed-region writer keeps any
|
|
89
|
+
// user content around the markers intact.
|
|
86
90
|
try {
|
|
87
|
-
const src = join(ROOT, 'AGENTS.md')
|
|
91
|
+
const src = join(ROOT, 'AGENTS.global.md')
|
|
88
92
|
if (existsSync(src)) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
const status = writeAgentsMdManaged(
|
|
94
|
+
join(configDir, 'AGENTS.md'),
|
|
95
|
+
readFileSync(src, 'utf8'),
|
|
96
|
+
false,
|
|
97
|
+
)
|
|
98
|
+
if (status === 'skipped') result.skipped++
|
|
99
|
+
else {
|
|
92
100
|
result.created++
|
|
93
|
-
|
|
101
|
+
if (status === 'merged') {
|
|
102
|
+
result.errors.push(
|
|
103
|
+
'instructions: non-generated content found in AGENTS.md — preserved around the Pantheon managed region',
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
94
107
|
}
|
|
95
108
|
} catch (err) {
|
|
96
109
|
result.errors.push(`instructions: ${err.message}`)
|
package/scripts/sync-tui.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
|
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
15
|
import { join } from 'node:path'
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
17
|
+
import { resolveOpenCodeVersion } from './install/opencode-version.mjs'
|
|
17
18
|
|
|
18
19
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
19
20
|
const ROOT = join(__dirname, '..')
|
|
@@ -63,7 +64,7 @@ function resolveConfigDir() {
|
|
|
63
64
|
* The raw TSX source is never copied — the loader consumes only the bundle.
|
|
64
65
|
* @returns {{ created: number, skipped: number }}
|
|
65
66
|
*/
|
|
66
|
-
function copyPluginFiles(srcDir, dstDir) {
|
|
67
|
+
function copyPluginFiles(srcDir, dstDir, version) {
|
|
67
68
|
const result = { created: 0, skipped: 0 }
|
|
68
69
|
|
|
69
70
|
mkdirSync(dstDir, { recursive: true })
|
|
@@ -87,11 +88,30 @@ function copyPluginFiles(srcDir, dstDir) {
|
|
|
87
88
|
// package.json
|
|
88
89
|
const pkgSrc = join(srcDir, 'package.json')
|
|
89
90
|
if (existsSync(pkgSrc)) {
|
|
90
|
-
const
|
|
91
|
+
const packageJson = JSON.parse(readFileSync(pkgSrc, 'utf8'))
|
|
92
|
+
if (version === 'v2' && packageJson.exports?.['./tui-v2']) {
|
|
93
|
+
packageJson.exports['./tui'] = packageJson.exports['./tui-v2']
|
|
94
|
+
}
|
|
95
|
+
const content = `${JSON.stringify(packageJson, null, 2)}\n`
|
|
91
96
|
if (writeIfChanged(join(dstDir, 'package.json'), content)) result.created++
|
|
92
97
|
else result.skipped++
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
const tuiEntry = join(srcDir, version === 'v2' ? 'tui-v2.ts' : 'tui.ts')
|
|
101
|
+
if (existsSync(tuiEntry)) {
|
|
102
|
+
if (writeIfChanged(join(dstDir, 'tui.ts'), readFileSync(tuiEntry, 'utf8'))) result.created++
|
|
103
|
+
else result.skipped++
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const v2ServerEntry = join(srcDir, 'index-v2.ts')
|
|
107
|
+
const v2ServerDestination = join(dstDir, 'index.ts')
|
|
108
|
+
if (version === 'v2' && existsSync(v2ServerEntry)) {
|
|
109
|
+
if (writeIfChanged(v2ServerDestination, readFileSync(v2ServerEntry, 'utf8'))) result.created++
|
|
110
|
+
else result.skipped++
|
|
111
|
+
} else if (existsSync(v2ServerDestination)) {
|
|
112
|
+
rmSync(v2ServerDestination, { force: true })
|
|
113
|
+
}
|
|
114
|
+
|
|
95
115
|
// Keep the copied plugin installable with deterministic npm ci.
|
|
96
116
|
const lockSrc = join(srcDir, 'package-lock.json')
|
|
97
117
|
if (existsSync(lockSrc)) {
|
|
@@ -169,7 +189,8 @@ async function main() {
|
|
|
169
189
|
const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
|
|
170
190
|
|
|
171
191
|
// Copy fresh files
|
|
172
|
-
const {
|
|
192
|
+
const version = resolveOpenCodeVersion('auto', { warn: (message) => console.warn(message) })
|
|
193
|
+
const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir, version)
|
|
173
194
|
|
|
174
195
|
// Lockfile installation is the only accepted dependency path. Strip
|
|
175
196
|
// global-context npm vars so a global postinstall can't trigger ECIGLOBAL.
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
# Env overrides:
|
|
35
35
|
# PANTHEON_SANDBOX_ROOT Sandbox root (default: ~/pantheon-sandbox)
|
|
36
36
|
# OPENCODE_V2_SPEC npm spec providing the V2 binary
|
|
37
|
-
# (default: @opencode
|
|
37
|
+
# (default: @opencode/cli@latest). NOTE: this is an
|
|
38
38
|
# install spec only — it is not a dependency of this
|
|
39
39
|
# package, and no code imports it.
|
|
40
40
|
# PANTHEON_REPO Repository the sandbox is built from (default: the
|
|
@@ -63,7 +63,7 @@ SANDBOX_ROOT="${PANTHEON_SANDBOX_ROOT:-$HOME/pantheon-sandbox}"
|
|
|
63
63
|
SANDBOX_HOME="$SANDBOX_ROOT/home"
|
|
64
64
|
NPM_PREFIX="$SANDBOX_HOME/.npm-global"
|
|
65
65
|
|
|
66
|
-
OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode
|
|
66
|
+
OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode/cli@latest}"
|
|
67
67
|
PANTHEON_SANDBOX_MODEL="${PANTHEON_SANDBOX_MODEL:-opencode-go/mimo-v2.5}"
|
|
68
68
|
PANTHEON_PROMPT_TIMEOUT="${PANTHEON_PROMPT_TIMEOUT:-300}"
|
|
69
69
|
|
|
@@ -241,11 +241,11 @@ try:
|
|
|
241
241
|
except OSError:
|
|
242
242
|
raise SystemExit(1)
|
|
243
243
|
expected = {
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"
|
|
248
|
-
"
|
|
244
|
+
"code_mode.py",
|
|
245
|
+
"memory_mcp.py",
|
|
246
|
+
"mcp_persistence.py",
|
|
247
|
+
"mcp_resources.py",
|
|
248
|
+
"pantheon_vision.py",
|
|
249
249
|
}
|
|
250
250
|
pattern = re.compile(
|
|
251
251
|
r"spawning process \{\s*"
|
|
@@ -265,7 +265,10 @@ for record in records:
|
|
|
265
265
|
if command != python_bin or cwd != runtime_dir or script not in expected:
|
|
266
266
|
raise SystemExit(1)
|
|
267
267
|
registered.add(script)
|
|
268
|
-
|
|
268
|
+
# OpenCode can initialize the same configured workspace more than once during
|
|
269
|
+
# service startup and `mcp list`. Count the logical server set here; the later
|
|
270
|
+
# `mcp list` assertion still requires exactly five connected MCP names.
|
|
271
|
+
if registered != expected:
|
|
269
272
|
raise SystemExit(1)
|
|
270
273
|
PY
|
|
271
274
|
then
|
|
@@ -418,6 +421,7 @@ V2_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
|
|
|
418
421
|
V2_PORT="${PANTHEON_V2_PORT:-49376}"
|
|
419
422
|
V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
|
|
420
423
|
V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
|
|
424
|
+
V2_MCP_LIST_TIMEOUT="${PANTHEON_V2_MCP_LIST_TIMEOUT:-15}"
|
|
421
425
|
V2_SERVER_LOG="$SANDBOX_DIR/v2-server.log"
|
|
422
426
|
V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
|
|
423
427
|
V2_SERVER_PID=""
|
|
@@ -515,11 +519,11 @@ try:
|
|
|
515
519
|
except OSError:
|
|
516
520
|
raise SystemExit(1)
|
|
517
521
|
expected = {
|
|
518
|
-
"
|
|
519
|
-
"
|
|
520
|
-
"
|
|
521
|
-
"
|
|
522
|
-
"
|
|
522
|
+
"code_mode.py",
|
|
523
|
+
"memory_mcp.py",
|
|
524
|
+
"mcp_persistence.py",
|
|
525
|
+
"mcp_resources.py",
|
|
526
|
+
"pantheon_vision.py",
|
|
523
527
|
}
|
|
524
528
|
pattern = re.compile(
|
|
525
529
|
r"spawning process \{\s*"
|
|
@@ -642,11 +646,11 @@ config_path = Path(os.environ["CONFIG_PATH"])
|
|
|
642
646
|
runtime_dir = os.environ["RUNTIME_DIR"]
|
|
643
647
|
python_bin = os.environ["MCP_PYTHON"]
|
|
644
648
|
managed = {
|
|
645
|
-
"pantheon-code-mode": "
|
|
646
|
-
"pantheon-memory": "
|
|
647
|
-
"pantheon-persistence": "
|
|
648
|
-
"pantheon-resources": "
|
|
649
|
-
"pantheon-vision": "
|
|
649
|
+
"pantheon-code-mode": "code_mode.py",
|
|
650
|
+
"pantheon-memory": "memory_mcp.py",
|
|
651
|
+
"pantheon-persistence": "mcp_persistence.py",
|
|
652
|
+
"pantheon-resources": "mcp_resources.py",
|
|
653
|
+
"pantheon-vision": "pantheon_vision.py",
|
|
650
654
|
}
|
|
651
655
|
try:
|
|
652
656
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
@@ -735,7 +739,8 @@ check_mcp() { # label binary project
|
|
|
735
739
|
if ! wait_for_v2_mcp_registrations "$project/.opencode" "$project/.venv/bin/python3"; then
|
|
736
740
|
kill "$list_pid" 2>/dev/null || true
|
|
737
741
|
wait "$list_pid" 2>/dev/null || true
|
|
738
|
-
|
|
742
|
+
echo "--- mcp list output (preserved at $list_file) ---" >&2
|
|
743
|
+
cat "$list_file" >&2 || true
|
|
739
744
|
record_status "$label MCP" "FAIL" "MCP registrations did not match the active sandbox"
|
|
740
745
|
return 0
|
|
741
746
|
fi
|
|
@@ -744,7 +749,8 @@ check_mcp() { # label binary project
|
|
|
744
749
|
else
|
|
745
750
|
kill "$list_pid" 2>/dev/null || true
|
|
746
751
|
wait "$list_pid" 2>/dev/null || true
|
|
747
|
-
|
|
752
|
+
echo "--- mcp list output (preserved at $list_file) ---" >&2
|
|
753
|
+
cat "$list_file" >&2 || true
|
|
748
754
|
record_status "$label MCP" "FAIL" "MCP handshakes did not become ready after mcp list started"
|
|
749
755
|
return 0
|
|
750
756
|
fi
|
|
@@ -809,6 +815,11 @@ if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$TGZ" >/dev/null 2>&1; then
|
|
|
809
815
|
echo "--- Init (headless) ---"
|
|
810
816
|
pantheon-opencode init --headless -y
|
|
811
817
|
rewrite_v2_mcp_config
|
|
818
|
+
node "$REPO_DIR/scripts/validate-sandbox-install.mjs" \
|
|
819
|
+
--repo "$REPO_DIR" \
|
|
820
|
+
--install "$NPM_PREFIX/lib/node_modules/pantheon-opencode" \
|
|
821
|
+
--project-config "$V2_CONFIG" \
|
|
822
|
+
--config "$PANTHEON_GLOBAL/opencode.json"
|
|
812
823
|
|
|
813
824
|
echo "--- MCP Validation ---"
|
|
814
825
|
check_mcp "V2" opencode2 "$SANDBOX_DIR/project-v2"
|
|
@@ -829,7 +840,7 @@ else
|
|
|
829
840
|
fi
|
|
830
841
|
|
|
831
842
|
echo "--- Gate (b): pantheon://agents CONTENT check ---"
|
|
832
|
-
RESOURCES_SRV="$PANTHEON_GLOBAL/scripts/
|
|
843
|
+
RESOURCES_SRV="$PANTHEON_GLOBAL/scripts/mcp_resources.py"
|
|
833
844
|
if [ ! -f "$RESOURCES_SRV" ]; then
|
|
834
845
|
record_status "pantheon://agents resource" "FAIL" "resources server not found at $RESOURCES_SRV"
|
|
835
846
|
else
|
|
@@ -871,6 +882,139 @@ fi
|
|
|
871
882
|
echo "Next: open the isolated TUI with: $SANDBOX_DIR/start-pantheon.sh"
|
|
872
883
|
RUNTEST
|
|
873
884
|
chmod +x "$SANDBOX_ROOT/run-test.sh"
|
|
885
|
+
|
|
886
|
+
cat > "$SANDBOX_ROOT/start-pantheon.sh" <<'STARTPANTHEON'
|
|
887
|
+
#!/usr/bin/env bash
|
|
888
|
+
# Generated by pantheon-opencode scripts/test-opencode-v2-sandbox.sh --prepare
|
|
889
|
+
# Launches the installed OpenCode beta with only this sandbox's HOME/config/db.
|
|
890
|
+
set -euo pipefail
|
|
891
|
+
|
|
892
|
+
SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
893
|
+
SANDBOX_HOME="$SANDBOX_DIR/home"
|
|
894
|
+
NPM_PREFIX="$SANDBOX_HOME/.npm-global"
|
|
895
|
+
SANDBOX_VENV="$SANDBOX_HOME/.config/opencode/.venv"
|
|
896
|
+
V2_PORT="${PANTHEON_V2_PORT:-49376}"
|
|
897
|
+
if [[ ! "$V2_PORT" =~ ^[0-9]{1,5}$ ]]; then
|
|
898
|
+
echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
|
|
899
|
+
exit 1
|
|
900
|
+
fi
|
|
901
|
+
V2_PORT=$((10#$V2_PORT))
|
|
902
|
+
if [ "$V2_PORT" -lt 1 ] || [ "$V2_PORT" -gt 65535 ]; then
|
|
903
|
+
echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
|
|
904
|
+
exit 1
|
|
905
|
+
fi
|
|
906
|
+
if ! python3 - "$V2_PORT" "$NPM_PREFIX" <<'CHECKPORT'
|
|
907
|
+
import errno
|
|
908
|
+
import os
|
|
909
|
+
import socket
|
|
910
|
+
import sys
|
|
911
|
+
from urllib.request import urlopen
|
|
912
|
+
|
|
913
|
+
port = int(sys.argv[1])
|
|
914
|
+
cli_prefix = os.path.realpath(
|
|
915
|
+
os.path.join(sys.argv[2], "lib", "node_modules", "@opencode", "cli")
|
|
916
|
+
)
|
|
917
|
+
try:
|
|
918
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
919
|
+
# A just-stopped service can leave a harmless TIME_WAIT socket behind.
|
|
920
|
+
# SO_REUSEADDR permits the new service to bind that port without
|
|
921
|
+
# masking a live listener or an unrelated server.
|
|
922
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
923
|
+
probe.bind(("127.0.0.1", port))
|
|
924
|
+
except OSError as exc:
|
|
925
|
+
if exc.errno != errno.EADDRINUSE:
|
|
926
|
+
print(f"ERROR: cannot reserve dedicated V2 port 127.0.0.1:{port}: {exc}", file=sys.stderr)
|
|
927
|
+
raise SystemExit(1)
|
|
928
|
+
|
|
929
|
+
# OpenCode intentionally keeps its managed service alive after the TUI exits.
|
|
930
|
+
# Reuse it only when /proc proves the listener is this sandbox's CLI binary.
|
|
931
|
+
inodes = set()
|
|
932
|
+
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
933
|
+
try:
|
|
934
|
+
with open(table, encoding="ascii") as entries:
|
|
935
|
+
next(entries, None)
|
|
936
|
+
for line in entries:
|
|
937
|
+
fields = line.split()
|
|
938
|
+
if len(fields) >= 10 and int(fields[1].rsplit(":", 1)[1], 16) == port and fields[3] == "0A":
|
|
939
|
+
inodes.add(fields[9])
|
|
940
|
+
except OSError:
|
|
941
|
+
pass
|
|
942
|
+
|
|
943
|
+
listener_pids = set()
|
|
944
|
+
for pid in (entry for entry in os.listdir("/proc") if entry.isdigit()):
|
|
945
|
+
fd_dir = f"/proc/{pid}/fd"
|
|
946
|
+
try:
|
|
947
|
+
for fd in os.listdir(fd_dir):
|
|
948
|
+
try:
|
|
949
|
+
target = os.readlink(os.path.join(fd_dir, fd))
|
|
950
|
+
except OSError:
|
|
951
|
+
continue
|
|
952
|
+
if target.startswith("socket:[") and target[8:-1] in inodes:
|
|
953
|
+
listener_pids.add(pid)
|
|
954
|
+
break
|
|
955
|
+
except OSError:
|
|
956
|
+
continue
|
|
957
|
+
|
|
958
|
+
owns_listener = False
|
|
959
|
+
for pid in listener_pids:
|
|
960
|
+
try:
|
|
961
|
+
with open(f"/proc/{pid}/cmdline", "rb") as command_line:
|
|
962
|
+
args = [arg.decode(errors="replace") for arg in command_line.read().split(b"\0") if arg]
|
|
963
|
+
except OSError:
|
|
964
|
+
continue
|
|
965
|
+
executable = os.path.realpath(args[0]) if args else ""
|
|
966
|
+
if (
|
|
967
|
+
executable.startswith(cli_prefix + os.sep)
|
|
968
|
+
and "serve" in args
|
|
969
|
+
and "--service" in args
|
|
970
|
+
and len(listener_pids) == 1
|
|
971
|
+
):
|
|
972
|
+
owns_listener = True
|
|
973
|
+
break
|
|
974
|
+
|
|
975
|
+
healthy = False
|
|
976
|
+
if owns_listener:
|
|
977
|
+
try:
|
|
978
|
+
scheme = "http"
|
|
979
|
+
loopback = ".".join(("127", "0", "0", "1"))
|
|
980
|
+
with urlopen(f"{scheme}://{loopback}:{port}/global/health", timeout=2) as response:
|
|
981
|
+
healthy = response.status == 200
|
|
982
|
+
except Exception:
|
|
983
|
+
pass
|
|
984
|
+
|
|
985
|
+
if not healthy:
|
|
986
|
+
print(
|
|
987
|
+
f"ERROR: dedicated V2 port 127.0.0.1:{port} is occupied by a different or unverifiable service; no service was stopped",
|
|
988
|
+
file=sys.stderr,
|
|
989
|
+
)
|
|
990
|
+
raise SystemExit(1)
|
|
991
|
+
print(f"Reusing the existing beta sandbox OpenCode service on 127.0.0.1:{port} (PID {next(iter(listener_pids))}).")
|
|
992
|
+
CHECKPORT
|
|
993
|
+
then
|
|
994
|
+
exit 1
|
|
995
|
+
fi
|
|
996
|
+
|
|
997
|
+
export HOME="$SANDBOX_HOME"
|
|
998
|
+
export PATH="$NPM_PREFIX/bin:$SANDBOX_VENV/bin:$PATH"
|
|
999
|
+
export npm_config_prefix="$NPM_PREFIX"
|
|
1000
|
+
export XDG_CONFIG_HOME="$SANDBOX_HOME/.config"
|
|
1001
|
+
export XDG_CONFIG_DIRS="$SANDBOX_HOME/.config/xdg"
|
|
1002
|
+
export XDG_DATA_HOME="$SANDBOX_HOME/.local/share"
|
|
1003
|
+
export XDG_DATA_DIRS="$SANDBOX_HOME/.local/share/xdg"
|
|
1004
|
+
export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
|
|
1005
|
+
export XDG_CACHE_HOME="$SANDBOX_HOME/.cache"
|
|
1006
|
+
export OPENCODE_CONFIG_DIR="$SANDBOX_DIR/project-v2"
|
|
1007
|
+
export OPENCODE_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
|
|
1008
|
+
export PANTHEON_V2_PORT="$V2_PORT"
|
|
1009
|
+
export PORT="$PANTHEON_V2_PORT"
|
|
1010
|
+
unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE \
|
|
1011
|
+
OPENCODE_DATA_DIR OPENCODE_STATE_DIR OPENCODE_CACHE_DIR OPENCODE_STORAGE_PATH
|
|
1012
|
+
|
|
1013
|
+
"$NPM_PREFIX/bin/opencode2" service set port "$V2_PORT"
|
|
1014
|
+
cd "$SANDBOX_DIR/project-v2"
|
|
1015
|
+
exec "$NPM_PREFIX/bin/opencode2" "$@"
|
|
1016
|
+
STARTPANTHEON
|
|
1017
|
+
chmod +x "$SANDBOX_ROOT/start-pantheon.sh"
|
|
874
1018
|
}
|
|
875
1019
|
|
|
876
1020
|
# ── Reset / Prepare ───────────────────────────────────────────────────────────
|
|
@@ -990,6 +1134,7 @@ prepare_project() {
|
|
|
990
1134
|
(cd "$dir" && "$pan" init --project --version "$TARGET_VERSION" --model "$PANTHEON_SANDBOX_MODEL" -y) \
|
|
991
1135
|
|| die "init --project --version $TARGET_VERSION failed in $dir"
|
|
992
1136
|
rewrite_v2_mcp_config
|
|
1137
|
+
validate_sandbox_install
|
|
993
1138
|
}
|
|
994
1139
|
|
|
995
1140
|
# The installer merges an existing project config. A reused sandbox can
|
|
@@ -1013,11 +1158,11 @@ config_path = Path(os.environ["CONFIG_PATH"])
|
|
|
1013
1158
|
runtime_dir = os.environ["RUNTIME_DIR"]
|
|
1014
1159
|
python_bin = os.environ["MCP_PYTHON"]
|
|
1015
1160
|
managed = {
|
|
1016
|
-
"pantheon-code-mode": "
|
|
1017
|
-
"pantheon-memory": "
|
|
1018
|
-
"pantheon-persistence": "
|
|
1019
|
-
"pantheon-resources": "
|
|
1020
|
-
"pantheon-vision": "
|
|
1161
|
+
"pantheon-code-mode": "code_mode.py",
|
|
1162
|
+
"pantheon-memory": "memory_mcp.py",
|
|
1163
|
+
"pantheon-persistence": "mcp_persistence.py",
|
|
1164
|
+
"pantheon-resources": "mcp_resources.py",
|
|
1165
|
+
"pantheon-vision": "pantheon_vision.py",
|
|
1021
1166
|
}
|
|
1022
1167
|
|
|
1023
1168
|
try:
|
|
@@ -1077,6 +1222,15 @@ for name, script in managed.items():
|
|
|
1077
1222
|
PY
|
|
1078
1223
|
}
|
|
1079
1224
|
|
|
1225
|
+
validate_sandbox_install() {
|
|
1226
|
+
node "$REPO_DIR/scripts/validate-sandbox-install.mjs" \
|
|
1227
|
+
--repo "$REPO_DIR" \
|
|
1228
|
+
--install "$NPM_PREFIX/lib/node_modules/pantheon-opencode" \
|
|
1229
|
+
--project-config "$V2_CONFIG" \
|
|
1230
|
+
--config "$SANDBOX_HOME/.config/opencode/opencode.json" \
|
|
1231
|
+
|| die "sandbox package/config validation failed"
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1080
1234
|
cmd_prepare() {
|
|
1081
1235
|
mkdir -p "$SANDBOX_HOME" "$NPM_PREFIX"
|
|
1082
1236
|
write_run_test_sh
|
|
@@ -19,7 +19,7 @@ import { createHash } from 'node:crypto'
|
|
|
19
19
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
20
20
|
import { dirname, join } from 'node:path'
|
|
21
21
|
import { fileURLToPath } from 'node:url'
|
|
22
|
-
import
|
|
22
|
+
import { load } from 'js-yaml'
|
|
23
23
|
import { validatePresetDefs } from '../src/pantheon/presets.mjs'
|
|
24
24
|
|
|
25
25
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
@@ -52,7 +52,7 @@ if (!existsSync(routingPath)) {
|
|
|
52
52
|
console.error('❌ routing.yml not found')
|
|
53
53
|
process.exit(1)
|
|
54
54
|
}
|
|
55
|
-
const routing =
|
|
55
|
+
const routing = load(readFileSync(routingPath, 'utf8'))
|
|
56
56
|
|
|
57
57
|
console.log('🔍 Validating routing.yml...\n')
|
|
58
58
|
|
|
@@ -253,7 +253,7 @@ function parseFrontmatter(filePath) {
|
|
|
253
253
|
const match = content.match(FRONTMATTER_RE)
|
|
254
254
|
if (!match) return null
|
|
255
255
|
try {
|
|
256
|
-
return
|
|
256
|
+
return load(match[1])
|
|
257
257
|
} catch {
|
|
258
258
|
return null
|
|
259
259
|
}
|