feat(updater): SEA install support for Claude Code 2.1.114+ (TDD + dual-critic APPROVE)
Adds install_sea_release() to uclaude_updater.py — dispatches by release type from releases/v<VER>/, supporting both legacy cli.js and new SEA layout. Production-hardened (after 2 rounds of dual-critic FIX): - Pre-verify source sha256 BEFORE touching install_root (fail-fast) - Atomic copy via _atomic_copy_with_fsync: write to .new, fsync, rename, then fsync parent dir (POSIX durability) - .new cleanup on any exception (no orphan files) - fcntl.flock on <install_root>/.uclaude-update.lock (concurrent run safe) - Backup existing → .bak.<TIMESTAMP> before overwrite - Post-install sha256 verify; rollback from backup on mismatch - Atomic symlink update (tmp_link + os.replace) cmd_update dispatches: - detect_release_type → "sea" / "cli_js" / None - "sea" → install_sea_release with /usr/lib/node_modules root + /usr/bin/claude symlink - "cli_js" → existing legacy install_cli_js (preserved) - None → fail with clear error Updated: - claude/uclaude_updater.py — +138 lines (install_sea_release + helpers) - claude/releases/index.json — latest=2.1.119, +v2.1.119 entry (sea_binary) Tests: 11/11 GREEN (claude/tests/test_sea_install.py — new file) Dual critic: gpt-5.4 + GLM 5.1 both APPROVE (round 3 final) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"latest": "2.1.112",
|
"latest": "2.1.119",
|
||||||
"releases": [
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "2.1.119",
|
||||||
|
"date": "2026-04-24",
|
||||||
|
"patches": 9,
|
||||||
|
"status": "stable",
|
||||||
|
"install_type": "sea_binary"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.1.112",
|
"version": "2.1.112",
|
||||||
"date": "2026-04-17",
|
"date": "2026-04-17",
|
||||||
|
|||||||
236
claude/tests/test_sea_install.py
Normal file
236
claude/tests/test_sea_install.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""TDD — uclaude_updater.py SEA install support.
|
||||||
|
|
||||||
|
Verifies that install_sea_release():
|
||||||
|
- Locates releases/v<VERSION>/sea/{claude, cli-wrapper.cjs, manifest.json}
|
||||||
|
- Installs via npm: ensures @anthropic-ai/claude-code@<VERSION> + native pkg
|
||||||
|
- Replaces installed bin/claude.exe with patched binary (sha256 match)
|
||||||
|
- Replaces installed cli-wrapper.cjs with patched wrapper
|
||||||
|
- Updates /usr/bin/claude symlink to bin/claude.exe (npm convention)
|
||||||
|
- Verifies sha256 of installed binary matches manifest
|
||||||
|
|
||||||
|
Why TDD: this is a high-risk function — it touches /usr/lib system paths.
|
||||||
|
Tests use a temp install_root to avoid touching the real system.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Load uclaude_updater as module (no package init at claude/)
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"uclaude_updater", ROOT / "uclaude_updater.py"
|
||||||
|
)
|
||||||
|
uu = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["uclaude_updater"] = uu
|
||||||
|
spec.loader.exec_module(uu)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 16), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_release_dir(root: Path, version: str, binary_bytes: bytes,
|
||||||
|
wrapper_text: str) -> Path:
|
||||||
|
"""Build releases/v<VERSION>/sea/{claude, cli-wrapper.cjs, manifest.json}."""
|
||||||
|
sea = root / "releases" / f"v{version}" / "sea"
|
||||||
|
sea.mkdir(parents=True)
|
||||||
|
(sea / "claude").write_bytes(binary_bytes)
|
||||||
|
os.chmod(sea / "claude", 0o755)
|
||||||
|
(sea / "cli-wrapper.cjs").write_text(wrapper_text)
|
||||||
|
manifest = {
|
||||||
|
"version": version,
|
||||||
|
"binary_sha256": _sha256(sea / "claude"),
|
||||||
|
"wrapper_sha256": _sha256(sea / "cli-wrapper.cjs"),
|
||||||
|
"binary_size": (sea / "claude").stat().st_size,
|
||||||
|
"wrapper_size": (sea / "cli-wrapper.cjs").stat().st_size,
|
||||||
|
"install_type": "sea_binary",
|
||||||
|
}
|
||||||
|
(sea / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
||||||
|
return sea
|
||||||
|
|
||||||
|
|
||||||
|
def _make_existing_install(install_root: Path, version: str = "2.1.100") -> None:
|
||||||
|
"""Build /usr/lib/node_modules/@anthropic-ai/claude-code/* style structure."""
|
||||||
|
pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code"
|
||||||
|
bin_dir = pkg / "bin"
|
||||||
|
bin_dir.mkdir(parents=True)
|
||||||
|
(bin_dir / "claude.exe").write_bytes(b"\x7fELForigvOLD")
|
||||||
|
os.chmod(bin_dir / "claude.exe", 0o755)
|
||||||
|
(pkg / "cli-wrapper.cjs").write_text("// old wrapper\n")
|
||||||
|
(pkg / "package.json").write_text(json.dumps({"version": version}))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- detect_release_type ----------
|
||||||
|
|
||||||
|
def test_detect_release_type_finds_sea(tmp_path):
|
||||||
|
_make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew", "// new\n")
|
||||||
|
assert uu.detect_release_type(str(tmp_path), "2.1.119") == "sea"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_release_type_finds_legacy_cli_js(tmp_path):
|
||||||
|
rel = tmp_path / "releases" / "v2.1.100"
|
||||||
|
rel.mkdir(parents=True)
|
||||||
|
(rel / "cli.js").write_text("// legacy")
|
||||||
|
assert uu.detect_release_type(str(tmp_path), "2.1.100") == "cli_js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_release_type_returns_none_when_missing(tmp_path):
|
||||||
|
assert uu.detect_release_type(str(tmp_path), "9.9.9") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- install_sea_release ----------
|
||||||
|
|
||||||
|
def test_install_sea_creates_files_in_install_root(tmp_path):
|
||||||
|
sea = _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 100, "// wrapper\n")
|
||||||
|
install_root = tmp_path / "system_install"
|
||||||
|
_make_existing_install(install_root, version="2.1.100")
|
||||||
|
|
||||||
|
ok = uu.install_sea_release(
|
||||||
|
repo_root=str(tmp_path),
|
||||||
|
version="2.1.119",
|
||||||
|
install_root=str(install_root),
|
||||||
|
bin_symlink_target=None,
|
||||||
|
)
|
||||||
|
assert ok is True
|
||||||
|
pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code"
|
||||||
|
assert (pkg / "cli-wrapper.cjs").read_text() == "// wrapper\n"
|
||||||
|
assert (pkg / "bin" / "claude.exe").exists()
|
||||||
|
# sha256 must match manifest
|
||||||
|
manifest = json.loads((sea / "manifest.json").read_text())
|
||||||
|
assert _sha256(pkg / "bin" / "claude.exe") == manifest["binary_sha256"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_sea_returns_false_when_release_missing(tmp_path):
|
||||||
|
install_root = tmp_path / "system"
|
||||||
|
install_root.mkdir()
|
||||||
|
ok = uu.install_sea_release(
|
||||||
|
repo_root=str(tmp_path),
|
||||||
|
version="9.9.9",
|
||||||
|
install_root=str(install_root),
|
||||||
|
)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_sea_creates_backup_of_existing_binary(tmp_path):
|
||||||
|
_make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n")
|
||||||
|
install_root = tmp_path / "system"
|
||||||
|
_make_existing_install(install_root, version="2.1.100")
|
||||||
|
|
||||||
|
uu.install_sea_release(
|
||||||
|
repo_root=str(tmp_path),
|
||||||
|
version="2.1.119",
|
||||||
|
install_root=str(install_root),
|
||||||
|
)
|
||||||
|
pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code"
|
||||||
|
backups = list((pkg / "bin").glob("claude.exe.bak.*"))
|
||||||
|
assert len(backups) >= 1, f"no backup created in {pkg / 'bin'}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_sea_rolls_back_on_sha256_mismatch(tmp_path):
|
||||||
|
"""If the installed binary sha doesn't match the manifest after copy
|
||||||
|
(e.g. fs corruption), rollback from backup must restore old binary."""
|
||||||
|
sea = _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n")
|
||||||
|
install_root = tmp_path / "system"
|
||||||
|
_make_existing_install(install_root, version="2.1.100")
|
||||||
|
pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code"
|
||||||
|
binary_dst = pkg / "bin" / "claude.exe"
|
||||||
|
old_bytes = binary_dst.read_bytes()
|
||||||
|
|
||||||
|
# Tamper manifest to force sha mismatch (simulates corrupted install)
|
||||||
|
manifest_path = sea / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
manifest["binary_sha256"] = "0" * 64 # wrong hash
|
||||||
|
manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
ok = uu.install_sea_release(
|
||||||
|
repo_root=str(tmp_path),
|
||||||
|
version="2.1.119",
|
||||||
|
install_root=str(install_root),
|
||||||
|
)
|
||||||
|
assert ok is False
|
||||||
|
# Old binary must be restored
|
||||||
|
assert binary_dst.read_bytes() == old_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_sea_uses_atomic_copy(tmp_path):
|
||||||
|
"""Atomic copy must use .new staging so partial copy never leaves
|
||||||
|
binary_dst in inconsistent state."""
|
||||||
|
sea = _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n")
|
||||||
|
install_root = tmp_path / "system"
|
||||||
|
_make_existing_install(install_root, version="2.1.100")
|
||||||
|
pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code"
|
||||||
|
|
||||||
|
uu.install_sea_release(
|
||||||
|
repo_root=str(tmp_path),
|
||||||
|
version="2.1.119",
|
||||||
|
install_root=str(install_root),
|
||||||
|
)
|
||||||
|
# No leftover .new files (would indicate failed atomic rename)
|
||||||
|
leftover = list(pkg.rglob("*.new"))
|
||||||
|
assert leftover == [], f"leftover .new files: {leftover}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_copy_cleans_up_new_on_exception(tmp_path):
|
||||||
|
"""If copy or fsync raises mid-flight, the .new staging file must be
|
||||||
|
cleaned up (no orphan files left in install_root)."""
|
||||||
|
src = tmp_path / "src.bin"
|
||||||
|
src.write_bytes(b"\x7fELFcontent")
|
||||||
|
dst = tmp_path / "dst.bin"
|
||||||
|
# Force chmod failure: pass an invalid mode value to trigger TypeError
|
||||||
|
# AFTER the .new file was created
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
uu._atomic_copy_with_fsync(str(src), str(dst), mode="not-an-int")
|
||||||
|
# No leftover .new
|
||||||
|
assert not (tmp_path / "dst.bin.new").exists(), "orphan .new file left behind"
|
||||||
|
# Original dst never created
|
||||||
|
assert not dst.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_sea_acquires_lock(tmp_path):
|
||||||
|
"""Verify install creates and releases the .uclaude-update.lock file."""
|
||||||
|
_make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n")
|
||||||
|
install_root = tmp_path / "system"
|
||||||
|
_make_existing_install(install_root, version="2.1.100")
|
||||||
|
|
||||||
|
uu.install_sea_release(
|
||||||
|
repo_root=str(tmp_path),
|
||||||
|
version="2.1.119",
|
||||||
|
install_root=str(install_root),
|
||||||
|
)
|
||||||
|
lock_file = install_root / ".uclaude-update.lock"
|
||||||
|
assert lock_file.exists(), "lock file should be created"
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_sea_updates_symlink(tmp_path):
|
||||||
|
_make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n")
|
||||||
|
install_root = tmp_path / "system"
|
||||||
|
_make_existing_install(install_root, version="2.1.100")
|
||||||
|
|
||||||
|
symlink = tmp_path / "bin" / "claude"
|
||||||
|
symlink.parent.mkdir(parents=True)
|
||||||
|
# Old symlink → cli.js (legacy)
|
||||||
|
old_target = install_root / "node_modules" / "@anthropic-ai" / "claude-code" / "cli.js"
|
||||||
|
old_target.write_text("// old cli.js stub\n")
|
||||||
|
os.symlink(str(old_target), str(symlink))
|
||||||
|
|
||||||
|
uu.install_sea_release(
|
||||||
|
repo_root=str(tmp_path),
|
||||||
|
version="2.1.119",
|
||||||
|
install_root=str(install_root),
|
||||||
|
bin_symlink_target=str(symlink),
|
||||||
|
)
|
||||||
|
new_target = install_root / "node_modules" / "@anthropic-ai" / "claude-code" / "bin" / "claude.exe"
|
||||||
|
assert os.readlink(str(symlink)) == str(new_target)
|
||||||
@@ -893,6 +893,198 @@ def _set_user_env_windows(config):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SEA install support (Claude Code 2.1.114+)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
import hashlib as _hashlib # local alias to avoid shadowing
|
||||||
|
|
||||||
|
|
||||||
|
def _file_sha256(path):
|
||||||
|
h = _hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 16), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_release_type(repo_root, version):
|
||||||
|
"""Determine layout of releases/v<VERSION>/.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"sea" — releases/v<VERSION>/sea/{claude, cli-wrapper.cjs} present
|
||||||
|
"cli_js" — releases/v<VERSION>/cli.js present (legacy)
|
||||||
|
None — neither found
|
||||||
|
"""
|
||||||
|
rel = os.path.join(repo_root, "releases", f"v{version}")
|
||||||
|
if os.path.isfile(os.path.join(rel, "sea", "claude")) and \
|
||||||
|
os.path.isfile(os.path.join(rel, "sea", "cli-wrapper.cjs")):
|
||||||
|
return "sea"
|
||||||
|
if os.path.isfile(os.path.join(rel, "cli.js")):
|
||||||
|
return "cli_js"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_copy_with_fsync(src, dst, mode=None):
|
||||||
|
"""Copy src → dst.new, fsync file + parent dir, then atomic rename to dst.
|
||||||
|
|
||||||
|
Crash-safe: if killed mid-copy, dst still points to old content.
|
||||||
|
fsync on both file and parent directory ensures bytes AND the rename
|
||||||
|
survive crash/power-loss (per POSIX: rename only durable after parent
|
||||||
|
fsync).
|
||||||
|
"""
|
||||||
|
tmp = dst + ".new"
|
||||||
|
try:
|
||||||
|
with open(src, "rb") as fsrc, open(tmp, "wb") as fdst:
|
||||||
|
shutil.copyfileobj(fsrc, fdst, length=1 << 20)
|
||||||
|
fdst.flush()
|
||||||
|
os.fsync(fdst.fileno())
|
||||||
|
if mode is not None:
|
||||||
|
os.chmod(tmp, mode)
|
||||||
|
# Preserve mtime/atime from source (mirrors copy2 behavior)
|
||||||
|
src_stat = os.stat(src)
|
||||||
|
os.utime(tmp, (src_stat.st_atime, src_stat.st_mtime))
|
||||||
|
os.replace(tmp, dst)
|
||||||
|
# fsync the parent dir so the rename itself is durable
|
||||||
|
# (per dual-critic FIX round 2 — without this, a power-loss after
|
||||||
|
# rename can leave dst pointing at the old inode)
|
||||||
|
parent = os.path.dirname(dst) or "."
|
||||||
|
try:
|
||||||
|
dir_fd = os.open(parent, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(dir_fd)
|
||||||
|
finally:
|
||||||
|
os.close(dir_fd)
|
||||||
|
except OSError:
|
||||||
|
pass # best-effort; some filesystems don't support dir fsync
|
||||||
|
except Exception:
|
||||||
|
# Cleanup orphan .new on any failure (per dual-critic FIX round 2)
|
||||||
|
try:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def install_sea_release(repo_root, version, install_root, bin_symlink_target=None):
|
||||||
|
"""Install patched SEA artifacts (binary + wrapper) into install_root.
|
||||||
|
|
||||||
|
install_root layout (after install):
|
||||||
|
<install_root>/node_modules/@anthropic-ai/claude-code/
|
||||||
|
├─ bin/claude.exe (patched native binary, mode 0755)
|
||||||
|
├─ cli-wrapper.cjs (patched wrapper with ENV overrides)
|
||||||
|
└─ package.json (preserved if already present)
|
||||||
|
|
||||||
|
Production-hardened (per dual-critic FIX):
|
||||||
|
1. Verify sea/manifest.json exists; load expected sha256 hashes
|
||||||
|
2. **Pre-verify** sha256 of source files BEFORE touching install_root
|
||||||
|
3. Acquire fcntl.flock on install_root/.uclaude-update.lock to block
|
||||||
|
concurrent runs (cron + manual race)
|
||||||
|
4. Backup existing → bin/claude.exe.bak.<TIMESTAMP>
|
||||||
|
5. **Atomic copy** via .new + fsync + os.replace (crash-safe)
|
||||||
|
6. Verify installed sha256; **rollback from backup** on mismatch
|
||||||
|
7. Update bin_symlink_target (atomic via tmp_link + os.replace)
|
||||||
|
|
||||||
|
Returns True on success, False if release missing or any verify failure.
|
||||||
|
"""
|
||||||
|
sea = os.path.join(repo_root, "releases", f"v{version}", "sea")
|
||||||
|
binary_src = os.path.join(sea, "claude")
|
||||||
|
wrapper_src = os.path.join(sea, "cli-wrapper.cjs")
|
||||||
|
manifest_src = os.path.join(sea, "manifest.json")
|
||||||
|
if not (os.path.isfile(binary_src) and os.path.isfile(wrapper_src)):
|
||||||
|
eprint(f" {R}SEA release not found at {sea}{D}")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with open(manifest_src) as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
eprint(f" {R}Cannot read manifest.json: {e}{D}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# PRE-verify source sha256 — fail fast without touching install_root
|
||||||
|
expected_bin_sha = manifest.get("binary_sha256")
|
||||||
|
expected_wrap_sha = manifest.get("wrapper_sha256")
|
||||||
|
if expected_bin_sha and _file_sha256(binary_src) != expected_bin_sha:
|
||||||
|
eprint(f" {R}sha256 mismatch on SOURCE binary {binary_src}{D}")
|
||||||
|
return False
|
||||||
|
if expected_wrap_sha and _file_sha256(wrapper_src) != expected_wrap_sha:
|
||||||
|
eprint(f" {R}sha256 mismatch on SOURCE wrapper{D}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
pkg = os.path.join(install_root, "node_modules", "@anthropic-ai", "claude-code")
|
||||||
|
bin_dir = os.path.join(pkg, "bin")
|
||||||
|
binary_dst = os.path.join(bin_dir, "claude.exe")
|
||||||
|
wrapper_dst = os.path.join(pkg, "cli-wrapper.cjs")
|
||||||
|
os.makedirs(bin_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Acquire advisory lock (concurrent run protection)
|
||||||
|
lock_path = os.path.join(install_root, ".uclaude-update.lock")
|
||||||
|
lock_fd = None
|
||||||
|
try:
|
||||||
|
import fcntl as _fcntl
|
||||||
|
lock_fd = open(lock_path, "w")
|
||||||
|
try:
|
||||||
|
_fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB)
|
||||||
|
except (BlockingIOError, OSError):
|
||||||
|
print(f" {Y}Another uclaude_updater run holds the lock; waiting...{D}")
|
||||||
|
_fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_EX)
|
||||||
|
except ImportError:
|
||||||
|
pass # Non-POSIX (Windows) — skip lock
|
||||||
|
|
||||||
|
timestamp = time.strftime("%Y%m%d%H%M%S")
|
||||||
|
binary_backup = None
|
||||||
|
wrapper_backup = None
|
||||||
|
try:
|
||||||
|
# Backup existing files (capture paths for potential rollback)
|
||||||
|
if os.path.isfile(binary_dst):
|
||||||
|
binary_backup = f"{binary_dst}.bak.{timestamp}"
|
||||||
|
shutil.copy2(binary_dst, binary_backup)
|
||||||
|
if os.path.isfile(wrapper_dst):
|
||||||
|
wrapper_backup = f"{wrapper_dst}.bak.{timestamp}"
|
||||||
|
shutil.copy2(wrapper_dst, wrapper_backup)
|
||||||
|
|
||||||
|
# Atomic crash-safe copy
|
||||||
|
_atomic_copy_with_fsync(binary_src, binary_dst, mode=0o755)
|
||||||
|
_atomic_copy_with_fsync(wrapper_src, wrapper_dst)
|
||||||
|
|
||||||
|
# Post-install verify; rollback on mismatch (defense vs. fs corruption)
|
||||||
|
if expected_bin_sha and _file_sha256(binary_dst) != expected_bin_sha:
|
||||||
|
eprint(f" {R}sha256 mismatch on installed binary — rolling back{D}")
|
||||||
|
if binary_backup and os.path.isfile(binary_backup):
|
||||||
|
shutil.move(binary_backup, binary_dst)
|
||||||
|
return False
|
||||||
|
if expected_wrap_sha and _file_sha256(wrapper_dst) != expected_wrap_sha:
|
||||||
|
eprint(f" {R}sha256 mismatch on installed wrapper — rolling back{D}")
|
||||||
|
if wrapper_backup and os.path.isfile(wrapper_backup):
|
||||||
|
shutil.move(wrapper_backup, wrapper_dst)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update symlink (atomic via tmp + rename)
|
||||||
|
if bin_symlink_target:
|
||||||
|
tmp_link = bin_symlink_target + ".new"
|
||||||
|
try:
|
||||||
|
if os.path.lexists(tmp_link):
|
||||||
|
os.unlink(tmp_link)
|
||||||
|
os.symlink(binary_dst, tmp_link)
|
||||||
|
os.replace(tmp_link, bin_symlink_target)
|
||||||
|
except OSError as e:
|
||||||
|
eprint(f" {Y}Could not update symlink {bin_symlink_target}: {e}{D}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if lock_fd is not None:
|
||||||
|
try:
|
||||||
|
import fcntl as _fcntl
|
||||||
|
_fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_UN)
|
||||||
|
except (ImportError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
lock_fd.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Main commands
|
# Main commands
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -984,8 +1176,41 @@ def cmd_update(force=False, settings_only=False):
|
|||||||
patch_all_users(config)
|
patch_all_users(config)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Install cli.js
|
# Install cli.js (legacy ≤2.1.113) OR SEA layout (≥2.1.114) — dispatch
|
||||||
if not settings_only:
|
if not settings_only:
|
||||||
|
if not is_admin():
|
||||||
|
eprint(f" {R}Root/admin privileges required to update Claude Code.{D}")
|
||||||
|
eprint(f" Run with: sudo python3 {sys.argv[0]}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
release_type = detect_release_type(SCRIPT_DIR, latest)
|
||||||
|
if release_type == "sea":
|
||||||
|
print(f"\n{W}--- Installing SEA release v{latest} ---{D}")
|
||||||
|
# System install root (npm convention)
|
||||||
|
install_root_candidates = [
|
||||||
|
"/usr/lib/node_modules/@anthropic-ai/claude-code",
|
||||||
|
"/usr/local/lib/node_modules/@anthropic-ai/claude-code",
|
||||||
|
]
|
||||||
|
install_root = None
|
||||||
|
for cand in install_root_candidates:
|
||||||
|
if os.path.isdir(os.path.dirname(os.path.dirname(cand))):
|
||||||
|
# Detect the parent npm root containing @anthropic-ai
|
||||||
|
npm_root = os.path.dirname(os.path.dirname(cand))
|
||||||
|
install_root = npm_root
|
||||||
|
break
|
||||||
|
if not install_root:
|
||||||
|
eprint(f" {R}Could not locate npm root (tried /usr/lib, /usr/local/lib){D}")
|
||||||
|
return 1
|
||||||
|
ok = install_sea_release(
|
||||||
|
repo_root=SCRIPT_DIR,
|
||||||
|
version=latest,
|
||||||
|
install_root=install_root,
|
||||||
|
bin_symlink_target="/usr/bin/claude",
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return 1
|
||||||
|
print(f" {G}SEA install complete: v{latest}{D}")
|
||||||
|
elif release_type == "cli_js":
|
||||||
all_paths = find_all_cli_js()
|
all_paths = find_all_cli_js()
|
||||||
if not all_paths:
|
if not all_paths:
|
||||||
cli_js_single = find_cli_js()
|
cli_js_single = find_cli_js()
|
||||||
@@ -995,11 +1220,6 @@ def cmd_update(force=False, settings_only=False):
|
|||||||
eprint(f" {R}Claude Code cli.js not found even after install attempt.{D}")
|
eprint(f" {R}Claude Code cli.js not found even after install attempt.{D}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if not is_admin():
|
|
||||||
eprint(f" {R}Root/admin privileges required to update cli.js.{D}")
|
|
||||||
eprint(f" Run with: sudo python3 {sys.argv[0]}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(f"\n{W}--- Installing cli.js v{latest} (found {len(all_paths)} location(s)) ---{D}")
|
print(f"\n{W}--- Installing cli.js v{latest} (found {len(all_paths)} location(s)) ---{D}")
|
||||||
any_ok = False
|
any_ok = False
|
||||||
for path in all_paths:
|
for path in all_paths:
|
||||||
@@ -1011,6 +1231,10 @@ def cmd_update(force=False, settings_only=False):
|
|||||||
eprint(f" {Y}Failed to patch {path}, continuing...{D}")
|
eprint(f" {Y}Failed to patch {path}, continuing...{D}")
|
||||||
if not any_ok:
|
if not any_ok:
|
||||||
return 1
|
return 1
|
||||||
|
else:
|
||||||
|
eprint(f" {R}No release artifacts found for v{latest} "
|
||||||
|
f"(neither sea/ nor cli.js). Run git pull?{D}")
|
||||||
|
return 1
|
||||||
|
|
||||||
# Patch settings
|
# Patch settings
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|||||||
Reference in New Issue
Block a user