pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.4
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 +835 -0
- package/README.md +103 -15
- package/README.pt-BR.md +57 -13
- package/package.json +8 -3
- package/plugin.json +1 -1
- package/pyproject.toml +57 -0
- package/scripts/code_mode_server.py +403 -42
- package/scripts/install/opencode.mjs +42 -0
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +516 -163
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-pantheon-cost.mjs +10 -1
- package/scripts/release-beta-version.mjs +61 -60
- package/scripts/sync-tui.mjs +76 -54
- package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +131 -88
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +403 -42
- package/src/mcp/mcp_persistence_server.py +516 -163
- package/src/pantheon/background-job-board.ts +14 -0
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +62 -42
- package/src/pantheon/delegate-manager.ts +109 -12
- 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/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +18 -110
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +10 -1
- package/src/plugin.ts +19 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Build and verify the single immutable npm package evidence artifact. */
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from 'node:child_process'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { isAbsolute, join } from 'node:path'
|
|
19
|
+
import { validateInventory } from './manifest-inventory.mjs'
|
|
20
|
+
|
|
21
|
+
const CI_ARGS = ['ci', '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund']
|
|
22
|
+
const REQUIRED_PAYLOAD = [
|
|
23
|
+
'package/package.json',
|
|
24
|
+
'package/plugin.json',
|
|
25
|
+
'package/pyproject.toml',
|
|
26
|
+
'package/src/plugins/tui/package.json',
|
|
27
|
+
'package/src/plugins/tui/package-lock.json',
|
|
28
|
+
'package/src/plugin.ts',
|
|
29
|
+
'package/src/plugin-v2.ts',
|
|
30
|
+
'package/src/pantheon/v2-bridge.ts',
|
|
31
|
+
'package/src/plugins/tui/dist/tui.js',
|
|
32
|
+
'package/src/plugins/tui/dist/server.js',
|
|
33
|
+
'package/bin/pantheon-init.mjs',
|
|
34
|
+
'package/scripts/doctor.mjs',
|
|
35
|
+
'package/.pantheon/code-mode/compress-inline.py',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
function fail(message) {
|
|
39
|
+
throw new Error(message)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveTargetSha(root, supplied) {
|
|
43
|
+
const raw =
|
|
44
|
+
supplied ?? execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' })
|
|
45
|
+
const sha = String(raw).trim().toLowerCase()
|
|
46
|
+
if (!/^[0-9a-f]{40}$/.test(sha))
|
|
47
|
+
fail('target SHA must be a full lowercase 40-character hexadecimal SHA')
|
|
48
|
+
return sha
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createOutputDirectory(outputDir) {
|
|
52
|
+
const directory = outputDir ?? mkdtempSync(join(tmpdir(), 'pantheon-package-evidence-'))
|
|
53
|
+
mkdirSync(directory, { recursive: true })
|
|
54
|
+
if (readdirSync(directory).length !== 0) fail(`output directory must be empty: ${directory}`)
|
|
55
|
+
return { directory }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function packOnce(root, outputDir) {
|
|
59
|
+
const raw = execFileSync(
|
|
60
|
+
'npm',
|
|
61
|
+
['pack', '--ignore-scripts', '--json', '--pack-destination', outputDir],
|
|
62
|
+
{ cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
|
|
63
|
+
)
|
|
64
|
+
let result
|
|
65
|
+
try {
|
|
66
|
+
result = JSON.parse(raw)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
fail(`npm pack returned malformed JSON: ${error.message}`)
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(result) || result.length !== 1 || typeof result[0]?.filename !== 'string') {
|
|
71
|
+
fail('npm pack must produce exactly one JSON result')
|
|
72
|
+
}
|
|
73
|
+
const tgzFiles = readdirSync(outputDir).filter((name) => name.endsWith('.tgz'))
|
|
74
|
+
if (tgzFiles.length !== 1)
|
|
75
|
+
fail(`package evidence requires exactly one .tgz, found ${tgzFiles.length}`)
|
|
76
|
+
if (tgzFiles[0] !== result[0].filename)
|
|
77
|
+
fail('npm pack result does not identify the sole .tgz artifact')
|
|
78
|
+
return join(outputDir, tgzFiles[0])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validateArchiveEntries(tarball) {
|
|
82
|
+
const listing = execFileSync('tar', ['-tzf', tarball], { encoding: 'utf8' })
|
|
83
|
+
const entries = listing.split('\n').filter(Boolean)
|
|
84
|
+
if (entries.length === 0) fail('package tarball is empty')
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const normalized = entry.replace(/\/$/, '')
|
|
87
|
+
const parts = normalized.split('/')
|
|
88
|
+
if (
|
|
89
|
+
isAbsolute(normalized) ||
|
|
90
|
+
normalized.includes('\\') ||
|
|
91
|
+
parts.includes('..') ||
|
|
92
|
+
(!normalized.startsWith('package/') && normalized !== 'package')
|
|
93
|
+
) {
|
|
94
|
+
fail(`unsafe package archive entry: ${entry}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const detailed = execFileSync('tar', ['-tvzf', tarball], { encoding: 'utf8' })
|
|
98
|
+
for (const line of detailed.split('\n').filter(Boolean)) {
|
|
99
|
+
if (/^[lh]/i.test(line)) fail(`package archive contains a link entry: ${line}`)
|
|
100
|
+
}
|
|
101
|
+
return new Set(entries)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractSafely(tarball, workDir) {
|
|
105
|
+
const extractionRoot = join(workDir, 'extract')
|
|
106
|
+
mkdirSync(extractionRoot)
|
|
107
|
+
validateArchiveEntries(tarball)
|
|
108
|
+
execFileSync(
|
|
109
|
+
'tar',
|
|
110
|
+
['-xzf', tarball, '--directory', extractionRoot, '--no-same-owner', '--no-same-permissions'],
|
|
111
|
+
{ encoding: 'utf8' },
|
|
112
|
+
)
|
|
113
|
+
const realRoot = realpathSync(extractionRoot)
|
|
114
|
+
const walk = (current) => {
|
|
115
|
+
const stat = lstatSync(current)
|
|
116
|
+
if (stat.isSymbolicLink()) fail(`package archive extraction contains a symlink: ${current}`)
|
|
117
|
+
const resolved = realpathSync(current)
|
|
118
|
+
if (resolved !== realRoot && !resolved.startsWith(`${realRoot}/`)) {
|
|
119
|
+
fail(`package archive extraction escaped its temporary directory: ${current}`)
|
|
120
|
+
}
|
|
121
|
+
if (stat.isDirectory()) for (const name of readdirSync(current)) walk(join(current, name))
|
|
122
|
+
}
|
|
123
|
+
walk(extractionRoot)
|
|
124
|
+
return join(extractionRoot, 'package')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function validatePayload(packageRoot, inventory) {
|
|
128
|
+
for (const relativePath of REQUIRED_PAYLOAD) {
|
|
129
|
+
const path = join(packageRoot, relativePath.replace(/^package\//, ''))
|
|
130
|
+
if (!existsSync(path) || !lstatSync(path).isFile())
|
|
131
|
+
fail(`required package payload is missing: ${relativePath}`)
|
|
132
|
+
}
|
|
133
|
+
for (const document of inventory.documents) {
|
|
134
|
+
const { entry, path: sourcePath } = document
|
|
135
|
+
const packagedPath = join(packageRoot, entry.file)
|
|
136
|
+
if (entry.role === 'lock' && entry.file === 'package-lock.json' && !existsSync(packagedPath)) {
|
|
137
|
+
// npm-packlist excludes the package root lockfile by policy. It remains
|
|
138
|
+
// validated below as source evidence, but is not fabricated in the tgz.
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (!existsSync(packagedPath) || !lstatSync(packagedPath).isFile())
|
|
142
|
+
fail(`versioned package payload is missing: package/${entry.file}`)
|
|
143
|
+
if (sha256(sourcePath) !== sha256(packagedPath))
|
|
144
|
+
fail(`published package payload differs from source: ${entry.file}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function collectLockfileEvidence(inventory, entries, packageRoot) {
|
|
149
|
+
return inventory.documents
|
|
150
|
+
.filter(({ entry }) => entry.role === 'lock')
|
|
151
|
+
.map(({ entry, data, path: sourcePath }) => {
|
|
152
|
+
const archivePath = `package/${entry.file}`
|
|
153
|
+
const packagedPath = join(packageRoot, entry.file)
|
|
154
|
+
const publishedInTarball = entries.has(archivePath)
|
|
155
|
+
return {
|
|
156
|
+
file: entry.file,
|
|
157
|
+
sourceSha256: sha256(sourcePath),
|
|
158
|
+
publishedSha256: publishedInTarball ? sha256(packagedPath) : null,
|
|
159
|
+
sourceName: data.name,
|
|
160
|
+
sourceVersion: data.version,
|
|
161
|
+
lockfileVersion: data.lockfileVersion,
|
|
162
|
+
parity: 'validated-against-manifest',
|
|
163
|
+
publishedInTarball,
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function installProductionDependencies(root) {
|
|
169
|
+
// The root package-lock.json is source evidence only: npm-packlist omits it
|
|
170
|
+
// from the root of an npm package, so root ci must validate the source tree.
|
|
171
|
+
execFileSync('npm', CI_ARGS, { cwd: root, stdio: 'inherit' })
|
|
172
|
+
execFileSync('npm', CI_ARGS, {
|
|
173
|
+
cwd: join(root, 'src', 'plugins', 'tui'),
|
|
174
|
+
stdio: 'inherit',
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sha256(path) {
|
|
179
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createPackageEvidence(root, { outputDir, targetSha } = {}) {
|
|
183
|
+
const inventory = validateInventory(root)
|
|
184
|
+
const sha = resolveTargetSha(root, targetSha)
|
|
185
|
+
const output = createOutputDirectory(outputDir)
|
|
186
|
+
let workDir
|
|
187
|
+
try {
|
|
188
|
+
const tarball = packOnce(root, output.directory)
|
|
189
|
+
workDir = mkdtempSync(join(tmpdir(), 'pantheon-package-evidence-check-'))
|
|
190
|
+
const entries = validateArchiveEntries(tarball)
|
|
191
|
+
const packageRoot = extractSafely(tarball, workDir)
|
|
192
|
+
validatePayload(packageRoot, inventory)
|
|
193
|
+
installProductionDependencies(root)
|
|
194
|
+
const digest = sha256(tarball)
|
|
195
|
+
if (!/^[0-9a-f]{64}$/.test(digest)) fail('tarball SHA-256 is not lowercase 64-hex')
|
|
196
|
+
const metadata = {
|
|
197
|
+
version: inventory.sourceVersion,
|
|
198
|
+
targetSha: sha,
|
|
199
|
+
tarballSha256: digest,
|
|
200
|
+
tarball: tarball.split('/').at(-1),
|
|
201
|
+
lockfilePolicy: {
|
|
202
|
+
rootPackageLockfile: entries.has('package/package-lock.json')
|
|
203
|
+
? 'published-by-npm-pack'
|
|
204
|
+
: 'omitted-by-npm-pack',
|
|
205
|
+
note: entries.has('package/package-lock.json')
|
|
206
|
+
? 'npm pack published the root package-lock.json; lockfile parity is validated and recorded as source evidence, while tarball validation covers only the payload npm pack publishes.'
|
|
207
|
+
: 'npm pack omits the package root package-lock.json by policy; lockfile parity is validated and recorded as source evidence, while tarball validation covers only the payload npm pack publishes.',
|
|
208
|
+
},
|
|
209
|
+
lockfileEvidence: collectLockfileEvidence(inventory, entries, packageRoot),
|
|
210
|
+
}
|
|
211
|
+
writeFileSync(join(output.directory, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`)
|
|
212
|
+
writeFileSync(join(output.directory, 'version'), `${metadata.version}\n`)
|
|
213
|
+
writeFileSync(join(output.directory, 'target-sha'), `${metadata.targetSha}\n`)
|
|
214
|
+
writeFileSync(join(output.directory, 'tarball-sha256'), `${metadata.tarballSha256}\n`)
|
|
215
|
+
return { ...metadata, outputDir: output.directory, tarball }
|
|
216
|
+
} catch (error) {
|
|
217
|
+
rmSync(output.directory, { recursive: true, force: true })
|
|
218
|
+
throw error
|
|
219
|
+
} finally {
|
|
220
|
+
if (workDir) rmSync(workDir, { recursive: true, force: true })
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseOptions(args) {
|
|
225
|
+
const options = {}
|
|
226
|
+
for (const arg of args) {
|
|
227
|
+
const match = /^--(output-dir|target-sha)=(.*)$/.exec(arg)
|
|
228
|
+
if (!match) fail(`unsupported package evidence option: ${arg}`)
|
|
229
|
+
options[match[1] === 'output-dir' ? 'outputDir' : 'targetSha'] = match[2]
|
|
230
|
+
}
|
|
231
|
+
return options
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (process.argv[1]?.endsWith('package-evidence.mjs')) {
|
|
235
|
+
try {
|
|
236
|
+
const evidence = createPackageEvidence(process.cwd(), parseOptions(process.argv.slice(2)))
|
|
237
|
+
console.log(JSON.stringify(evidence))
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.error(`package-evidence: ${error.message}`)
|
|
240
|
+
process.exit(1)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -28,6 +28,15 @@ function emit(status, detail, checks = []) {
|
|
|
28
28
|
if (status === 'FAIL') process.exitCode = 1
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function diagnostic(error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
33
|
+
const stderr =
|
|
34
|
+
error && typeof error === 'object' && typeof error.stderr === 'string'
|
|
35
|
+
? error.stderr.trim()
|
|
36
|
+
: ''
|
|
37
|
+
return stderr === '' ? message : `${message}; stderr: ${stderr}`
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
function ambiental(detail) {
|
|
32
41
|
emit('AMBIENTAL', detail)
|
|
33
42
|
}
|
|
@@ -295,7 +304,7 @@ async function main() {
|
|
|
295
304
|
checks.push('incompatible schema: PASS')
|
|
296
305
|
emit('PASS', 'offline synthetic DB checks passed; no real DB was read', checks)
|
|
297
306
|
} catch (error) {
|
|
298
|
-
emit('FAIL',
|
|
307
|
+
emit('FAIL', diagnostic(error), checks)
|
|
299
308
|
} finally {
|
|
300
309
|
rmSync(root, { recursive: true, force: true })
|
|
301
310
|
}
|
|
@@ -1,80 +1,81 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* release-beta-version.mjs — beta version helpers.
|
|
4
|
+
*
|
|
5
|
+
* The beta version is committed in the manifests (package.json) and never
|
|
6
|
+
* computed at release time. `nextBetaVersion` advances the committed version:
|
|
7
|
+
*
|
|
8
|
+
* X.Y.Z-beta.N -> X.Y.Z-beta.(N+1) (stay on the current beta line)
|
|
9
|
+
* X.Y.Z -> X.Y.(Z+1)-beta.1 (start a fresh beta line)
|
|
10
|
+
*
|
|
11
|
+
* `applyBetaVersion` writes an already-decided beta version into every
|
|
12
|
+
* manifest and is retained for recovery tooling. The legacy PR+SHA scheme
|
|
13
|
+
* (`X.Y.Z-beta.<pr>.<sha7>`) is accepted by validation for backward
|
|
14
|
+
* compatibility only; it is no longer generated.
|
|
15
|
+
*/
|
|
3
16
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
17
|
+
import {
|
|
18
|
+
SEMVER_PATTERN,
|
|
19
|
+
validateInventory,
|
|
20
|
+
writeInventoryVersion,
|
|
21
|
+
} from './manifest-inventory.mjs'
|
|
6
22
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
]
|
|
13
|
-
|
|
14
|
-
const STABLE = /^(\d+)\.(\d+)\.(\d+)$/
|
|
23
|
+
const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
|
|
24
|
+
// Current committed sequential beta line.
|
|
25
|
+
const BETA_LINE = /^(\d+\.\d+\.\d+)-beta\.(\d+)$/
|
|
26
|
+
// Legacy PR+SHA beta scheme, accepted for recovery only.
|
|
27
|
+
const LEGACY_BETA =
|
|
28
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.[1-9]\d*\.[0-9a-f]{7}$/
|
|
29
|
+
const BETA_RELEASE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.[1-9]\d*$/
|
|
15
30
|
|
|
16
31
|
export function nextStableVersion(latest, intent = 'patch') {
|
|
17
|
-
const match = STABLE.exec(String(latest))
|
|
18
|
-
if (!match) throw new Error(`
|
|
19
|
-
const [major, minor, patch] = match.slice(1).map(Number)
|
|
32
|
+
const match = STABLE.exec(String(latest).trim())
|
|
33
|
+
if (!match) throw new Error(`latest is not a stable semver: "${latest}"`)
|
|
20
34
|
if (!['patch', 'minor', 'major'].includes(intent)) {
|
|
21
35
|
throw new Error(`release intent must be patch, minor, or major: "${intent}"`)
|
|
22
36
|
}
|
|
37
|
+
const [major, minor, patch] = match.slice(1).map(Number)
|
|
23
38
|
if (intent === 'major') return `${major + 1}.0.0`
|
|
24
39
|
if (intent === 'minor') return `${major}.${minor + 1}.0`
|
|
25
40
|
return `${major}.${minor}.${patch + 1}`
|
|
26
41
|
}
|
|
27
42
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Next beta version derived from the committed manifest version.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} current committed version (e.g. "1.5.0-beta.2")
|
|
47
|
+
* @returns {string} the next sequential beta (e.g. "1.5.0-beta.3")
|
|
48
|
+
*/
|
|
49
|
+
export function nextBetaVersion(current) {
|
|
50
|
+
const value = String(current).trim()
|
|
51
|
+
const line = BETA_LINE.exec(value)
|
|
52
|
+
if (line) return `${line[1]}-beta.${Number(line[2]) + 1}`
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const raw = readFileSync(path, 'utf8')
|
|
38
|
-
if (kind === 'toml') {
|
|
39
|
-
const updated = raw.replace(/^(version\s*=\s*")[^"]+("\s*)$/m, `$1${version}$2`)
|
|
40
|
-
if (updated === raw) throw new Error(`no version field in ${file}`)
|
|
41
|
-
writeFileSync(path, updated)
|
|
42
|
-
return
|
|
43
|
-
}
|
|
44
|
-
const content = JSON.parse(raw)
|
|
45
|
-
content.version = version
|
|
46
|
-
writeFileSync(path, `${JSON.stringify(content, null, 2)}\n`)
|
|
47
|
-
}
|
|
54
|
+
const stable = STABLE.exec(value)
|
|
55
|
+
if (stable) return `${nextStableVersion(value, 'patch')}-beta.1`
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const lock = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
54
|
-
lock.version = version
|
|
55
|
-
if (lock.packages?.['']) lock.packages[''].version = version
|
|
56
|
-
writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
|
|
57
|
+
// Any other prerelease (e.g. 1.5.0-rc.1): beta the next patch of its base.
|
|
58
|
+
const base = /^(\d+\.\d+\.\d+)-[0-9A-Za-z.-]+$/.exec(value)
|
|
59
|
+
if (!base) throw new Error(`cannot derive a beta version from: "${current}"`)
|
|
60
|
+
return `${nextStableVersion(base[1], 'patch')}-beta.1`
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (args.has('latest')) {
|
|
67
|
-
try {
|
|
68
|
-
const version = betaVersion(
|
|
69
|
-
args.get('latest'),
|
|
70
|
-
args.get('intent') || 'patch',
|
|
71
|
-
args.get('pr'),
|
|
72
|
-
args.get('sha'),
|
|
63
|
+
function validateReleaseVersion(version) {
|
|
64
|
+
if (typeof version !== 'string' || !SEMVER_PATTERN.test(version)) {
|
|
65
|
+
throw new Error(`invalid release semver: "${version}"`)
|
|
66
|
+
}
|
|
67
|
+
if (!BETA_RELEASE.test(version) && !LEGACY_BETA.test(version)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`release version must be <base>-beta.<N> or legacy <base>-beta.<pr>.<7-char-sha>: "${version}"`,
|
|
73
70
|
)
|
|
74
|
-
if (args.has('apply')) applyBetaVersion(process.cwd(), version)
|
|
75
|
-
console.log(version)
|
|
76
|
-
} catch (error) {
|
|
77
|
-
console.error(`release-beta-version: ${error.message}`)
|
|
78
|
-
process.exit(1)
|
|
79
71
|
}
|
|
80
72
|
}
|
|
73
|
+
|
|
74
|
+
/** Preflight all six entries, then update metadata only. */
|
|
75
|
+
export function applyBetaVersion(root, version) {
|
|
76
|
+
validateReleaseVersion(version)
|
|
77
|
+
const inventory = validateInventory(root, { allowVersionDivergence: true })
|
|
78
|
+
writeInventoryVersion(inventory, version)
|
|
79
|
+
const after = validateInventory(root)
|
|
80
|
+
if (after.sourceVersion !== version) throw new Error('beta version post-write validation failed')
|
|
81
|
+
}
|
package/scripts/sync-tui.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { execSync } from 'node:child_process'
|
|
|
13
13
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
15
|
import { join } from 'node:path'
|
|
16
|
-
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
17
17
|
|
|
18
18
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
19
19
|
const ROOT = join(__dirname, '..')
|
|
@@ -106,68 +106,90 @@ function copyPluginFiles(srcDir, dstDir) {
|
|
|
106
106
|
return result
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// ──
|
|
109
|
+
// ── npm env hygiene ────────────────────────────────────────────────────
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
/**
|
|
112
|
+
* npm lifecycle env vars that put a nested `npm ci` into global context and
|
|
113
|
+
* make it abort with ECIGLOBAL ("`npm ci` does not work for global packages").
|
|
114
|
+
* When the package itself is installed with `npm install -g`, npm injects these
|
|
115
|
+
* into the postinstall environment; the TUI dependency sync is a local
|
|
116
|
+
* operation and must never inherit them.
|
|
117
|
+
*/
|
|
118
|
+
const GLOBAL_NPM_ENV_KEYS = new Set([
|
|
119
|
+
'npm_config_global',
|
|
120
|
+
'npm_config_globalconfig',
|
|
121
|
+
'npm_config_prefix',
|
|
122
|
+
'npm_config_location',
|
|
123
|
+
])
|
|
117
124
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Return a copy of `env` with global-context npm config vars removed.
|
|
127
|
+
* Matching is case-insensitive so Windows-style uppercase keys are handled.
|
|
128
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
129
|
+
* @returns {NodeJS.ProcessEnv}
|
|
130
|
+
*/
|
|
131
|
+
export function sanitizeNpmEnv(env = process.env) {
|
|
132
|
+
const clean = { ...env }
|
|
133
|
+
for (const key of Object.keys(clean)) {
|
|
134
|
+
if (GLOBAL_NPM_ENV_KEYS.has(key.toLowerCase())) delete clean[key]
|
|
122
135
|
}
|
|
136
|
+
return clean
|
|
137
|
+
}
|
|
123
138
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
function main() {
|
|
142
|
+
try {
|
|
143
|
+
const configDir = resolveConfigDir()
|
|
144
|
+
if (!configDir) {
|
|
145
|
+
// User hasn't run init yet — silent exit, no error
|
|
146
|
+
process.exit(0)
|
|
147
|
+
}
|
|
130
148
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
149
|
+
const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
|
|
150
|
+
if (!existsSync(tuiCopyDir)) {
|
|
151
|
+
// TUI plugin not installed — silent exit
|
|
152
|
+
process.exit(0)
|
|
153
|
+
}
|
|
134
154
|
|
|
135
|
-
|
|
136
|
-
|
|
155
|
+
// Source: TUI plugin inside the installed package
|
|
156
|
+
const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
157
|
+
if (!existsSync(tuiSrcDir)) {
|
|
158
|
+
throw new Error(`TUI plugin source not found: ${tuiSrcDir}`)
|
|
159
|
+
}
|
|
137
160
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
// Compare versions before copy
|
|
162
|
+
const installedVersion = readVersion(join(tuiCopyDir, 'package.json'))
|
|
163
|
+
const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
|
|
164
|
+
|
|
165
|
+
// Copy fresh files
|
|
166
|
+
const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
|
|
167
|
+
|
|
168
|
+
// Lockfile installation is the only accepted dependency path. Strip
|
|
169
|
+
// global-context npm vars so a global postinstall can't trigger ECIGLOBAL.
|
|
170
|
+
execSync('npm ci --omit=dev --no-audit --no-fund', {
|
|
171
|
+
cwd: tuiCopyDir,
|
|
172
|
+
stdio: 'pipe',
|
|
173
|
+
env: sanitizeNpmEnv(),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// Log result
|
|
177
|
+
if (installedVersion && sourceVersion && installedVersion === sourceVersion && created === 0) {
|
|
178
|
+
console.log(` TUI plugin already up to date (v${sourceVersion})`)
|
|
153
179
|
} else {
|
|
154
|
-
console.
|
|
155
|
-
|
|
156
|
-
`PANTHEON_ALLOW_NPM_INSTALL_FALLBACK=1 to explicitly allow npm install: ${err.message}`,
|
|
180
|
+
console.log(
|
|
181
|
+
` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
|
|
157
182
|
)
|
|
158
183
|
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error(`❌ TUI sync failed: ${err.message}`)
|
|
186
|
+
process.exitCode = 1
|
|
159
187
|
}
|
|
160
|
-
|
|
161
|
-
// Log result
|
|
162
|
-
if (installedVersion && sourceVersion && installedVersion === sourceVersion && created === 0) {
|
|
163
|
-
console.log(` TUI plugin already up to date (v${sourceVersion})`)
|
|
164
|
-
} else {
|
|
165
|
-
console.log(
|
|
166
|
-
` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
} catch (err) {
|
|
170
|
-
// Postinstall scripts must never break npm install
|
|
171
|
-
console.warn(`⚠️ TUI sync skipped: ${err.message}`)
|
|
172
|
-
process.exit(0)
|
|
173
188
|
}
|
|
189
|
+
|
|
190
|
+
// Only run when invoked directly (`node scripts/sync-tui.mjs`). Importing this
|
|
191
|
+
// module for unit tests must not trigger the side-effecting postinstall sync.
|
|
192
|
+
const isDirectRun =
|
|
193
|
+
Boolean(process.argv[1]) && pathToFileURL(process.argv[1]).href === import.meta.url
|
|
194
|
+
|
|
195
|
+
if (isDirectRun) main()
|