pantheon-opencode 1.4.2 → 1.5.0-beta.1

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 (98) hide show
  1. package/.pantheon/code-mode/.gitkeep +0 -0
  2. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
  3. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
  4. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
  5. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
  6. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
  7. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
  8. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
  9. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
  10. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
  11. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
  12. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
  13. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
  14. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
  15. package/.pantheon/code-mode/README.md +93 -0
  16. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  17. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  18. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  19. package/.pantheon/code-mode/compress-inline.py +450 -0
  20. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  21. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  22. package/.pantheon/code-mode/eval-run.py +197 -0
  23. package/.pantheon/code-mode/eval-static.py +321 -0
  24. package/.pantheon/code-mode/example-sync.sh +5 -0
  25. package/.pantheon/code-mode/session-end-save.py +186 -0
  26. package/.pantheon/code-mode/session-end-save.sh +18 -0
  27. package/.pantheon/code-mode/test-allowed.sh +5 -0
  28. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  29. package/.pantheon/code-mode/test-env.sh +7 -0
  30. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  31. package/.pantheon/code-mode/test-timeout.sh +6 -0
  32. package/README.md +158 -1189
  33. package/README.pt-BR.md +222 -0
  34. package/bin/pantheon-init.mjs +30 -11
  35. package/opencode.json +1 -0
  36. package/package.json +14 -3
  37. package/plugin.json +15 -0
  38. package/scripts/_pantheon_paths.py +48 -7
  39. package/scripts/ci-validate-yaml.py +7 -2
  40. package/scripts/code_mode_server.py +33 -2
  41. package/scripts/doctor.mjs +135 -127
  42. package/scripts/generate-preset-docs.mjs +127 -66
  43. package/scripts/generate-prompts.sh +2 -2
  44. package/scripts/install/cli-ui.mjs +8 -1
  45. package/scripts/install/config-migration.mjs +401 -0
  46. package/scripts/install/health-check.mjs +3 -3
  47. package/scripts/install/model-picker.mjs +72 -23
  48. package/scripts/install/opencode-version.mjs +47 -0
  49. package/scripts/install/opencode.mjs +261 -88
  50. package/scripts/install/plugin.mjs +112 -42
  51. package/scripts/install/shared.mjs +13 -158
  52. package/scripts/install-mcp.mjs +44 -232
  53. package/scripts/mcp_persistence_server.py +344 -15
  54. package/scripts/mcp_resources_server.py +144 -40
  55. package/scripts/memory_mcp_server.py +2 -2
  56. package/scripts/recover-zenodo-deposition.mjs +146 -0
  57. package/scripts/sync-tui.mjs +32 -7
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +591 -0
  59. package/scripts/uninstall.mjs +298 -954
  60. package/scripts/validate-zenodo-release.mjs +154 -0
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/mcp/_pantheon_paths.py +48 -7
  64. package/src/mcp/code_mode_server.py +33 -2
  65. package/src/mcp/eval_store.py +1 -1
  66. package/src/mcp/mcp_persistence_server.py +344 -15
  67. package/src/mcp/mcp_resources_server.py +144 -40
  68. package/src/mcp/pantheon_vision_server.py +1 -1
  69. package/src/mcp/toon_codec.py +491 -0
  70. package/src/pantheon/background-job-board.ts +67 -1
  71. package/src/pantheon/cost-command.ts +6 -2
  72. package/src/pantheon/delegate-manager.ts +384 -0
  73. package/src/pantheon/delegate-task-adapter.ts +444 -0
  74. package/src/pantheon/delegation-notify.ts +35 -20
  75. package/src/pantheon/model-command.ts +86 -32
  76. package/src/pantheon/token-opt.ts +555 -0
  77. package/src/pantheon/toon-codec.ts +481 -0
  78. package/src/pantheon/tui-version-check.ts +1 -3
  79. package/src/pantheon/v2-bridge.ts +129 -0
  80. package/src/pantheon/v2-events.ts +131 -0
  81. package/src/pantheon/v2-hooks.ts +287 -0
  82. package/src/pantheon/v2-tool-definitions.ts +331 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +836 -0
  85. package/src/plugin.ts +57 -10
  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 +22 -16
  98. package/src/skills/README.md +0 -2
@@ -17,7 +17,7 @@ from pathlib import Path
17
17
  from typing import Any
18
18
 
19
19
  import yaml
20
- from _pantheon_paths import pantheon_home, pantheon_project
20
+ from _pantheon_paths import has_symlink_component, pantheon_home, pantheon_project
21
21
  from eval_store import get_latest_eval, list_evals
22
22
  from mcp.server.fastmcp import FastMCP
23
23
 
@@ -25,6 +25,88 @@ from mcp.server.fastmcp import FastMCP
25
25
  _PANTHEON_HOME: Path = pantheon_home()
26
26
  _PANTHEON_PROJECT: Path | None = pantheon_project()
27
27
 
28
+
29
+ def _within(path: Path, parent: Path) -> bool:
30
+ """Return whether ``path`` is contained by ``parent`` after resolution."""
31
+ try:
32
+ path.relative_to(parent)
33
+ except ValueError:
34
+ return False
35
+ return True
36
+
37
+
38
+ def _safe_root(root: Path, parent: Path) -> Path | None:
39
+ """Return a physical resource root only when its layout is trusted.
40
+
41
+ Local ``.opencode`` and ``.pantheon`` directories are trust boundaries:
42
+ accepting either as a symlink would make all resources below it
43
+ untrusted, even when the link happens to point inside the project.
44
+ """
45
+ if parent.is_symlink() or has_symlink_component(parent):
46
+ return None
47
+ # Keep the project root lexical until this trust-boundary check. This
48
+ # rejects PANTHEON_PROJECT/cwd symlinks instead of resolving them away.
49
+ if parent.name in {".opencode", ".pantheon"} and parent.parent.is_symlink():
50
+ return None
51
+ if root.is_symlink():
52
+ return None
53
+ if not root.exists() or not root.is_dir():
54
+ return None
55
+ parent_resolved = parent.resolve()
56
+ resolved = root.resolve()
57
+ if parent.name in {".opencode", ".pantheon"} and not _within(
58
+ parent_resolved, parent.parent.resolve()
59
+ ):
60
+ return None
61
+ return resolved if _within(resolved, parent_resolved) else None
62
+
63
+
64
+ def _resource_root(name: str) -> Path | None:
65
+ """Choose a project-local root before the global installation root.
66
+
67
+ A present but unsafe project root is ignored rather than followed; this
68
+ preserves the global fallback while preventing symlink escapes.
69
+ """
70
+ candidates: list[tuple[Path, Path]] = []
71
+ if _PANTHEON_PROJECT is not None:
72
+ if name in {"agents", "skills"}:
73
+ local = _PANTHEON_PROJECT / ".opencode"
74
+ candidates.append((local / name, local))
75
+ elif name in {"deepwork", "memory-bank"}:
76
+ local = _PANTHEON_PROJECT / ".pantheon"
77
+ candidates.append((local / name, local))
78
+ candidates.append((_PANTHEON_HOME / name, _PANTHEON_HOME))
79
+ for root, parent in candidates:
80
+ safe = _safe_root(root, parent)
81
+ if safe is not None:
82
+ return safe
83
+ return None
84
+
85
+
86
+ def _resource_roots(name: str) -> list[Path]:
87
+ """Return safe resource roots in local-first precedence order."""
88
+ roots: list[Path] = []
89
+ if _PANTHEON_PROJECT is not None:
90
+ base_name = ".opencode" if name in {"agents", "skills"} else ".pantheon"
91
+ base = _PANTHEON_PROJECT / base_name
92
+ local = _safe_root(base / name, base)
93
+ if local is not None:
94
+ roots.append(local)
95
+ global_root = _safe_root(_PANTHEON_HOME / name, _PANTHEON_HOME)
96
+ if global_root is not None:
97
+ roots.append(global_root)
98
+ return roots
99
+
100
+
101
+ def _safe_child(root: Path, relative: str) -> Path | None:
102
+ """Resolve a child while rejecting every lexical symlink component."""
103
+ candidate = root / relative
104
+ if has_symlink_component(candidate):
105
+ return None
106
+ resolved = candidate.resolve()
107
+ return resolved if _within(resolved, root.resolve()) else None
108
+
109
+
28
110
  # ── FastMCP App ───────────────────────────────────────────────────────────────
29
111
  mcp = FastMCP(
30
112
  "Pantheon Resources",
@@ -67,17 +149,27 @@ def _get_role_from_frontmatter(frontmatter: dict[str, Any]) -> str:
67
149
  )
68
150
  async def list_agents() -> str:
69
151
  """Return a markdown list of all agents with their roles."""
70
- agents_dir = _PANTHEON_HOME / "agents"
71
- if not agents_dir.is_dir():
152
+ agents_dirs = _resource_roots("agents")
153
+ if not agents_dirs:
72
154
  return "Agents directory not found."
73
155
 
74
156
  agents: list[str] = []
75
- for f in sorted(agents_dir.iterdir()):
76
- if f.suffix == ".md" and f.stem.lower() != "readme":
77
- frontmatter = _parse_yaml_frontmatter(f)
78
- name = frontmatter.get("name", f.stem)
79
- role = _get_role_from_frontmatter(frontmatter)
80
- agents.append(f"- **{name}** — {role}")
157
+ seen: set[str] = set()
158
+ for agents_dir in agents_dirs:
159
+ for entry in sorted(agents_dir.iterdir()):
160
+ f = _safe_child(agents_dir, entry.name)
161
+ if (
162
+ f is not None
163
+ and f.is_file()
164
+ and f.suffix == ".md"
165
+ and f.stem.lower() != "readme"
166
+ and f.name not in seen
167
+ ):
168
+ seen.add(f.name)
169
+ frontmatter = _parse_yaml_frontmatter(f)
170
+ name = frontmatter.get("name", f.stem)
171
+ role = _get_role_from_frontmatter(frontmatter)
172
+ agents.append(f"- **{name}** — {role}")
81
173
 
82
174
  return "\n".join(agents) if agents else "No agents found."
83
175
 
@@ -88,19 +180,23 @@ async def list_agents() -> str:
88
180
  )
89
181
  async def list_skills() -> str:
90
182
  """Return a markdown list of all skills with descriptions."""
91
- skills_dir = _PANTHEON_HOME / "skills"
92
- if not skills_dir.is_dir():
183
+ skills_dirs = _resource_roots("skills")
184
+ if not skills_dirs:
93
185
  return "Skills directory not found."
94
186
 
95
187
  skills: list[str] = []
96
- for f in sorted(skills_dir.iterdir()):
97
- if f.is_dir():
98
- skill_file = f / "SKILL.md"
99
- if skill_file.exists():
100
- frontmatter = _parse_yaml_frontmatter(skill_file)
101
- name = frontmatter.get("name", f.name)
102
- desc = frontmatter.get("description", "No description")
103
- skills.append(f"- **{name}** — {desc}")
188
+ seen: set[str] = set()
189
+ for skills_dir in skills_dirs:
190
+ for entry in sorted(skills_dir.iterdir()):
191
+ f = _safe_child(skills_dir, entry.name)
192
+ if f is not None and f.is_dir() and f.name not in seen:
193
+ skill_file = _safe_child(f, "SKILL.md")
194
+ if skill_file is not None and skill_file.is_file():
195
+ seen.add(f.name)
196
+ frontmatter = _parse_yaml_frontmatter(skill_file)
197
+ name = frontmatter.get("name", f.name)
198
+ desc = frontmatter.get("description", "No description")
199
+ skills.append(f"- **{name}** — {desc}")
104
200
 
105
201
  return "\n".join(skills) if skills else "No skills found."
106
202
 
@@ -111,10 +207,10 @@ async def list_skills() -> str:
111
207
  )
112
208
  async def get_routing() -> str:
113
209
  """Return the full content of routing.yml."""
114
- routing_file = _PANTHEON_HOME / "routing.yml"
115
- if not routing_file.exists() and _PANTHEON_PROJECT:
116
- routing_file = _PANTHEON_PROJECT / "src" / "routing.yml"
117
- if not routing_file.exists():
210
+ routing_file = _safe_child(_PANTHEON_HOME, "routing.yml")
211
+ if routing_file is None and _PANTHEON_PROJECT:
212
+ routing_file = _safe_child(_PANTHEON_PROJECT / "src", "routing.yml")
213
+ if routing_file is None or not routing_file.exists():
118
214
  return "routing.yml not found."
119
215
  return routing_file.read_text(encoding="utf-8")
120
216
 
@@ -128,11 +224,17 @@ async def get_routing() -> str:
128
224
  )
129
225
  async def get_agent(agent_name: str) -> str:
130
226
  """Return the full content of an agent file, case-insensitively."""
131
- agents_dir = _PANTHEON_HOME / "agents"
132
227
  name_lower = agent_name.lower()
133
- for f in agents_dir.iterdir():
134
- if f.suffix == ".md" and f.stem.lower() == name_lower:
135
- return f.read_text(encoding="utf-8")
228
+ for agents_dir in _resource_roots("agents"):
229
+ for entry in agents_dir.iterdir():
230
+ f = _safe_child(agents_dir, entry.name)
231
+ if (
232
+ f is not None
233
+ and f.is_file()
234
+ and f.suffix == ".md"
235
+ and f.stem.lower() == name_lower
236
+ ):
237
+ return f.read_text(encoding="utf-8")
136
238
  return f"Agent '{agent_name}' not found."
137
239
 
138
240
 
@@ -142,10 +244,11 @@ async def get_agent(agent_name: str) -> str:
142
244
  )
143
245
  async def get_deepwork_plan(slug: str) -> str:
144
246
  """Return PLAN.md content for a deepwork slug."""
145
- if _PANTHEON_PROJECT is None:
247
+ deepwork_dir = _resource_root("deepwork")
248
+ if deepwork_dir is None:
146
249
  return f"Deepwork '{slug}' not found. (PANTHEON_PROJECT not set)"
147
- plan_file = _PANTHEON_PROJECT / ".pantheon" / "deepwork" / slug / "PLAN.md"
148
- if not plan_file.exists():
250
+ plan_file = _safe_child(deepwork_dir, f"{slug}/PLAN.md")
251
+ if plan_file is None or not plan_file.is_file():
149
252
  return f"Deepwork '{slug}' not found."
150
253
  return plan_file.read_text(encoding="utf-8")
151
254
 
@@ -157,10 +260,11 @@ async def get_deepwork_plan(slug: str) -> str:
157
260
  )
158
261
  async def get_deepwork_status(slug: str) -> str:
159
262
  """Return STATUS.md content for a deepwork slug, or a default message."""
160
- if _PANTHEON_PROJECT is None:
263
+ deepwork_dir = _resource_root("deepwork")
264
+ if deepwork_dir is None:
161
265
  return "STATUS.md not found. (PANTHEON_PROJECT not set)"
162
- status_file = _PANTHEON_PROJECT / ".pantheon" / "deepwork" / slug / "STATUS.md"
163
- if not status_file.exists():
266
+ status_file = _safe_child(deepwork_dir, f"{slug}/STATUS.md")
267
+ if status_file is None or not status_file.is_file():
164
268
  return "STATUS.md not found. Current state: IN PROGRESS"
165
269
  return status_file.read_text(encoding="utf-8")
166
270
 
@@ -171,8 +275,9 @@ async def get_deepwork_status(slug: str) -> str:
171
275
  )
172
276
  async def get_skill(name: str) -> str:
173
277
  """Return SKILL.md content for a skill directory."""
174
- skill_file = _PANTHEON_HOME / "skills" / name / "SKILL.md"
175
- if not skill_file.exists():
278
+ skills_dir = _resource_root("skills")
279
+ skill_file = _safe_child(skills_dir, f"{name}/SKILL.md") if skills_dir else None
280
+ if skill_file is None or not skill_file.is_file():
176
281
  return f"Skill '{name}' not found."
177
282
  return skill_file.read_text(encoding="utf-8")
178
283
 
@@ -188,12 +293,11 @@ async def get_memory_bank(path: str) -> str:
188
293
  Security: resolves absolute path and verifies it stays within
189
294
  .pantheon/memory-bank/ to prevent directory traversal attacks.
190
295
  """
191
- if _PANTHEON_PROJECT is None:
296
+ memory_dir = _resource_root("memory-bank")
297
+ if memory_dir is None:
192
298
  return "Memory bank not available. (PANTHEON_PROJECT not set)"
193
- resolved = (_PANTHEON_PROJECT / ".pantheon" / "memory-bank" / path).resolve()
194
- mb_dir = (_PANTHEON_PROJECT / ".pantheon" / "memory-bank").resolve()
195
-
196
- if not str(resolved).startswith(str(mb_dir)):
299
+ resolved = _safe_child(memory_dir, path)
300
+ if resolved is None:
197
301
  return "Path traversal blocked: access denied."
198
302
 
199
303
  if not resolved.exists() or not resolved.is_file():
@@ -25,7 +25,7 @@ import json
25
25
  import os
26
26
  import sqlite3
27
27
  import time
28
- from datetime import datetime, timezone
28
+ from datetime import UTC, datetime
29
29
  from pathlib import Path
30
30
  from typing import Any
31
31
 
@@ -198,7 +198,7 @@ def _parse_iso_ts(value: str) -> float:
198
198
  """
199
199
  dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
200
200
  if dt.tzinfo is None:
201
- dt = dt.replace(tzinfo=timezone.utc)
201
+ dt = dt.replace(tzinfo=UTC)
202
202
  return dt.timestamp()
203
203
 
204
204
 
@@ -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