Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions runtime-manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ required scripts/start-token-meter
required scripts/uninstall-launch-agent
required scripts/uninstall-systemd-user
required scripts/update
required scripts/update-linux
optional README.md
optional LICENSE
python-tree token_meter
Expand Down
12 changes: 12 additions & 0 deletions scripts/update
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail

ENTRYPOINT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
case "$(uname -s)" in
Linux)
exec "$ENTRYPOINT_ROOT/scripts/update-linux" "$@"
;;
Darwin)
;;
*)
echo "Token Meter update failed: supported platforms are macOS and Linux." >&2
exit 1
;;
esac
SOURCE_ROOT="${1:-}"
STATUS_PATH="${2:-$HOME/.token-meter/update-status.json}"
RUNTIME_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
Expand Down
149 changes: 149 additions & 0 deletions scripts/update-linux
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
set -euo pipefail

SOURCE_ROOT="${1:-}"
STATUS_PATH="${2:-$HOME/.token-meter/update-status.json}"
RUNTIME_ROOT="$(cd "$(dirname "$0")/.." && pwd)"

[[ "$(uname -s)" == "Linux" ]] || {
echo "Token Meter Linux update failed: this updater requires Linux." >&2
exit 1
}

write_status() {
local phase="$1"
local error_code="${2:-}"
local current_revision="${3:-}"
local latest_revision="${4:-}"
local previous_revision="${5:-}"
UPDATE_PHASE="$phase" \
UPDATE_ERROR_CODE="$error_code" \
UPDATE_CURRENT_REVISION="$current_revision" \
UPDATE_LATEST_REVISION="$latest_revision" \
UPDATE_PREVIOUS_REVISION="$previous_revision" \
python3 - "$STATUS_PATH" <<'PY'
import json
import os
import sys
import time

path = os.path.abspath(os.path.expanduser(sys.argv[1]))
directory = os.path.dirname(path)
os.makedirs(directory, exist_ok=True)
try:
with open(path, encoding="utf-8") as handle:
previous = json.load(handle)
except (FileNotFoundError, json.JSONDecodeError, OSError):
previous = {}
phase = os.environ["UPDATE_PHASE"]
record = {
"phase": phase,
"error_code": os.environ.get("UPDATE_ERROR_CODE", ""),
"current_revision": os.environ.get("UPDATE_CURRENT_REVISION", ""),
"latest_revision": os.environ.get("UPDATE_LATEST_REVISION", ""),
"previous_revision": (
os.environ.get("UPDATE_PREVIOUS_REVISION")
or previous.get("previous_revision", "")
),
"checked_at": int(previous.get("checked_at") or time.time()),
"started_at": int(previous.get("started_at") or time.time()),
"available": phase not in {"complete", "current"},
"can_update": False,
}
if phase == "failed":
record["failed_revision"] = (
record["current_revision"]
or record["latest_revision"]
or previous.get("failed_revision", "")
)
if phase == "complete":
record["installed_at"] = int(time.time())
elif previous.get("installed_at"):
record["installed_at"] = int(previous["installed_at"])
temporary = f"{path}.tmp-{os.getpid()}"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(record, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
PY
}

fail_update() {
local error_code="$1"
write_status "failed" "$error_code" "$current_revision" "$latest_revision" "$previous_revision"
exit 1
}

current_revision=""
latest_revision=""
previous_revision=""
retry_revision="${TOKEN_METER_UPDATE_RETRY_REVISION:-}"

sleep 1

if [[ -z "$SOURCE_ROOT" || ! -d "$SOURCE_ROOT" || ! -e "$SOURCE_ROOT/.git" \
|| ! -f "$SOURCE_ROOT/scripts/install-linux" ]]; then
fail_update "source_unavailable"
fi
if ! command -v git >/dev/null 2>&1; then
fail_update "git_unavailable"
fi

SOURCE_ROOT="$(cd "$SOURCE_ROOT" && pwd)"
current_revision="$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null || true)"
previous_revision="$current_revision"
upstream="$(git -C "$SOURCE_ROOT" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true)"
if [[ ! "$upstream" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]{1,199}$ || "$upstream" != */* ]]; then
fail_update "upstream_unavailable"
fi
branch="$(git -C "$SOURCE_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if [[ "$branch" != "main" || "${upstream##*/}" != "main" ]]; then
fail_update "unsupported_update_branch"
fi
if [[ -n "$retry_revision" && ! "$retry_revision" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
retry_revision=""
fi
if [[ -n "$retry_revision" ]]; then
previous_revision=""
fi
remote="${upstream%%/*}"

write_status "fetching" "" "$current_revision" "" "$previous_revision"
if ! git -C "$SOURCE_ROOT" fetch --quiet --prune --no-tags "$remote"; then
fail_update "fetch_failed"
fi

latest_revision="$(git -C "$SOURCE_ROOT" rev-parse '@{upstream}' 2>/dev/null || true)"
counts="$(git -C "$SOURCE_ROOT" rev-list --left-right --count 'HEAD...@{upstream}' 2>/dev/null || true)"
read -r ahead behind <<<"$counts"
if [[ ! "$ahead" =~ ^[0-9]+$ || ! "$behind" =~ ^[0-9]+$ \
|| ! "$current_revision" =~ ^[0-9a-fA-F]{7,40}$ \
|| ! "$latest_revision" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
fail_update "inspect_failed"
fi
if [[ -n "$(git -C "$SOURCE_ROOT" status --porcelain 2>/dev/null || true)" ]]; then
fail_update "dirty_checkout"
fi
if (( ahead > 0 )); then
fail_update "diverged_checkout"
fi
if (( behind == 0 )) && [[ "$current_revision" != "$retry_revision" ]]; then
write_status "current" "" "$current_revision" "$latest_revision" "$previous_revision"
exit 0
fi

if (( behind > 0 )); then
if ! git -C "$SOURCE_ROOT" merge --ff-only '@{upstream}' >/dev/null 2>&1; then
fail_update "diverged_checkout"
fi
current_revision="$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null || true)"
fi

write_status "installing" "" "$current_revision" "$latest_revision" "$previous_revision"
if ! TOKEN_METER_INSTALL_ROOT="$RUNTIME_ROOT" "$SOURCE_ROOT/scripts/install-linux"; then
fail_update "install_failed"
fi

write_status "complete" "" "$current_revision" "$latest_revision" "$previous_revision"
2 changes: 1 addition & 1 deletion specs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates
| `python3 -m unittest discover -s tests -v` | Run all unit and contract tests |
| `PYTHONPYCACHEPREFIX=/private/tmp/token-meter-pycache python3 -m py_compile meter.py token_meter_mcp.py $(find token_meter -type f -name '*.py' -print)` | Compile Python without polluting the repo |
| `node -e "const fs=require('fs');const h=fs.readFileSync('page.html','utf8');const m=h.match(/<script>([\\s\\S]*)<\\/script>/);new Function(m[1]);console.log('js ok')"` | Parse embedded dashboard JavaScript |
| `bash -n scripts/install scripts/install-linux scripts/install-launch-agent scripts/install-systemd-user scripts/run-menubar scripts/run-token-meter-mcp scripts/start-token-meter scripts/uninstall-launch-agent scripts/uninstall-systemd-user scripts/update` | Check shell syntax |
| `bash -n scripts/install scripts/install-linux scripts/install-launch-agent scripts/install-systemd-user scripts/run-menubar scripts/run-token-meter-mcp scripts/start-token-meter scripts/uninstall-launch-agent scripts/uninstall-systemd-user scripts/update scripts/update-linux` | Check shell syntax |
| `swiftc menubar/TokenMeterMenuBar.swift -o /private/tmp/token-meter-menubar` | Compile the native companion |
| `TOKEN_METER_MENUBAR_SMOKE=1 /private/tmp/token-meter-menubar` | Run deterministic native smoke output |
| `powershell -NoProfile -Command "[void] [scriptblock]::Create((Get-Content -Raw scripts/install-windows.ps1))"` | Parse a Windows script on a Windows host |
Expand Down
2 changes: 1 addition & 1 deletion specs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ Run these checks from the repository root before opening a pull request:
```bash
PYTHONPYCACHEPREFIX=/tmp/token-meter-pycache python3 -m py_compile meter.py token_meter_mcp.py $(find token_meter -type f -name '*.py' -print | LC_ALL=C sort)
python3 -m unittest discover -s tests -v
bash -n scripts/install scripts/install-linux scripts/install-launch-agent scripts/install-systemd-user scripts/run-menubar scripts/run-token-meter-mcp scripts/start-token-meter scripts/uninstall-launch-agent scripts/uninstall-systemd-user scripts/update
bash -n scripts/install scripts/install-linux scripts/install-launch-agent scripts/install-systemd-user scripts/run-menubar scripts/run-token-meter-mcp scripts/start-token-meter scripts/uninstall-launch-agent scripts/uninstall-systemd-user scripts/update scripts/update-linux
node -e "const fs=require('fs'); const html=fs.readFileSync('page.html','utf8'); const m=html.match(/<script>([\\s\\S]*)<\\/script>/); new Function(m[1]); console.log('js ok')"
git diff --check
```
Expand Down
77 changes: 77 additions & 0 deletions tests/test_linux_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import json
import os
import platform
import subprocess
import tempfile
import unittest
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]


@unittest.skipUnless(platform.system() == "Linux", "Linux update integration")
class LinuxUpdateIntegrationTests(unittest.TestCase):
def test_fast_forwards_and_invokes_linux_installer(self):
def git(*args, cwd):
return subprocess.run(
["git", *args], cwd=cwd, check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)

with tempfile.TemporaryDirectory() as tmp:
workspace = Path(tmp)
remote = workspace / "remote.git"
seed = workspace / "seed"
source = workspace / "source"
marker = workspace / "installer-marker"
install_log = workspace / "installer.log"

git("init", "--bare", "--initial-branch=main", str(remote), cwd=workspace)
seed.mkdir()
git("init", "--initial-branch=main", cwd=seed)
install_script = seed / "scripts" / "install-linux"
install_script.parent.mkdir()
install_script.write_text(
"#!/usr/bin/env bash\n"
"set -euo pipefail\n"
f"printf '%s\\n' \"$TOKEN_METER_INSTALL_ROOT\" > {install_log}\n"
f"touch {marker}\n"
)
install_script.chmod(0o755)
(seed / "version.txt").write_text("one\n")
git("add", ".", cwd=seed)
git(
"-c", "user.name=Token Meter Test",
"-c", "user.email=test@example.invalid",
"commit", "-m", "initial", cwd=seed,
)
git("remote", "add", "origin", str(remote), cwd=seed)
git("push", "--set-upstream", "origin", "main", cwd=seed)
git("clone", "--branch", "main", str(remote), str(source), cwd=workspace)

(seed / "version.txt").write_text("two\n")
git("add", "version.txt", cwd=seed)
git(
"-c", "user.name=Token Meter Test",
"-c", "user.email=test@example.invalid",
"commit", "-m", "update", cwd=seed,
)
git("push", "origin", "main", cwd=seed)

status_path = workspace / "status.json"
result = subprocess.run(
[str(ROOT / "scripts" / "update-linux"), str(source), str(status_path)],
cwd=ROOT, check=False, capture_output=True, text=True,
env={**os.environ, "HOME": str(workspace)},
)

self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(marker.is_file())
self.assertEqual(install_log.read_text().strip(), str(ROOT))
self.assertEqual((source / "version.txt").read_text(), "two\n")
self.assertEqual(json.loads(status_path.read_text())["phase"], "complete")


if __name__ == "__main__":
unittest.main()
15 changes: 15 additions & 0 deletions tests/test_meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10479,6 +10479,21 @@ def test_update_helper_requires_clean_fast_forward_then_reuses_installer(self):
self.assertNotIn("reset --hard", script)
self.assertNotIn("sudo ", script)

def test_update_entrypoint_dispatches_linux_to_linux_helper(self):
root = Path(__file__).resolve().parents[1]
entrypoint = (root / "scripts" / "update").read_text()
linux_helper = (root / "scripts" / "update-linux").read_text()
manifest = (root / "runtime-manifest.txt").read_text()

self.assertIn('case "$(uname -s)" in', entrypoint)
self.assertIn('Linux)\n exec "$ENTRYPOINT_ROOT/scripts/update-linux" "$@"', entrypoint)
self.assertIn('Darwin)', entrypoint)
self.assertIn('supported platforms are macOS and Linux.', entrypoint)
self.assertIn('[[ "$(uname -s)" == "Linux" ]]', linux_helper)
self.assertIn(' ! -f "$SOURCE_ROOT/scripts/install-linux"', linux_helper)
self.assertIn('"$SOURCE_ROOT/scripts/install-linux"', linux_helper)
self.assertIn('required scripts/update-linux', manifest)

def test_windows_update_helper_requires_main_and_supports_failed_retry(self):
root = Path(__file__).resolve().parents[1]
script = (root / "scripts" / "update-windows.ps1").read_text()
Expand Down