pcode 0.1.0__py3-none-any.whl
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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/meridian_setup.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Signing Meridian in through Claude Code, and installing or upgrading Meridian.
|
|
2
|
+
|
|
3
|
+
Sign-in always runs the real `claude auth login`, so it completes through
|
|
4
|
+
Anthropic's own flow and pcode never sees the credential. Meridian can keep
|
|
5
|
+
several account profiles, each with its own Claude config directory; the login
|
|
6
|
+
goes to whichever one the Meridian that pcode uses will read.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import plistlib
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import httpx2
|
|
21
|
+
|
|
22
|
+
from pcode.auth import LoginError
|
|
23
|
+
from pcode.meridian_process import default_profile, meridian_profiles, profile_dir
|
|
24
|
+
|
|
25
|
+
PACKAGE = "@rynfar/meridian"
|
|
26
|
+
LOGIN_TIMEOUT_SECONDS = 300.0
|
|
27
|
+
# OSC (hyperlinks, titles) and CSI (colour, cursor) escapes in CLI output.
|
|
28
|
+
ESCAPES = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[ -/]*[@-~]")
|
|
29
|
+
REMOTE_VARIABLES = ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER")
|
|
30
|
+
# Printed without a newline, so whatever the CLI says next lands on the same line.
|
|
31
|
+
PASTE_PROMPT = re.compile(r"Paste code here if prompted >\s*")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LoginTarget:
|
|
36
|
+
"""Where `claude auth login` should write: a profile's config dir, or the default."""
|
|
37
|
+
|
|
38
|
+
config_dir: str | None
|
|
39
|
+
label: str
|
|
40
|
+
oauth_token: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def profile_target(profile: dict) -> LoginTarget:
|
|
44
|
+
token = bool(profile.get("oauthToken")) or profile.get("type") == "oauth-token"
|
|
45
|
+
return LoginTarget(profile_dir(profile), f"Meridian profile {profile['id']}", token)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def active_profile(base_url: str) -> str | None:
|
|
49
|
+
"""The running proxy's active profile, or None when it has no profiles."""
|
|
50
|
+
key = os.environ.get("PCODE_MERIDIAN_API_KEY", "").strip()
|
|
51
|
+
try:
|
|
52
|
+
response = httpx2.get(
|
|
53
|
+
base_url + "/profiles/list",
|
|
54
|
+
headers={"x-api-key": key} if key else {},
|
|
55
|
+
timeout=2,
|
|
56
|
+
trust_env=False,
|
|
57
|
+
)
|
|
58
|
+
data = response.json()
|
|
59
|
+
except (httpx2.HTTPError, ValueError):
|
|
60
|
+
return None
|
|
61
|
+
if not isinstance(data, dict) or not data.get("profiles"):
|
|
62
|
+
return None
|
|
63
|
+
active = data.get("activeProfile")
|
|
64
|
+
return active if isinstance(active, str) and active else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def login_target() -> LoginTarget:
|
|
68
|
+
"""The login the Meridian this session uses reads, decided as `managed_endpoint` does.
|
|
69
|
+
|
|
70
|
+
A managed instance uses `default_profile()`, or Claude Code's own login when
|
|
71
|
+
there are no profiles; an external proxy uses whichever profile it reports
|
|
72
|
+
as active.
|
|
73
|
+
"""
|
|
74
|
+
from pcode.meridian import DEFAULT_BASE_URL, meridian_base_url
|
|
75
|
+
from pcode.meridian_process import external_proxy_running, managed_base_url, managed_mode
|
|
76
|
+
|
|
77
|
+
default = LoginTarget(os.environ.get("CLAUDE_CONFIG_DIR") or None, "Claude Code's login")
|
|
78
|
+
configured = os.environ.get("PCODE_MERIDIAN_BASE_URL", "").strip()
|
|
79
|
+
mode = "off" if configured else managed_mode()
|
|
80
|
+
base = meridian_base_url() if configured else DEFAULT_BASE_URL
|
|
81
|
+
managed = managed_base_url() is not None or mode == "on"
|
|
82
|
+
if managed or (mode == "auto" and not external_proxy_running(base)):
|
|
83
|
+
profile = default_profile()
|
|
84
|
+
return profile_target(profile) if profile else default
|
|
85
|
+
active = active_profile(base)
|
|
86
|
+
if active is None:
|
|
87
|
+
return default
|
|
88
|
+
known = next((p for p in meridian_profiles() if p["id"] == active), {"id": active})
|
|
89
|
+
return profile_target(known)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def remote_session() -> bool:
|
|
93
|
+
return any(os.environ.get(name) for name in REMOTE_VARIABLES)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def claude_executable() -> str | None:
|
|
97
|
+
return os.environ.get("MERIDIAN_CLAUDE_PATH") or shutil.which("claude")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def login_env(target: LoginTarget) -> dict[str, str]:
|
|
101
|
+
env = dict(os.environ)
|
|
102
|
+
if target.config_dir:
|
|
103
|
+
env["CLAUDE_CONFIG_DIR"] = target.config_dir
|
|
104
|
+
return env
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def auth_status(executable: str, env: dict[str, str]) -> dict:
|
|
108
|
+
"""Only the non-identifying parts of `claude auth status`: no email or org."""
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
[executable, "auth", "status", "--json"],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
timeout=15,
|
|
115
|
+
env=env,
|
|
116
|
+
)
|
|
117
|
+
data = json.loads(result.stdout)
|
|
118
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
119
|
+
return {}
|
|
120
|
+
if not isinstance(data, dict):
|
|
121
|
+
return {}
|
|
122
|
+
return {k: data[k] for k in ("loggedIn", "authMethod", "subscriptionType") if k in data}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def manual_login_command(target: LoginTarget) -> str:
|
|
126
|
+
if target.config_dir:
|
|
127
|
+
return f"CLAUDE_CONFIG_DIR={target.config_dir} claude auth login"
|
|
128
|
+
return "claude auth login"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def claude_login(
|
|
132
|
+
notify,
|
|
133
|
+
target: LoginTarget,
|
|
134
|
+
timeout: float = LOGIN_TIMEOUT_SECONDS,
|
|
135
|
+
*,
|
|
136
|
+
executable: str | None = None,
|
|
137
|
+
retry: str = "/login meridian",
|
|
138
|
+
extra_env: dict[str, str] | None = None,
|
|
139
|
+
for_meridian: bool = True,
|
|
140
|
+
):
|
|
141
|
+
"""Run `claude auth login --claudeai` for `target` and return its auth status.
|
|
142
|
+
|
|
143
|
+
`executable` defaults to the `claude` Meridian runs; `retry` is the command
|
|
144
|
+
the error messages suggest; `extra_env` overrides the inherited environment
|
|
145
|
+
for both the login and the status check. `for_meridian` names Meridian's
|
|
146
|
+
install knobs when no `claude` is found.
|
|
147
|
+
"""
|
|
148
|
+
if target.oauth_token:
|
|
149
|
+
raise LoginError(
|
|
150
|
+
f"{target.label} signs in with a `claude setup-token` token, not a browser login. "
|
|
151
|
+
"Replace it with `meridian profile add <name> --oauth-token`."
|
|
152
|
+
)
|
|
153
|
+
executable = executable or claude_executable()
|
|
154
|
+
if executable is None and for_meridian:
|
|
155
|
+
raise LoginError(
|
|
156
|
+
"Claude Code (`claude`) is not on PATH, and Meridian signs in through it. "
|
|
157
|
+
f"Install Claude Code, or set MERIDIAN_CLAUDE_PATH, then retry {retry}."
|
|
158
|
+
)
|
|
159
|
+
if executable is None:
|
|
160
|
+
raise LoginError(f"Claude Code (`claude`) was not found. Install it, then retry {retry}.")
|
|
161
|
+
if remote_session():
|
|
162
|
+
raise LoginError(
|
|
163
|
+
"This looks like a remote session, so the browser cannot finish signing in here. "
|
|
164
|
+
f"Run `{manual_login_command(target)}` in a terminal on this machine, then retry."
|
|
165
|
+
)
|
|
166
|
+
env = {**login_env(target), **(extra_env or {})}
|
|
167
|
+
process = await asyncio.create_subprocess_exec(
|
|
168
|
+
executable,
|
|
169
|
+
"auth",
|
|
170
|
+
"login",
|
|
171
|
+
"--claudeai",
|
|
172
|
+
stdin=subprocess.DEVNULL,
|
|
173
|
+
stdout=subprocess.PIPE,
|
|
174
|
+
stderr=subprocess.STDOUT,
|
|
175
|
+
env=env,
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
async with asyncio.timeout(timeout):
|
|
179
|
+
async for raw in process.stdout:
|
|
180
|
+
line = ESCAPES.sub("", raw.decode("utf-8", "replace"))
|
|
181
|
+
line = PASTE_PROMPT.sub("", line).strip()
|
|
182
|
+
if not line:
|
|
183
|
+
continue
|
|
184
|
+
if line.startswith("If the browser didn't open"):
|
|
185
|
+
# That URL leads to a code to paste, which pcode cannot pass on.
|
|
186
|
+
notify(
|
|
187
|
+
"If no browser opened, press Ctrl+C and run "
|
|
188
|
+
f"`{manual_login_command(target)}` in a terminal instead."
|
|
189
|
+
)
|
|
190
|
+
continue
|
|
191
|
+
notify(line)
|
|
192
|
+
code = await process.wait()
|
|
193
|
+
except TimeoutError:
|
|
194
|
+
raise LoginError(f"Claude sign-in timed out. Run {retry} to try again.") from None
|
|
195
|
+
finally:
|
|
196
|
+
if process.returncode is None:
|
|
197
|
+
process.kill()
|
|
198
|
+
await process.wait()
|
|
199
|
+
if code != 0:
|
|
200
|
+
raise LoginError(
|
|
201
|
+
f"Claude sign-in did not complete (exit {code}). "
|
|
202
|
+
f"Run `{manual_login_command(target)}` in a terminal to see why."
|
|
203
|
+
)
|
|
204
|
+
status = await asyncio.to_thread(auth_status, executable, env)
|
|
205
|
+
if status.get("loggedIn") is False:
|
|
206
|
+
raise LoginError(f"Claude Code still reports no login. Run {retry} to try again.")
|
|
207
|
+
return status
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class Install:
|
|
212
|
+
"""One npm-global Meridian installation."""
|
|
213
|
+
|
|
214
|
+
root: Path
|
|
215
|
+
npm: str
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def version(self) -> str | None:
|
|
219
|
+
try:
|
|
220
|
+
return json.loads((self.root / "package.json").read_text()).get("version")
|
|
221
|
+
except (OSError, ValueError):
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _run(args: list[str], env=None) -> str | None:
|
|
226
|
+
try:
|
|
227
|
+
result = subprocess.run(args, capture_output=True, text=True, timeout=60, env=env)
|
|
228
|
+
except (OSError, subprocess.SubprocessError):
|
|
229
|
+
return None
|
|
230
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def npm_env(npm: str) -> dict[str, str]:
|
|
234
|
+
"""Put the npm's own bin directory first so its node runs the install scripts."""
|
|
235
|
+
env = dict(os.environ)
|
|
236
|
+
env["PATH"] = os.pathsep.join([str(Path(npm).parent), env.get("PATH", "")])
|
|
237
|
+
return env
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def path_install() -> Install | None:
|
|
241
|
+
"""The Meridian that `meridian` on PATH runs, owned by the npm on PATH."""
|
|
242
|
+
npm = shutil.which("npm")
|
|
243
|
+
if npm is None:
|
|
244
|
+
return None
|
|
245
|
+
root = _run([npm, "root", "-g"])
|
|
246
|
+
if not root:
|
|
247
|
+
return None
|
|
248
|
+
package = Path(root) / PACKAGE
|
|
249
|
+
return Install(package, npm) if (package / "package.json").is_file() else None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def package_root(path: str) -> Path | None:
|
|
253
|
+
"""The `@rynfar/meridian` directory above any file inside the package."""
|
|
254
|
+
for parent in Path(path).parents:
|
|
255
|
+
if parent.name == "meridian" and parent.parent.name == "@rynfar":
|
|
256
|
+
return parent
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def proxy_install(base_url: str) -> Install | None:
|
|
261
|
+
"""The installation a running proxy was started from, via its /health report."""
|
|
262
|
+
try:
|
|
263
|
+
health = httpx2.get(base_url + "/health", timeout=2, trust_env=False).json()
|
|
264
|
+
path = health["claudeExecutable"]["path"]
|
|
265
|
+
except (httpx2.HTTPError, ValueError, KeyError, TypeError):
|
|
266
|
+
return None
|
|
267
|
+
root = package_root(path) if isinstance(path, str) else None
|
|
268
|
+
if root is None:
|
|
269
|
+
return None
|
|
270
|
+
prefix = root.parent.parent.parent # <prefix>/lib/node_modules/@rynfar/meridian
|
|
271
|
+
if prefix.name == "lib":
|
|
272
|
+
prefix = prefix.parent
|
|
273
|
+
for npm in (prefix / "bin" / "npm", prefix / "npm"):
|
|
274
|
+
if npm.is_file():
|
|
275
|
+
return Install(root, str(npm))
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def running_version(base_url: str) -> str | None:
|
|
280
|
+
try:
|
|
281
|
+
return httpx2.get(base_url + "/health", timeout=2, trust_env=False).json().get("version")
|
|
282
|
+
except (httpx2.HTTPError, ValueError, AttributeError):
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def launch_agent_label() -> str | None:
|
|
287
|
+
"""A macOS launchd agent that runs Meridian, if one is installed."""
|
|
288
|
+
agents = Path.home() / "Library" / "LaunchAgents"
|
|
289
|
+
for plist in sorted(agents.glob("*.plist")) if agents.is_dir() else ():
|
|
290
|
+
try:
|
|
291
|
+
data = plistlib.loads(plist.read_bytes())
|
|
292
|
+
except Exception:
|
|
293
|
+
continue
|
|
294
|
+
program = " ".join(
|
|
295
|
+
[str(data.get("Program", "")), *map(str, data.get("ProgramArguments", []))]
|
|
296
|
+
)
|
|
297
|
+
if "meridian" in program.lower() or "meridian" in str(data.get("Label", "")).lower():
|
|
298
|
+
return data.get("Label")
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def upgrade_meridian(out=None) -> int:
|
|
303
|
+
"""Install or upgrade every Meridian pcode uses; returns a process exit code."""
|
|
304
|
+
from pcode.meridian import meridian_base_url
|
|
305
|
+
|
|
306
|
+
out = out or sys.stdout
|
|
307
|
+
|
|
308
|
+
def say(text: str) -> None:
|
|
309
|
+
print(text, file=out, flush=True)
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
base = meridian_base_url()
|
|
313
|
+
except ValueError as error:
|
|
314
|
+
say(str(error))
|
|
315
|
+
return 2
|
|
316
|
+
installs: dict[Path, Install] = {}
|
|
317
|
+
for install in (path_install(), proxy_install(base)):
|
|
318
|
+
if install is not None:
|
|
319
|
+
installs.setdefault(install.root.resolve(), install)
|
|
320
|
+
if not installs:
|
|
321
|
+
npm = shutil.which("npm")
|
|
322
|
+
if npm is None:
|
|
323
|
+
say("Meridian is not installed and npm is not on PATH. Install Node.js, then retry.")
|
|
324
|
+
return 1
|
|
325
|
+
say(f"Meridian is not installed; installing {PACKAGE} with {npm}.")
|
|
326
|
+
return subprocess.call([npm, "install", "-g", f"{PACKAGE}@latest"], env=npm_env(npm))
|
|
327
|
+
failed = False
|
|
328
|
+
for install in installs.values():
|
|
329
|
+
before = install.version
|
|
330
|
+
say(f"Upgrading {install.root} ({before or 'unknown version'}) with {install.npm}")
|
|
331
|
+
code = subprocess.call(
|
|
332
|
+
[install.npm, "install", "-g", f"{PACKAGE}@latest"], env=npm_env(install.npm)
|
|
333
|
+
)
|
|
334
|
+
after = install.version
|
|
335
|
+
if code != 0:
|
|
336
|
+
failed = True
|
|
337
|
+
say(f"npm exited with {code}; {install.root} is still {after or 'unknown'}.")
|
|
338
|
+
elif after == before:
|
|
339
|
+
say(f"Already current: {after}.")
|
|
340
|
+
else:
|
|
341
|
+
say(f"Upgraded {before} -> {after}.")
|
|
342
|
+
running = running_version(base)
|
|
343
|
+
newest = max((i.version for i in installs.values() if i.version), default=None, key=_key)
|
|
344
|
+
if running and newest and _key(running) < _key(newest):
|
|
345
|
+
say(f"The proxy at {base} is still running {running}. Restart it to use {newest}.")
|
|
346
|
+
label = launch_agent_label()
|
|
347
|
+
if label:
|
|
348
|
+
say(f" launchctl kickstart -k gui/{os.getuid()}/{label}")
|
|
349
|
+
say("pcode sessions that started their own Meridian pick up the new version on restart.")
|
|
350
|
+
return 1 if failed else 0
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _key(version: str | None) -> tuple[int, ...]:
|
|
354
|
+
from pcode.meridian_process import version_tuple
|
|
355
|
+
|
|
356
|
+
return version_tuple(version) or ()
|