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.
Files changed (99) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/session-end-save.py +186 -0
  12. package/.pantheon/code-mode/session-end-save.sh +18 -0
  13. package/.pantheon/code-mode/test-allowed.sh +5 -0
  14. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  15. package/.pantheon/code-mode/test-env.sh +7 -0
  16. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  17. package/.pantheon/code-mode/test-timeout.sh +6 -0
  18. package/README.md +161 -1101
  19. package/README.pt-BR.md +222 -0
  20. package/bin/pantheon-init.mjs +30 -11
  21. package/opencode.json +1 -0
  22. package/package.json +14 -3
  23. package/plugin.json +15 -0
  24. package/scripts/_pantheon_paths.py +48 -7
  25. package/scripts/ci-validate-yaml.py +7 -2
  26. package/scripts/code_mode_server.py +33 -2
  27. package/scripts/cost.mjs +30 -14
  28. package/scripts/doctor.mjs +135 -127
  29. package/scripts/generate-preset-docs.mjs +127 -66
  30. package/scripts/generate-prompts.sh +2 -2
  31. package/scripts/install/cli-ui.mjs +8 -1
  32. package/scripts/install/config-migration.mjs +401 -0
  33. package/scripts/install/health-check.mjs +3 -3
  34. package/scripts/install/model-picker.mjs +72 -23
  35. package/scripts/install/opencode-version.mjs +47 -0
  36. package/scripts/install/opencode.mjs +261 -88
  37. package/scripts/install/plugin.mjs +112 -42
  38. package/scripts/install/shared.mjs +13 -158
  39. package/scripts/install-mcp.mjs +44 -232
  40. package/scripts/mcp_persistence_server.py +382 -16
  41. package/scripts/mcp_resources_server.py +144 -40
  42. package/scripts/memory_mcp_server.py +2 -2
  43. package/scripts/probe-context-rehydrate.mjs +240 -0
  44. package/scripts/probe-pantheon-cost.mjs +304 -0
  45. package/scripts/recover-zenodo-deposition.mjs +146 -0
  46. package/scripts/sync-tui.mjs +32 -7
  47. package/scripts/test-opencode-v1-v2-sandbox.sh +944 -0
  48. package/scripts/uninstall.mjs +298 -954
  49. package/scripts/validate-zenodo-release.mjs +154 -0
  50. package/scripts/zenodo-artifact.mjs +21 -0
  51. package/scripts/zenodo-release-state.mjs +53 -0
  52. package/src/agents/zeus.md +25 -14
  53. package/src/mcp/_pantheon_paths.py +48 -7
  54. package/src/mcp/code_mode_server.py +33 -2
  55. package/src/mcp/eval_store.py +1 -1
  56. package/src/mcp/mcp_persistence_server.py +382 -16
  57. package/src/mcp/mcp_resources_server.py +144 -40
  58. package/src/mcp/pantheon_vision_server.py +1 -1
  59. package/src/mcp/toon_codec.py +491 -0
  60. package/src/pantheon/background-job-board.ts +67 -1
  61. package/src/pantheon/cost-command.ts +60 -39
  62. package/src/pantheon/delegate-manager.ts +669 -0
  63. package/src/pantheon/delegation-classifier.ts +307 -0
  64. package/src/pantheon/delegation-enforce.ts +13 -12
  65. package/src/pantheon/delegation-finalize.ts +374 -0
  66. package/src/pantheon/delegation-notify.ts +212 -0
  67. package/src/pantheon/delegation-toast.ts +73 -0
  68. package/src/pantheon/delegation.ts +1120 -0
  69. package/src/pantheon/goal-loop.ts +2 -2
  70. package/src/pantheon/goal-store.ts +1 -1
  71. package/src/pantheon/hashline/tool.ts +2 -2
  72. package/src/pantheon/model-command.ts +2 -1
  73. package/src/pantheon/permission-eval.ts +284 -0
  74. package/src/pantheon/step-cap.ts +110 -0
  75. package/src/pantheon/token-opt.ts +781 -0
  76. package/src/pantheon/toon-codec.ts +481 -0
  77. package/src/pantheon/tui-version-check.ts +1 -3
  78. package/src/pantheon/v2-bridge.ts +129 -0
  79. package/src/pantheon/v2-events.ts +131 -0
  80. package/src/pantheon/v2-hooks.ts +287 -0
  81. package/src/pantheon/v2-tool-definitions.ts +331 -0
  82. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +824 -0
  85. package/src/plugin.ts +236 -42
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +28 -24
  98. package/src/skills/README.md +0 -2
  99. 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
+ }
@@ -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, readFileSync, readdirSync, writeFileSync } from 'node:fs'
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, src/index.tsx) from srcDir
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, skipped } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
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 install --omit=dev --no-audit', { cwd: tuiCopyDir, stdio: 'pipe' })
132
- } catch {
133
- // Non-fatal — deps may already be current
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