pantheon-opencode 1.4.3 → 1.5.0-beta.2
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/README.md +93 -0
- package/.pantheon/code-mode/checkpoint-session.py +7 -0
- package/.pantheon/code-mode/checkpoint-session.sh +11 -0
- package/.pantheon/code-mode/checkpoint_session.py +481 -0
- package/.pantheon/code-mode/compress-inline.py +450 -0
- package/.pantheon/code-mode/eval-llm-judge.py +255 -0
- package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
- package/.pantheon/code-mode/eval-run.py +197 -0
- package/.pantheon/code-mode/eval-static.py +321 -0
- package/.pantheon/code-mode/example-sync.sh +5 -0
- package/.pantheon/code-mode/session-end-save.py +186 -0
- package/.pantheon/code-mode/session-end-save.sh +18 -0
- package/.pantheon/code-mode/test-allowed.sh +5 -0
- package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
- package/.pantheon/code-mode/test-env.sh +7 -0
- package/.pantheon/code-mode/test-prlimit.sh +9 -0
- package/.pantheon/code-mode/test-timeout.sh +6 -0
- package/README.md +161 -1101
- package/README.pt-BR.md +222 -0
- package/bin/pantheon-init.mjs +30 -11
- package/opencode.json +1 -0
- package/package.json +14 -3
- package/plugin.json +15 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +33 -2
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +135 -127
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +8 -1
- package/scripts/install/config-migration.mjs +401 -0
- package/scripts/install/health-check.mjs +3 -3
- package/scripts/install/model-picker.mjs +72 -23
- package/scripts/install/opencode-version.mjs +47 -0
- package/scripts/install/opencode.mjs +261 -88
- package/scripts/install/plugin.mjs +112 -42
- package/scripts/install/shared.mjs +13 -158
- package/scripts/install-mcp.mjs +44 -232
- package/scripts/mcp_persistence_server.py +382 -16
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +304 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/sync-tui.mjs +32 -7
- package/scripts/test-opencode-v1-v2-sandbox.sh +944 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/zeus.md +25 -14
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +33 -2
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +382 -16
- package/src/mcp/mcp_resources_server.py +144 -40
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/toon_codec.py +491 -0
- package/src/pantheon/background-job-board.ts +67 -1
- package/src/pantheon/cost-command.ts +60 -39
- package/src/pantheon/delegate-manager.ts +669 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +374 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1120 -0
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/token-opt.ts +781 -0
- package/src/pantheon/toon-codec.ts +481 -0
- package/src/pantheon/tui-version-check.ts +1 -3
- package/src/pantheon/v2-bridge.ts +129 -0
- package/src/pantheon/v2-events.ts +131 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +236 -42
- 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 +10 -7
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +11 -8
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +22 -16
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +28 -24
- package/src/skills/README.md +0 -2
- package/src/pantheon/tool-context.ts +0 -41
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline/ambiental probe for pantheon_cost.
|
|
4
|
+
*
|
|
5
|
+
* The probe never reads or writes a user's real database. Without --fixture it
|
|
6
|
+
* reports AMBIENTAL because a real dependency/database is intentionally not
|
|
7
|
+
* exercised. With --fixture it imports the installed command and creates a
|
|
8
|
+
* temporary synthetic opencode database. A missing fixture runtime is also
|
|
9
|
+
* AMBIENTAL rather than a false PASS.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
const requestedVersion = valueFor('--version')
|
|
16
|
+
const jsonOutput = process.argv.includes('--json')
|
|
17
|
+
const fixtureMode = process.argv.includes('--fixture')
|
|
18
|
+
|
|
19
|
+
function valueFor(name) {
|
|
20
|
+
const index = process.argv.indexOf(name)
|
|
21
|
+
return index >= 0 ? process.argv[index + 1] : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function emit(status, detail, checks = []) {
|
|
25
|
+
const result = { status, version: requestedVersion, detail, checks }
|
|
26
|
+
if (jsonOutput) process.stdout.write(JSON.stringify(result))
|
|
27
|
+
else process.stdout.write(`${status}: ${detail}\n`)
|
|
28
|
+
if (status === 'FAIL') process.exitCode = 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ambiental(detail) {
|
|
32
|
+
emit('AMBIENTAL', detail)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function setEnvironment(values) {
|
|
36
|
+
const previous = new Map()
|
|
37
|
+
for (const [key, value] of Object.entries(values)) {
|
|
38
|
+
previous.set(key, process.env[key])
|
|
39
|
+
if (value === undefined) delete process.env[key]
|
|
40
|
+
else process.env[key] = value
|
|
41
|
+
}
|
|
42
|
+
return () => {
|
|
43
|
+
for (const [key, value] of previous) {
|
|
44
|
+
if (value === undefined) delete process.env[key]
|
|
45
|
+
else process.env[key] = value
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createSyntheticDb(DatabaseSync, path, rows) {
|
|
51
|
+
const db = new DatabaseSync(path)
|
|
52
|
+
db.exec('CREATE TABLE message (data TEXT NOT NULL, time_created INTEGER NOT NULL)')
|
|
53
|
+
const insert = db.prepare('INSERT INTO message (data, time_created) VALUES (?, ?)')
|
|
54
|
+
for (const data of rows) insert.run(JSON.stringify(data), Date.now())
|
|
55
|
+
db.close()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createIncompatibleDb(DatabaseSync, path) {
|
|
59
|
+
const db = new DatabaseSync(path)
|
|
60
|
+
db.exec('CREATE TABLE not_message (value TEXT NOT NULL)')
|
|
61
|
+
db.close()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requireOutput(output, fragment, label) {
|
|
65
|
+
if (!output.includes(fragment)) throw new Error(`${label}: missing ${fragment}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertTokensOnly(output, expectedRows, total) {
|
|
69
|
+
requireOutput(output, 'Tokens In | Tokens Out | Tokens Total', 'tokens-only header')
|
|
70
|
+
for (const row of expectedRows) {
|
|
71
|
+
requireOutput(
|
|
72
|
+
output,
|
|
73
|
+
`| ${row.agent} | ${row.phase} | ${row.input} | ${row.output} | ${row.total} |`,
|
|
74
|
+
'token row',
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
requireOutput(
|
|
78
|
+
output,
|
|
79
|
+
`| **Total** | — | **${total.input}** | **${total.output}** | **${total.total}** |`,
|
|
80
|
+
'token total',
|
|
81
|
+
)
|
|
82
|
+
if (/Cost \(USD\)|costUsd|\$\d|\bUSD\b/i.test(output)) {
|
|
83
|
+
throw new Error('tokens-only output contains a monetary value')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
if (requestedVersion !== 'v1' && requestedVersion !== 'v2') {
|
|
89
|
+
emit('FAIL', 'usage: --version v1|v2 [--fixture] [--json]')
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!fixtureMode) {
|
|
94
|
+
ambiental(
|
|
95
|
+
'real pantheon_cost dependency/database not exercised; use --fixture for synthetic checks',
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const source = new URL('../src/pantheon/cost-command.ts', import.meta.url)
|
|
101
|
+
if (!existsSync(source)) {
|
|
102
|
+
ambiental(
|
|
103
|
+
'pantheon_cost dependency is unavailable: installed package lacks src/pantheon/cost-command.ts',
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let DatabaseSync
|
|
109
|
+
let createCostCommand
|
|
110
|
+
try {
|
|
111
|
+
;({ DatabaseSync } = await import('node:sqlite'))
|
|
112
|
+
;({ createCostCommand } = await import(source.href))
|
|
113
|
+
} catch (error) {
|
|
114
|
+
ambiental(
|
|
115
|
+
`pantheon_cost fixture runtime unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
|
|
121
|
+
const dataHome = join(root, 'data')
|
|
122
|
+
const opencodeDir = join(dataHome, 'opencode')
|
|
123
|
+
const selectedDb = join(opencodeDir, requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db')
|
|
124
|
+
const otherDb = join(root, 'other.db')
|
|
125
|
+
const envDb = join(root, 'env.db')
|
|
126
|
+
const explicitDb = join(root, 'explicit.db')
|
|
127
|
+
const incompatibleDb = join(root, 'incompatible.db')
|
|
128
|
+
const checks = []
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
mkdirSync(opencodeDir, { recursive: true })
|
|
132
|
+
// These rows intentionally include a monetary-looking field to prove it is
|
|
133
|
+
// ignored; only input/output/total tokens may appear in the report.
|
|
134
|
+
const selectedRows =
|
|
135
|
+
requestedVersion === 'v2'
|
|
136
|
+
? [
|
|
137
|
+
{
|
|
138
|
+
role: 'assistant',
|
|
139
|
+
agent: 'aphrodite',
|
|
140
|
+
phase: 'plan',
|
|
141
|
+
cost: 999.99,
|
|
142
|
+
tokens: { input: 7, output: 8 },
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
role: 'assistant',
|
|
146
|
+
agent: 'aphrodite',
|
|
147
|
+
phase: 'plan',
|
|
148
|
+
cost: 999.99,
|
|
149
|
+
tokens: { input: 1, output: 2 },
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
role: 'assistant',
|
|
153
|
+
agent: 'zeus',
|
|
154
|
+
phase: 'audit',
|
|
155
|
+
cost: 999.99,
|
|
156
|
+
tokens: { input: 4, output: 6 },
|
|
157
|
+
},
|
|
158
|
+
]
|
|
159
|
+
: [
|
|
160
|
+
{
|
|
161
|
+
role: 'assistant',
|
|
162
|
+
agent: 'hermes',
|
|
163
|
+
phase: 'plan',
|
|
164
|
+
cost: 999.99,
|
|
165
|
+
tokens: { input: 10, output: 20 },
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
role: 'assistant',
|
|
169
|
+
agent: 'hermes',
|
|
170
|
+
phase: 'plan',
|
|
171
|
+
cost: 999.99,
|
|
172
|
+
tokens: { input: 3, output: 4 },
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
role: 'assistant',
|
|
176
|
+
agent: 'athena',
|
|
177
|
+
phase: 'review',
|
|
178
|
+
cost: 999.99,
|
|
179
|
+
tokens: { input: 2, output: 5 },
|
|
180
|
+
},
|
|
181
|
+
]
|
|
182
|
+
createSyntheticDb(DatabaseSync, selectedDb, selectedRows)
|
|
183
|
+
createSyntheticDb(DatabaseSync, otherDb, [
|
|
184
|
+
{
|
|
185
|
+
role: 'assistant',
|
|
186
|
+
agent: 'wrong-version',
|
|
187
|
+
phase: 'other',
|
|
188
|
+
tokens: { input: 90, output: 90 },
|
|
189
|
+
},
|
|
190
|
+
])
|
|
191
|
+
createSyntheticDb(DatabaseSync, envDb, [
|
|
192
|
+
{ role: 'assistant', agent: 'env-agent', phase: 'override', tokens: { input: 1, output: 2 } },
|
|
193
|
+
])
|
|
194
|
+
createSyntheticDb(DatabaseSync, explicitDb, [
|
|
195
|
+
{
|
|
196
|
+
role: 'assistant',
|
|
197
|
+
agent: 'explicit-agent',
|
|
198
|
+
phase: 'explicit',
|
|
199
|
+
tokens: { input: 9, output: 1 },
|
|
200
|
+
},
|
|
201
|
+
])
|
|
202
|
+
createIncompatibleDb(DatabaseSync, incompatibleDb)
|
|
203
|
+
|
|
204
|
+
const restore = setEnvironment({
|
|
205
|
+
XDG_DATA_HOME: dataHome,
|
|
206
|
+
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
207
|
+
PANTHEON_COST_DB: undefined,
|
|
208
|
+
})
|
|
209
|
+
try {
|
|
210
|
+
const selectedOutput = await createCostCommand().pantheon_cost.execute(
|
|
211
|
+
{},
|
|
212
|
+
{ sessionID: 'cost-probe' },
|
|
213
|
+
)
|
|
214
|
+
assertTokensOnly(
|
|
215
|
+
selectedOutput,
|
|
216
|
+
requestedVersion === 'v2'
|
|
217
|
+
? [
|
|
218
|
+
{ agent: 'aphrodite', phase: 'plan', input: 8, output: 10, total: 18 },
|
|
219
|
+
{ agent: 'zeus', phase: 'audit', input: 4, output: 6, total: 10 },
|
|
220
|
+
]
|
|
221
|
+
: [
|
|
222
|
+
{ agent: 'hermes', phase: 'plan', input: 13, output: 24, total: 37 },
|
|
223
|
+
{ agent: 'athena', phase: 'review', input: 2, output: 5, total: 7 },
|
|
224
|
+
],
|
|
225
|
+
requestedVersion === 'v2'
|
|
226
|
+
? { input: 12, output: 16, total: 28 }
|
|
227
|
+
: { input: 15, output: 29, total: 44 },
|
|
228
|
+
)
|
|
229
|
+
checks.push('version-selected DB: PASS')
|
|
230
|
+
} finally {
|
|
231
|
+
restore()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const envRestore = setEnvironment({
|
|
235
|
+
XDG_DATA_HOME: dataHome,
|
|
236
|
+
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
237
|
+
PANTHEON_COST_DB: envDb,
|
|
238
|
+
})
|
|
239
|
+
try {
|
|
240
|
+
const output = await createCostCommand().pantheon_cost.execute(
|
|
241
|
+
{},
|
|
242
|
+
{ sessionID: 'cost-probe' },
|
|
243
|
+
)
|
|
244
|
+
requireOutput(output, '| env-agent | override | 1 | 2 | 3 |', 'PANTHEON_COST_DB')
|
|
245
|
+
if (output.includes('wrong-version') || output.includes('env-agent') === false) {
|
|
246
|
+
throw new Error('PANTHEON_COST_DB did not override the version-selected database')
|
|
247
|
+
}
|
|
248
|
+
checks.push('PANTHEON_COST_DB override: PASS')
|
|
249
|
+
} finally {
|
|
250
|
+
envRestore()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const explicitRestore = setEnvironment({
|
|
254
|
+
XDG_DATA_HOME: dataHome,
|
|
255
|
+
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
256
|
+
PANTHEON_COST_DB: envDb,
|
|
257
|
+
})
|
|
258
|
+
try {
|
|
259
|
+
const output = await createCostCommand({ dbPath: explicitDb }).pantheon_cost.execute(
|
|
260
|
+
{},
|
|
261
|
+
{ sessionID: 'cost-probe' },
|
|
262
|
+
)
|
|
263
|
+
requireOutput(output, '| explicit-agent | explicit | 9 | 1 | 10 |', 'explicit dbPath')
|
|
264
|
+
if (output.includes('env-agent')) throw new Error('explicit dbPath did not take precedence')
|
|
265
|
+
checks.push('explicit dbPath precedence: PASS')
|
|
266
|
+
} finally {
|
|
267
|
+
explicitRestore()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const missingRestore = setEnvironment({
|
|
271
|
+
XDG_DATA_HOME: join(root, 'missing-data'),
|
|
272
|
+
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
273
|
+
PANTHEON_COST_DB: undefined,
|
|
274
|
+
})
|
|
275
|
+
try {
|
|
276
|
+
const output = await createCostCommand().pantheon_cost.execute(
|
|
277
|
+
{},
|
|
278
|
+
{ sessionID: 'cost-probe' },
|
|
279
|
+
)
|
|
280
|
+
requireOutput(output, 'not found', 'missing DB')
|
|
281
|
+
requireOutput(
|
|
282
|
+
output,
|
|
283
|
+
requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db',
|
|
284
|
+
'selected missing DB path',
|
|
285
|
+
)
|
|
286
|
+
checks.push('missing DB: PASS')
|
|
287
|
+
} finally {
|
|
288
|
+
missingRestore()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const incompatibleOutput = await createCostCommand({
|
|
292
|
+
dbPath: incompatibleDb,
|
|
293
|
+
}).pantheon_cost.execute({}, { sessionID: 'cost-probe' })
|
|
294
|
+
requireOutput(incompatibleOutput, 'incompatible opencode.db schema', 'incompatible schema')
|
|
295
|
+
checks.push('incompatible schema: PASS')
|
|
296
|
+
emit('PASS', 'offline synthetic DB checks passed; no real DB was read', checks)
|
|
297
|
+
} catch (error) {
|
|
298
|
+
emit('FAIL', error instanceof Error ? error.message : String(error), checks)
|
|
299
|
+
} finally {
|
|
300
|
+
rmSync(root, { recursive: true, force: true })
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await main()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
import { validateZenodoResponse } from './zenodo-release-state.mjs'
|
|
3
|
+
|
|
4
|
+
export function zenodoReleaseMarker(tag) {
|
|
5
|
+
if (typeof tag !== 'string' || !/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag))
|
|
6
|
+
throw new Error('Release tag is invalid.')
|
|
7
|
+
return `pantheon-opencode-release:${tag}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function depositionDescription(deposition) {
|
|
11
|
+
return [deposition?.metadata?.description, deposition?.description]
|
|
12
|
+
.filter((value) => typeof value === 'string')
|
|
13
|
+
.join('\n')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function exactTotal(value) {
|
|
17
|
+
if (Number.isSafeInteger(value) && value >= 0) return value
|
|
18
|
+
if (
|
|
19
|
+
value &&
|
|
20
|
+
typeof value === 'object' &&
|
|
21
|
+
Number.isSafeInteger(value.value) &&
|
|
22
|
+
value.value >= 0 &&
|
|
23
|
+
(value.relation === undefined || value.relation === 'eq')
|
|
24
|
+
)
|
|
25
|
+
return value.value
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function findUniqueZenodoDeposition(
|
|
30
|
+
depositionsUrl,
|
|
31
|
+
token,
|
|
32
|
+
releaseMarker,
|
|
33
|
+
fetchImpl = fetch,
|
|
34
|
+
) {
|
|
35
|
+
let url
|
|
36
|
+
try {
|
|
37
|
+
url = new URL(depositionsUrl)
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error('Zenodo deposition URL must be a valid absolute HTTPS URL.')
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
url.protocol !== 'https:' ||
|
|
43
|
+
!url.hostname ||
|
|
44
|
+
url.username ||
|
|
45
|
+
url.password ||
|
|
46
|
+
url.hash ||
|
|
47
|
+
/%(?![0-9A-Fa-f]{2})/.test(depositionsUrl) ||
|
|
48
|
+
/[\s{}\\|^`]/.test(depositionsUrl)
|
|
49
|
+
)
|
|
50
|
+
throw new Error('Zenodo deposition URL must be a valid HTTPS URL without credentials.')
|
|
51
|
+
if (typeof releaseMarker !== 'string' || !releaseMarker)
|
|
52
|
+
throw new Error('Zenodo release marker is required.')
|
|
53
|
+
if (typeof token !== 'string' || !token) throw new Error('Zenodo token is required.')
|
|
54
|
+
url.searchParams.set('q', releaseMarker)
|
|
55
|
+
// Zenodo documents page/size for this endpoint. The response's links are
|
|
56
|
+
// optional, so keep a deterministic fallback for the real hits/total shape.
|
|
57
|
+
url.searchParams.set('page', '1')
|
|
58
|
+
url.searchParams.set('size', '100')
|
|
59
|
+
const candidates = []
|
|
60
|
+
const visitedUrls = new Set()
|
|
61
|
+
let nextUrl = url
|
|
62
|
+
let pageNumber = 1
|
|
63
|
+
let expectedTotal = null
|
|
64
|
+
while (nextUrl) {
|
|
65
|
+
const currentUrl = String(nextUrl)
|
|
66
|
+
if (visitedUrls.has(currentUrl)) throw new Error('Zenodo search returned a pagination cycle.')
|
|
67
|
+
visitedUrls.add(currentUrl)
|
|
68
|
+
const response = await fetchImpl(nextUrl, {
|
|
69
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
70
|
+
})
|
|
71
|
+
if (!response.ok) throw new Error(`Zenodo deposition search failed (${response.status}).`)
|
|
72
|
+
const payload = await response.json()
|
|
73
|
+
if (Array.isArray(payload)) {
|
|
74
|
+
throw new Error('Zenodo search returned ambiguous pagination.')
|
|
75
|
+
}
|
|
76
|
+
const page = payload?.hits?.hits
|
|
77
|
+
const total = payload?.hits?.total
|
|
78
|
+
const normalizedTotal = exactTotal(total)
|
|
79
|
+
if (!Array.isArray(page) || normalizedTotal === null)
|
|
80
|
+
throw new Error('Zenodo search returned ambiguous pagination.')
|
|
81
|
+
if (expectedTotal !== null && expectedTotal !== normalizedTotal)
|
|
82
|
+
throw new Error('Zenodo search returned inconsistent pagination totals.')
|
|
83
|
+
expectedTotal = normalizedTotal
|
|
84
|
+
candidates.push(...page)
|
|
85
|
+
const rawNext = payload?.links?.next
|
|
86
|
+
if (candidates.length >= expectedTotal) {
|
|
87
|
+
nextUrl = null
|
|
88
|
+
} else if (rawNext === null || rawNext === undefined || rawNext === '') {
|
|
89
|
+
// Some Zenodo Cloud responses omit links entirely. Continue with the
|
|
90
|
+
// documented page parameter instead of treating the first page as the
|
|
91
|
+
// complete search result.
|
|
92
|
+
if (page.length === 0)
|
|
93
|
+
throw new Error('Zenodo search ended before all results were examined.')
|
|
94
|
+
pageNumber += 1
|
|
95
|
+
const pagedNext = new URL(url)
|
|
96
|
+
pagedNext.searchParams.set('page', String(pageNumber))
|
|
97
|
+
nextUrl = pagedNext
|
|
98
|
+
} else {
|
|
99
|
+
if (typeof rawNext !== 'string')
|
|
100
|
+
throw new Error('Zenodo search returned an invalid pagination link.')
|
|
101
|
+
let parsedNext
|
|
102
|
+
try {
|
|
103
|
+
parsedNext = new URL(rawNext, url)
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error('Zenodo search returned an invalid pagination link.')
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
parsedNext.protocol !== 'https:' ||
|
|
109
|
+
parsedNext.origin !== url.origin ||
|
|
110
|
+
parsedNext.pathname !== url.pathname
|
|
111
|
+
)
|
|
112
|
+
throw new Error('Zenodo search returned an unsafe pagination link.')
|
|
113
|
+
const linkedPage = Number(parsedNext.searchParams.get('page'))
|
|
114
|
+
if (Number.isSafeInteger(linkedPage) && linkedPage > pageNumber) {
|
|
115
|
+
pageNumber = linkedPage
|
|
116
|
+
}
|
|
117
|
+
nextUrl = parsedNext
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const escapedMarker = releaseMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
121
|
+
const markerPattern = new RegExp(`(?:^|[^A-Za-z0-9._:-])${escapedMarker}(?![A-Za-z0-9.-])`)
|
|
122
|
+
const matches = candidates.filter((deposition) =>
|
|
123
|
+
markerPattern.test(depositionDescription(deposition)),
|
|
124
|
+
)
|
|
125
|
+
if (matches.length === 0) return null
|
|
126
|
+
if (matches.length !== 1) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Zenodo recovery requires exactly one matching deposition; found ${matches.length}.`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
return validateZenodoResponse(matches[0])
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
135
|
+
try {
|
|
136
|
+
const result = await findUniqueZenodoDeposition(
|
|
137
|
+
process.env.ZENODO_DEPOSITIONS_URL,
|
|
138
|
+
process.env.ZENODO_TOKEN,
|
|
139
|
+
process.env.RELEASE_MARKER,
|
|
140
|
+
)
|
|
141
|
+
process.stdout.write(JSON.stringify(result))
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error(`zenodo recovery: ${error.message}`)
|
|
144
|
+
process.exit(1)
|
|
145
|
+
}
|
|
146
|
+
}
|
package/scripts/sync-tui.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* If the user hasn't initialized yet → do nothing (silent exit).
|
|
11
11
|
*/
|
|
12
12
|
import { execSync } from 'node:child_process'
|
|
13
|
-
import { existsSync, mkdirSync,
|
|
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
16
|
import { fileURLToPath } from 'node:url'
|
|
@@ -58,7 +58,8 @@ function resolveConfigDir() {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Copy plugin runtime files (dist/*, package.json,
|
|
61
|
+
* Copy plugin runtime files (dist/*, package.json, package-lock.json,
|
|
62
|
+
* src/index.tsx) from srcDir
|
|
62
63
|
* to dstDir. Skips files that are byte-identical.
|
|
63
64
|
* @returns {{ created: number, skipped: number }}
|
|
64
65
|
*/
|
|
@@ -94,6 +95,14 @@ function copyPluginFiles(srcDir, dstDir) {
|
|
|
94
95
|
else result.skipped++
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// Keep the copied plugin installable with deterministic npm ci.
|
|
99
|
+
const lockSrc = join(srcDir, 'package-lock.json')
|
|
100
|
+
if (existsSync(lockSrc)) {
|
|
101
|
+
const content = readFileSync(lockSrc, 'utf8')
|
|
102
|
+
if (writeIfChanged(join(dstDir, 'package-lock.json'), content)) result.created++
|
|
103
|
+
else result.skipped++
|
|
104
|
+
}
|
|
105
|
+
|
|
97
106
|
return result
|
|
98
107
|
}
|
|
99
108
|
|
|
@@ -124,13 +133,29 @@ try {
|
|
|
124
133
|
const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
|
|
125
134
|
|
|
126
135
|
// Copy fresh files
|
|
127
|
-
const { created
|
|
136
|
+
const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
|
|
128
137
|
|
|
129
|
-
// Refresh dependencies in the copy
|
|
138
|
+
// Refresh dependencies in the copy. npm install is only allowed when the
|
|
139
|
+
// operator explicitly opts into the non-deterministic legacy fallback.
|
|
130
140
|
try {
|
|
131
|
-
execSync('npm
|
|
132
|
-
} catch {
|
|
133
|
-
|
|
141
|
+
execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: tuiCopyDir, stdio: 'pipe' })
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (process.env.PANTHEON_ALLOW_NPM_INSTALL_FALLBACK === '1') {
|
|
144
|
+
console.warn(`⚠️ npm ci failed; explicit fallback enabled: ${err.message}`)
|
|
145
|
+
try {
|
|
146
|
+
execSync('npm install --omit=dev --no-audit --no-fund --package-lock=false', {
|
|
147
|
+
cwd: tuiCopyDir,
|
|
148
|
+
stdio: 'pipe',
|
|
149
|
+
})
|
|
150
|
+
} catch (fallbackError) {
|
|
151
|
+
console.warn(`⚠️ TUI dependency refresh skipped: ${fallbackError.message}`)
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
console.warn(
|
|
155
|
+
`⚠️ TUI dependency refresh skipped after npm ci failure. Set ` +
|
|
156
|
+
`PANTHEON_ALLOW_NPM_INSTALL_FALLBACK=1 to explicitly allow npm install: ${err.message}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
134
159
|
}
|
|
135
160
|
|
|
136
161
|
// Log result
|