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.
- package/.pantheon/code-mode/.gitkeep +0 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
- 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 +158 -1189
- 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/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 +344 -15
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/sync-tui.mjs +32 -7
- package/scripts/test-opencode-v1-v2-sandbox.sh +591 -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/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 +344 -15
- 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 +6 -2
- package/src/pantheon/delegate-manager.ts +384 -0
- package/src/pantheon/delegate-task-adapter.ts +444 -0
- package/src/pantheon/delegation-notify.ts +35 -20
- package/src/pantheon/model-command.ts +86 -32
- package/src/pantheon/token-opt.ts +555 -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/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +836 -0
- package/src/plugin.ts +57 -10
- 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 +22 -16
- 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
|
-
|
|
71
|
-
if not
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
92
|
-
if not
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
115
|
-
if
|
|
116
|
-
routing_file = _PANTHEON_PROJECT / "src"
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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 =
|
|
148
|
-
if not plan_file.
|
|
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
|
-
|
|
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 =
|
|
163
|
-
if not status_file.
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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 = (
|
|
194
|
-
|
|
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
|
|
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=
|
|
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
|
+
}
|
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
|