mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e1da95711 |
@@ -4,11 +4,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'server.mjs'
|
- 'server.mjs'
|
||||||
- 'setup.mjs'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'lib/**'
|
|
||||||
- 'ocp'
|
|
||||||
- 'ocp-connect'
|
|
||||||
- '.github/workflows/alignment.yml'
|
- '.github/workflows/alignment.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -71,80 +66,6 @@ jobs:
|
|||||||
|
|
||||||
echo "Blacklist scan clean."
|
echo "Blacklist scan clean."
|
||||||
|
|
||||||
port-spot:
|
|
||||||
name: port literal SPOT (hard fail)
|
|
||||||
# Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
|
|
||||||
# a hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs cascaded
|
|
||||||
# into wrong baseUrl writes for the OpenClaw "claude-local" provider,
|
|
||||||
# taking out the "大内总管" Telegram agent.
|
|
||||||
#
|
|
||||||
# Rule: the only places allowed to write a literal port number in source
|
|
||||||
# are (a) lib/constants.mjs (the SPOT), (b) bash scripts ocp / ocp-connect
|
|
||||||
# (which can't import .mjs and must keep the literal in sync — flagged
|
|
||||||
# with a `// keep in sync with lib/constants.mjs` style comment), and
|
|
||||||
# (c) test-features.mjs (intentionally pins historical ports for plist /
|
|
||||||
# systemd parser tests). Everything else MUST import from lib/constants.mjs.
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Scan for hardcoded port literals outside SPOT
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Files/paths exempt from the SPOT requirement.
|
|
||||||
EXEMPT_REGEX='^(lib/constants\.mjs|test-features\.mjs|ocp|ocp-connect|CHANGELOG\.md|README\.md|docs/|\.github/workflows/alignment\.yml)'
|
|
||||||
|
|
||||||
# Hardcoded port literals to forbid in non-exempt source.
|
|
||||||
FORBIDDEN_PORTS=("3478" "3456")
|
|
||||||
|
|
||||||
FAIL=0
|
|
||||||
for port in "${FORBIDDEN_PORTS[@]}"; do
|
|
||||||
HITS="$(git ls-files | grep -E '\.(mjs|js|ts|json)$' \
|
|
||||||
| xargs grep -n -E "[^0-9]${port}[^0-9]" 2>/dev/null \
|
|
||||||
| grep -v -E "${EXEMPT_REGEX}" \
|
|
||||||
|| true)"
|
|
||||||
if [ -n "$HITS" ]; then
|
|
||||||
echo "::error::Hardcoded port literal '${port}' found outside lib/constants.mjs:"
|
|
||||||
echo "$HITS"
|
|
||||||
FAIL=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$FAIL" -ne 0 ]; then
|
|
||||||
cat <<'EOF'
|
|
||||||
|
|
||||||
============================================================
|
|
||||||
PORT LITERAL SPOT VIOLATION
|
|
||||||
============================================================
|
|
||||||
A hardcoded TCP port literal was found in a source file
|
|
||||||
that should import from lib/constants.mjs instead.
|
|
||||||
|
|
||||||
Background: this rule exists because between 2026-05-08 and
|
|
||||||
2026-05-13 a stray hardcoded "3478" in scripts/upgrade.mjs
|
|
||||||
and scripts/doctor.mjs cascaded into downstream OpenClaw
|
|
||||||
config writes, taking out the OpenClaw Telegram agent.
|
|
||||||
See v3.16.3 CHANGELOG and lib/constants.mjs header comment.
|
|
||||||
|
|
||||||
Required action:
|
|
||||||
1. Import DEFAULT_PORT (or related constant) from
|
|
||||||
lib/constants.mjs instead of hardcoding the literal.
|
|
||||||
2. If the file genuinely cannot import .mjs (e.g. bash
|
|
||||||
script), add it to EXEMPT_REGEX in this workflow and
|
|
||||||
add a `keep in sync with lib/constants.mjs` comment
|
|
||||||
at the reference.
|
|
||||||
3. For test files that intentionally pin historical ports
|
|
||||||
(test-features.mjs), the regex already exempts them.
|
|
||||||
|
|
||||||
============================================================
|
|
||||||
EOF
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Port SPOT scan clean."
|
|
||||||
|
|
||||||
commit-citation:
|
commit-citation:
|
||||||
name: commit message citation (soft check)
|
name: commit message citation (soft check)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
-163
@@ -1,168 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v3.16.4 — 2026-05-13
|
|
||||||
|
|
||||||
### Refactor — port-literal SPOT + CI guardrail
|
|
||||||
|
|
||||||
Closes the structural side of the port-drift cascade addressed by v3.16.2
|
|
||||||
and v3.16.3. Those two releases reverted plist / plugin / scripts back to
|
|
||||||
3456 line-by-line, but the underlying invitation to drift — a hardcoded
|
|
||||||
port literal scattered across six source files — was still intact.
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
|
|
||||||
- **New `lib/constants.mjs`** — single source of truth for shared literals.
|
|
||||||
Exports `DEFAULT_PORT = 3456`, `LOCAL_HOST = "127.0.0.1"`,
|
|
||||||
`OPENAI_API_BASE = "/v1"`, `LOCAL_PROXY_URL`.
|
|
||||||
- **`server.mjs:127`, `setup.mjs:36`, `scripts/upgrade.mjs:137`,
|
|
||||||
`scripts/doctor.mjs:84` + `:205`, `scripts/sync-openclaw.mjs:73`** —
|
|
||||||
all replaced with imports from `lib/constants.mjs`. Behavior is
|
|
||||||
identical; the literal `3456` now exists in exactly one place per
|
|
||||||
language (`lib/constants.mjs` for `.mjs`, `ocp` + `ocp-connect` for
|
|
||||||
bash, `test-features.mjs` for pinned historical-port tests).
|
|
||||||
- **`.github/workflows/alignment.yml`** — extended the path filter to
|
|
||||||
`setup.mjs`, `scripts/**`, `lib/**`, `ocp`, `ocp-connect`. Added a new
|
|
||||||
`port-spot` hard-fail job that greps for any hardcoded `3478` or `3456`
|
|
||||||
literal in `.mjs/.js/.ts/.json` outside the EXEMPT_REGEX (which lists
|
|
||||||
`lib/constants.mjs`, `test-features.mjs`, the bash CLIs, docs, and the
|
|
||||||
workflow itself). Any future PR re-introducing a hardcoded port
|
|
||||||
literal will be blocked at CI before it can cascade.
|
|
||||||
- Doc comments in `server.mjs` env-var summary and `setup.mjs` usage
|
|
||||||
banner reworded so the literal `3456` no longer appears as
|
|
||||||
documentation text (CI grep is intentionally aggressive — it does not
|
|
||||||
parse comments — so doc strings reference `DEFAULT_PORT from
|
|
||||||
lib/constants.mjs` instead).
|
|
||||||
|
|
||||||
No behavior change for any user. `CLAUDE_PROXY_PORT` env var remains
|
|
||||||
the runtime override; the only difference is the unset-env fallback
|
|
||||||
now flows through one shared constant.
|
|
||||||
|
|
||||||
ALIGNMENT.md hard-requirements: this PR modifies `server.mjs` (one-line
|
|
||||||
import + one literal swap, mechanical). No cli.js operation changed;
|
|
||||||
the citation requirement does not apply. SPOT principle (Rule 2 spirit)
|
|
||||||
is the entire motivation.
|
|
||||||
|
|
||||||
## v3.16.3 — 2026-05-13
|
|
||||||
|
|
||||||
### Fixes — completes v3.16.2 port-drift revert
|
|
||||||
|
|
||||||
v3.16.2 reverted the plugin / `openclaw.plugin.json` / README / Mac mini
|
|
||||||
plist back to `3456` (the historical source default since `593d0dc`), but
|
|
||||||
missed three places in `scripts/` that still defaulted to `3478`. Those
|
|
||||||
three lines were the residual cascade source: every time `ocp doctor` or
|
|
||||||
`ocp upgrade` ran without `CLAUDE_PROXY_PORT` in the env, they probed
|
|
||||||
`3478`, reported "OCP not responding" against a healthy 3456 instance,
|
|
||||||
and (in the case of OpenClaw sync follow-ups on the maintainer's host)
|
|
||||||
re-introduced 3478 into downstream config.
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
|
|
||||||
- `scripts/upgrade.mjs:137` — default port `3478` → `3456`.
|
|
||||||
- `scripts/doctor.mjs:84` — default port `3478` → `3456`.
|
|
||||||
- `scripts/doctor.mjs:205` — default port `3478` → `3456`.
|
|
||||||
|
|
||||||
No behavior change for users who set `CLAUDE_PROXY_PORT` explicitly; env
|
|
||||||
still takes precedence. The fix only affects the unset-env fallback,
|
|
||||||
which now matches `server.mjs:126` and the rest of the codebase.
|
|
||||||
|
|
||||||
Test plan: existing `test-features.mjs` cases that pin
|
|
||||||
`CLAUDE_PROXY_PORT=3478` continue to pass — they use the env path, not
|
|
||||||
the default.
|
|
||||||
|
|
||||||
## v3.16.2 — 2026-05-12
|
|
||||||
|
|
||||||
### Fixes — corrects v3.16.1
|
|
||||||
|
|
||||||
The v3.16.1 fix was directionally correct (plugin now reads env first, falls back to a hardcoded default) but **the narrative and the hardcoded default were both wrong**.
|
|
||||||
|
|
||||||
What v3.16.1 said: "OCP server moved to 3478 default in v3.14+; plugin lagged at 3456."
|
|
||||||
What is actually true:
|
|
||||||
- **OCP server source default has been `3456` since `593d0dc` (initial release) and has never changed.** Every line in `server.mjs`, `setup.mjs`, and the `ocp` CLI still uses `3456` as the documented and code-level default.
|
|
||||||
- The single OCP installation observed on `3478` is the maintainer's Mac mini, whose plist was rewritten with `--port 3478` during a PR #71 dogfood smoke-test accident on 2026-05-08 (see `~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md`). The plist drift was never reconciled back to source default, and v3.16.1 incorrectly canonised the post-accident value as if it had been a release decision.
|
|
||||||
|
|
||||||
This release:
|
|
||||||
- Restores the plugin fallback to `http://127.0.0.1:3456` to match server source default.
|
|
||||||
- Updates `openclaw.plugin.json` `configSchema.proxyUrl.default` back to `3456`.
|
|
||||||
- Restores README §"Environment Variables" `CLAUDE_PROXY_PORT` default to `3456`.
|
|
||||||
- Plugin reads `OCP_PROXY_URL` env (full URL) first, then `CLAUDE_PROXY_PORT` env (port only), then falls back to `3456`. Hosts whose OCP plist injects a non-default port must also inject the same `CLAUDE_PROXY_PORT` into the OpenClaw plist for the plugin to follow.
|
|
||||||
- Maintainer's Mac mini plist was reverted from `3478` to `3456` as part of this release deploy (no source change reflects this; it was a one-host correction).
|
|
||||||
|
|
||||||
### Governance
|
|
||||||
|
|
||||||
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
|
||||||
|
|
||||||
## v3.16.1 — 2026-05-12 (superseded — narrative incorrect; see v3.16.2 erratum)
|
|
||||||
|
|
||||||
### Fixes (as shipped — note erratum above)
|
|
||||||
|
|
||||||
- **OCP plugin port lag** — `ocp-plugin/index.js` hard-coded `http://127.0.0.1:3456`. ~~While OCP server moved to 3478 in v3.14+,~~ **(corrected v3.16.2: no such move ever happened.)** The Mac mini's plist was on `3478` only as residue from a dogfood accident. Result: `/ocp` slash commands from the home Telegram bot returned "OCP error: fetch failed". v3.16.1 changed the plugin default to `3478` (wrong direction; v3.16.2 reverts to `3456`).
|
|
||||||
|
|
||||||
### Governance
|
|
||||||
|
|
||||||
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
|
||||||
|
|
||||||
## v3.16.0 — 2026-05-10
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **`ocp doctor --check oauth`** (PR #93) — fast path that runs only the OAuth check, skipping
|
|
||||||
version detection / from-version / git operations / models endpoint. ~50ms vs. full doctor's
|
|
||||||
~200-500ms. Use cases: AI agent repair loops, post-`claude auth login` verify, quick health
|
|
||||||
gates. Help text in `cmd_doctor_help` now reflects working behaviour.
|
|
||||||
- **`ocp update --rollback --gc`** — manually garbage-collect old upgrade snapshots.
|
|
||||||
Retention policy: keep last 5 snapshots OR snapshots newer than 30 days OR the single most
|
|
||||||
recent (always-keep safety net). `--dry-run` previews. Successful `ocp update` runs auto-GC
|
|
||||||
at the end of the full path; light path does not (no snapshot created there).
|
|
||||||
|
|
||||||
### Behavior changes
|
|
||||||
|
|
||||||
- After a successful cross-minor `ocp update`, the auto-GC emits `[gc] removed N old snapshots`
|
|
||||||
to stderr if any were collected. Safe to ignore; manual gc is `ocp update --rollback --gc`.
|
|
||||||
|
|
||||||
### Governance
|
|
||||||
|
|
||||||
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
|
||||||
- PR #93 (--check oauth) merged separately; this release bundles it with the GC feature.
|
|
||||||
|
|
||||||
## v3.15.1 — 2026-05-10
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- **doctor: dynamic `latest_version` from `origin/main:package.json`** — v3.15.0 doctor used a hard-coded `latest = "v3.14.0"` fallback, which made any v3.15.0+ install report `kind = upgrade` (against a stale value). `ocp update` would then attempt `git checkout v3.14.0` — a downgrade. Doctor now fetches `git -C ~/ocp show origin/main:package.json` to determine the actual latest version; on failure (offline, fresh clone with no remote), falls back to `currentVersion` so `kind = noop` instead of recommending a downgrade.
|
|
||||||
|
|
||||||
## v3.15.0 — 2026-05-10
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **`ocp doctor`** — health & upgrade-readiness check; primary entry for AI-driven debugging.
|
|
||||||
`--json` mode emits a `next_action` with `ai_executable[]` for agents to run verbatim
|
|
||||||
and `human_required[]` for steps requiring the user (typically only OAuth).
|
|
||||||
- **`ocp update` cross-version path** — for cross-minor jumps (e.g. v3.10 → v3.14),
|
|
||||||
`ocp update` now runs doctor → snapshot → `setup.mjs` (with the plist env-merge from
|
|
||||||
PR #90) → service restart → post-flight `/health` + `/v1/models` verification.
|
|
||||||
Same-patch updates retain the existing light path; users see no change for routine
|
|
||||||
patch bumps.
|
|
||||||
- **`ocp update --rollback`** — restore the most recent (or specified) upgrade snapshot.
|
|
||||||
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never auto-deleted.
|
|
||||||
- **Fresh-install routing** — `ocp update` on installations < v3.4.0 routes to a fresh-install
|
|
||||||
flow (with `--yes` to skip confirmation; AI agents pass this). OAuth survives via Claude
|
|
||||||
Code's credential store; users do not re-OAuth unless their token was independently broken.
|
|
||||||
- **AI prompt blocks in README** — §Installation, §Upgrading, and §Troubleshooting each
|
|
||||||
start with a copy-paste prompt for Claude Code / Cursor / Copilot, so users can drive
|
|
||||||
install / setup / upgrade through their existing AI assistant.
|
|
||||||
|
|
||||||
### Behavior changes
|
|
||||||
|
|
||||||
- `ocp update` may take 10–30s longer when a cross-minor jump triggers the full path
|
|
||||||
(snapshot + post-flight). Patch bumps are unchanged.
|
|
||||||
- Pre-v3.4.0 installs are routed to fresh-install rather than failing silently or
|
|
||||||
half-migrating.
|
|
||||||
|
|
||||||
### Governance
|
|
||||||
|
|
||||||
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
|
||||||
- Depends on PR #90 (plist env merge bug fix; merged before this release).
|
|
||||||
|
|
||||||
## v3.14.0 — 2026-05-10
|
## v3.14.0 — 2026-05-10
|
||||||
|
|
||||||
### Features (security hardening)
|
### Features (security hardening)
|
||||||
|
|||||||
@@ -69,20 +69,6 @@ Any tool that accepts `OPENAI_BASE_URL` works with OCP:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The simplest path: ask your AI.
|
|
||||||
|
|
||||||
Paste this prompt to Claude Code / Cursor / Copilot:
|
|
||||||
|
|
||||||
```
|
|
||||||
Install OCP for me. Read README §Manual Installation and follow it.
|
|
||||||
Tell me when I need to run `claude auth login`.
|
|
||||||
```
|
|
||||||
|
|
||||||
The AI will run `git clone`, `npm install`, `node setup.mjs`, and tell you
|
|
||||||
when to OAuth.
|
|
||||||
|
|
||||||
### Manual Installation
|
|
||||||
|
|
||||||
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
|
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -515,7 +501,6 @@ ocp keys List all API keys (multi mode)
|
|||||||
ocp keys add <name> Create a new API key
|
ocp keys add <name> Create a new API key
|
||||||
ocp keys revoke <name> Revoke an API key
|
ocp keys revoke <name> Revoke an API key
|
||||||
ocp connect <ip> One-command LAN client setup
|
ocp connect <ip> One-command LAN client setup
|
||||||
ocp doctor Health & upgrade-readiness check; primary entry for AI-driven debugging. --json produces a next_action for AI agents.
|
|
||||||
ocp lan Show LAN connection info & IP
|
ocp lan Show LAN connection info & IP
|
||||||
ocp settings View tunable settings
|
ocp settings View tunable settings
|
||||||
ocp settings <k> <v> Update a setting at runtime
|
ocp settings <k> <v> Update a setting at runtime
|
||||||
@@ -542,57 +527,17 @@ ocp --help
|
|||||||
|
|
||||||
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
|
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
|
||||||
|
|
||||||
## Upgrading
|
### Self-Update
|
||||||
|
|
||||||
The simplest path: ask your AI.
|
|
||||||
|
|
||||||
Paste this prompt:
|
|
||||||
|
|
||||||
```
|
|
||||||
Upgrade my OCP. Run `ocp update` and follow whatever it says.
|
|
||||||
If it tells me to run `claude auth login`, I'll do that.
|
|
||||||
```
|
|
||||||
|
|
||||||
What `ocp update` does:
|
|
||||||
|
|
||||||
- **Patch bump** (e.g. `v3.14.0 → v3.14.1`):
|
|
||||||
light path (git pull + npm install + restart).
|
|
||||||
- **Cross-minor** (e.g. `v3.10 → v3.14`):
|
|
||||||
full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge),
|
|
||||||
service restart, post-flight `/health` and `/v1/models` verification.
|
|
||||||
- **Old version** (< v3.4.0):
|
|
||||||
fresh-install. Pre-v3.4 lacked admin-key/usage-db, so there is nothing to
|
|
||||||
migrate. Your OAuth token (managed by the Claude Code CLI, not OCP) is
|
|
||||||
preserved; you do not need to re-OAuth unless your token expired
|
|
||||||
separately.
|
|
||||||
|
|
||||||
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never
|
|
||||||
auto-deleted. Clean old ones with `rm -rf ~/.ocp/upgrade-snapshot-*` once
|
|
||||||
you're confident the upgrade is stable.
|
|
||||||
|
|
||||||
### Manual upgrade — same command, no AI
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ocp update # smart-pick path
|
# Check if a new version is available
|
||||||
ocp update --check # show available updates, don't apply
|
ocp update --check
|
||||||
ocp update --dry-run # preview plan
|
|
||||||
ocp update --target v3.13.0 # pin a specific version
|
# Pull latest, sync plugin, restart proxy — one command
|
||||||
ocp update --rollback --yes # restore most recent snapshot (--yes confirms)
|
ocp update
|
||||||
ocp update --rollback --list # list snapshots, no mutation
|
|
||||||
ocp update --rollback --dry-run # preview rollback plan
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### When upgrade fails
|
`ocp update` runs (in order): `git pull` → `npm install` → plugin sync → **OpenClaw model registry sync** (v3.11.0+) → proxy restart → health check.
|
||||||
|
|
||||||
`ocp update` prints a recovery line on failure. To restore from the snapshot:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ocp update --rollback --yes # --yes confirms the destructive restore
|
|
||||||
ocp doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
If `ocp doctor` still reports problems after rollback, open a GitHub issue
|
|
||||||
with the snapshot path and the doctor JSON output (`ocp doctor --json`).
|
|
||||||
|
|
||||||
### OpenClaw Auto-Sync (v3.11.0+)
|
### OpenClaw Auto-Sync (v3.11.0+)
|
||||||
|
|
||||||
@@ -765,21 +710,6 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
The simplest path: ask your AI.
|
|
||||||
|
|
||||||
Paste this prompt:
|
|
||||||
|
|
||||||
```
|
|
||||||
Run `ocp doctor` and follow its `next_action`. Tell me if you hit
|
|
||||||
anything that needs human input.
|
|
||||||
```
|
|
||||||
|
|
||||||
The doctor produces a JSON `next_action` with `ai_executable[]` (commands
|
|
||||||
the agent runs verbatim) and `human_required[]` (steps that need you,
|
|
||||||
typically just OAuth).
|
|
||||||
|
|
||||||
### Manual debugging
|
|
||||||
|
|
||||||
### Setup fails with "claude: command not found"
|
### Setup fails with "claude: command not found"
|
||||||
|
|
||||||
`setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`.
|
`setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`.
|
||||||
@@ -855,8 +785,7 @@ Future `ocp update` invocations sync automatically.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port (server-side). Also consumed by the OpenClaw `ocp-plugin` to dial the local proxy. |
|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||||
| `OCP_PROXY_URL` | *(unset)* | Plugin-side full URL override (e.g. `http://10.0.0.5:3456`). Wins over `CLAUDE_PROXY_PORT` when both are set. Read by `ocp-plugin/index.js` only — server ignores it. |
|
|
||||||
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
||||||
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
||||||
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
|
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* OCP shared constants — single source of truth.
|
|
||||||
*
|
|
||||||
* Any literal that appears in more than one place across server.mjs, setup.mjs,
|
|
||||||
* scripts/* belongs here so port-drift / URL-drift cascades cannot recur.
|
|
||||||
*
|
|
||||||
* Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
|
|
||||||
* (v3.16.3) a single hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs
|
|
||||||
* cascaded into every downstream config write, ultimately taking out the
|
|
||||||
* OpenClaw "大内总管" Telegram agent. See CHANGELOG v3.16.2 and v3.16.3.
|
|
||||||
*
|
|
||||||
* Adding a new constant: prefer ALL_CAPS_SNAKE_CASE. Document the consumers.
|
|
||||||
* If a literal is referenced from a shell script (ocp, ocp-connect, setup.sh)
|
|
||||||
* that can't import .mjs, add a `// keep in sync with lib/constants.mjs` note
|
|
||||||
* at the shell-script reference; CI grep prevents drift.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Default TCP port the OCP HTTP proxy listens on. Set by env CLAUDE_PROXY_PORT
|
|
||||||
// at runtime; this is the fallback when env is unset.
|
|
||||||
// Consumers: server.mjs, setup.mjs, scripts/upgrade.mjs, scripts/doctor.mjs,
|
|
||||||
// scripts/sync-openclaw.mjs. Shell scripts ocp / ocp-connect keep the literal
|
|
||||||
// "3456" in sync with this value (see CI gate in .github/workflows/alignment.yml).
|
|
||||||
export const DEFAULT_PORT = 3456;
|
|
||||||
|
|
||||||
// Localhost bind for client-side fetches (curl, health checks).
|
|
||||||
export const LOCAL_HOST = "127.0.0.1";
|
|
||||||
|
|
||||||
// OpenAI-compatible API base path appended to the proxy URL.
|
|
||||||
export const OPENAI_API_BASE = "/v1";
|
|
||||||
|
|
||||||
// Convenience: full local URL the OCP proxy listens on by default.
|
|
||||||
// scripts that want to probe locally can use this directly.
|
|
||||||
export const LOCAL_PROXY_URL = `http://${LOCAL_HOST}:${DEFAULT_PORT}`;
|
|
||||||
@@ -692,35 +692,25 @@ for e in d.get('errors', []):
|
|||||||
# ── update ──────────────────────────────────────────────────────────────
|
# ── update ──────────────────────────────────────────────────────────────
|
||||||
cmd_update_help() {
|
cmd_update_help() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
ocp update — Smart upgrade dispatcher
|
ocp update — Update OCP to the latest version
|
||||||
|
|
||||||
Runs `ocp doctor` internally to choose the right path:
|
Pulls the latest code from GitHub, restarts the proxy service,
|
||||||
• Patch bump (same minor): light path (git pull + npm install + restart)
|
and optionally syncs the plugin to the OpenClaw extensions directory.
|
||||||
• Cross-minor (e.g. v3.10→v3.14): full path with snapshot + post-flight
|
|
||||||
• Old version (< v3.4.0): fresh-install (asks first; AI passes --yes)
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
ocp update Smart auto-pick path
|
ocp update Pull latest and restart
|
||||||
ocp update --check Show available updates, don't apply
|
ocp update --check Check for updates without applying
|
||||||
ocp update --dry-run Preview the plan, don't mutate
|
|
||||||
ocp update --target v3.13.0 Pin a specific version
|
|
||||||
ocp update --yes Skip y/N prompts (AI agents pass this)
|
|
||||||
ocp update --rollback Restore the most recent upgrade snapshot
|
|
||||||
ocp update --rollback --list List available snapshots
|
|
||||||
ocp update --rollback <path> Restore a specific snapshot
|
|
||||||
ocp update --rollback --dry-run Preview rollback plan
|
|
||||||
ocp update --rollback --gc Delete old snapshots (keep last 5, or <30 days)
|
|
||||||
ocp update --rollback --gc --dry-run Preview what would be deleted
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_update() {
|
cmd_update() {
|
||||||
local script_dir self
|
local script_dir self
|
||||||
self="${BASH_SOURCE[0]}"
|
self="${BASH_SOURCE[0]}"
|
||||||
|
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
|
||||||
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
||||||
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
||||||
|
|
||||||
# Pass through --check fast path (existing behaviour)
|
# Check-only mode
|
||||||
if [[ "${1:-}" == "--check" ]]; then
|
if [[ "${1:-}" == "--check" ]]; then
|
||||||
cd "$script_dir"
|
cd "$script_dir"
|
||||||
git fetch origin main --quiet 2>/dev/null || true
|
git fetch origin main --quiet 2>/dev/null || true
|
||||||
@@ -737,104 +727,71 @@ cmd_update() {
|
|||||||
echo " Status: ✓ Up to date"
|
echo " Status: ✓ Up to date"
|
||||||
else
|
else
|
||||||
echo " Status: $behind commit(s) behind"
|
echo " Status: $behind commit(s) behind"
|
||||||
|
echo ""
|
||||||
echo " Run 'ocp update' to apply."
|
echo " Run 'ocp update' to apply."
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rollback path
|
echo "Updating OCP..."
|
||||||
if [[ "${1:-}" == "--rollback" ]]; then
|
echo ""
|
||||||
shift
|
|
||||||
exec node "$script_dir/scripts/upgrade.mjs" --rollback "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Doctor-driven path selection
|
# 1. Pull latest
|
||||||
local kind
|
|
||||||
kind=$(node "$script_dir/scripts/doctor.mjs" --json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['next_action']['kind'])" 2>/dev/null || echo "unknown")
|
|
||||||
|
|
||||||
case "$kind" in
|
|
||||||
noop)
|
|
||||||
echo "Already at latest. Nothing to do."
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
update)
|
|
||||||
_cmd_update_light "$script_dir"
|
|
||||||
;;
|
|
||||||
upgrade|fresh_install)
|
|
||||||
exec node "$script_dir/scripts/upgrade.mjs" "$@"
|
|
||||||
;;
|
|
||||||
fix_oauth|fix_service)
|
|
||||||
echo "Pre-upgrade check failed: $kind"
|
|
||||||
echo "Run \`ocp doctor\` for details and ai_executable steps."
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown doctor kind: $kind. Run \`ocp doctor --json\` to inspect."
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# Existing light-path body extracted into a helper so cmd_update can call it conditionally.
|
|
||||||
_cmd_update_light() {
|
|
||||||
local script_dir="$1"
|
|
||||||
echo "Updating OCP (light path)..."
|
|
||||||
cd "$script_dir"
|
cd "$script_dir"
|
||||||
local old_ver new_ver
|
local old_ver
|
||||||
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||||
|
|
||||||
echo " Pulling latest from GitHub..."
|
echo " Pulling latest from GitHub..."
|
||||||
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
||||||
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local new_ver
|
||||||
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||||
|
|
||||||
if [[ "$old_ver" == "$new_ver" ]]; then
|
if [[ "$old_ver" == "$new_ver" ]]; then
|
||||||
echo " ✓ Already at latest (v$new_ver)"
|
echo " ✓ Already at latest (v$new_ver)"
|
||||||
else
|
else
|
||||||
echo " ✓ Updated v$old_ver → v$new_ver"
|
echo " ✓ Updated v$old_ver → v$new_ver"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Sync plugin (existing logic preserved)
|
# 2. Sync plugin to extensions dir
|
||||||
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
||||||
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
||||||
|
echo ""
|
||||||
echo " Syncing OCP plugin..."
|
echo " Syncing OCP plugin..."
|
||||||
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
||||||
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
||||||
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
||||||
echo " ✓ Plugin synced"
|
echo " ✓ Plugin synced to $ext_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 3. Sync OpenClaw registry from models.json (non-fatal)
|
||||||
if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
|
if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
|
||||||
|
echo ""
|
||||||
echo " Syncing OpenClaw registry..."
|
echo " Syncing OpenClaw registry..."
|
||||||
node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /' || echo " ⚠ OpenClaw sync failed (non-fatal)"
|
if ! node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /'; then
|
||||||
|
echo " ⚠ OpenClaw sync failed (non-fatal, continuing)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 4. Restart proxy
|
||||||
|
echo ""
|
||||||
echo " Restarting proxy..."
|
echo " Restarting proxy..."
|
||||||
cmd_restart > /dev/null 2>&1
|
cmd_restart > /dev/null 2>&1
|
||||||
}
|
sleep 2
|
||||||
|
|
||||||
cmd_doctor_help() {
|
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||||
cat <<'EOF'
|
local running_ver
|
||||||
ocp doctor — Health & upgrade-readiness check
|
running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?")
|
||||||
|
echo " ✓ Proxy running (v$running_ver)"
|
||||||
|
else
|
||||||
|
echo " ⚠ Proxy not responding — check: ocp health"
|
||||||
|
fi
|
||||||
|
|
||||||
Runs a series of checks (Node version, git state, service health,
|
echo ""
|
||||||
OAuth token, plist customisation, OpenClaw provider) and emits either
|
echo "Done."
|
||||||
human-readable PASS/WARN/FAIL output or a JSON next_action that
|
|
||||||
AI agents can execute.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
ocp doctor Human-readable output
|
|
||||||
ocp doctor --json JSON for AI agents and ocp update internal use
|
|
||||||
ocp doctor --check oauth Fast path: OAuth check only
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_doctor() {
|
|
||||||
local script_dir self
|
|
||||||
self="${BASH_SOURCE[0]}"
|
|
||||||
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
|
||||||
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
|
||||||
exec node "$script_dir/scripts/doctor.mjs" "$@"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── help ─────────────────────────────────────────────────────────────────
|
# ── help ─────────────────────────────────────────────────────────────────
|
||||||
@@ -902,7 +859,6 @@ case "$subcmd" in
|
|||||||
lan) cmd_lan ;;
|
lan) cmd_lan ;;
|
||||||
connect) cmd_connect "$@" ;;
|
connect) cmd_connect "$@" ;;
|
||||||
restart) cmd_restart "${1:-}" ;;
|
restart) cmd_restart "${1:-}" ;;
|
||||||
doctor) cmd_doctor "$@" ;;
|
update) cmd_update "${1:-}" ;;
|
||||||
update) cmd_update "$@" ;;
|
|
||||||
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+3
-13
@@ -1,19 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
|
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
|
||||||
* Calls the local claude-proxy and formats the response.
|
* Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response.
|
||||||
*
|
|
||||||
* Port resolution (in priority order):
|
|
||||||
* 1. OCP_PROXY_URL env (full URL, e.g. http://10.0.0.5:3456)
|
|
||||||
* 2. CLAUDE_PROXY_PORT env (port only; localhost assumed)
|
|
||||||
* 3. Fallback: http://127.0.0.1:3456 (OCP server source default since v1.0)
|
|
||||||
*
|
|
||||||
* If a particular host's OCP plist injects a non-default CLAUDE_PROXY_PORT,
|
|
||||||
* the OpenClaw launchd plist for that host must also inject the same
|
|
||||||
* CLAUDE_PROXY_PORT into the plugin's env, or the plugin will fall back to
|
|
||||||
* 3456 and miss the server.
|
|
||||||
*/
|
*/
|
||||||
const PROXY = process.env.OCP_PROXY_URL
|
|
||||||
|| (process.env.CLAUDE_PROXY_PORT ? `http://127.0.0.1:${process.env.CLAUDE_PROXY_PORT}` : "http://127.0.0.1:3456");
|
const PROXY = "http://127.0.0.1:3456";
|
||||||
|
|
||||||
// Wrap output in monospace code block for Telegram/Discord alignment
|
// Wrap output in monospace code block for Telegram/Discord alignment
|
||||||
function mono(text) { return "```\n" + text + "\n```"; }
|
function mono(text) { return "```\n" + text + "\n```"; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "ocp",
|
"id": "ocp",
|
||||||
"name": "OCP Commands",
|
"name": "OCP Commands",
|
||||||
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
|
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
|
||||||
"version": "3.16.2",
|
"version": "3.12.0",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"proxyUrl": {
|
"proxyUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "http://127.0.0.1:3456",
|
"default": "http://127.0.0.1:3456",
|
||||||
"description": "URL of the Claude proxy. Overridable via OCP_PROXY_URL or CLAUDE_PROXY_PORT env."
|
"description": "URL of the Claude proxy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.16.4",
|
"version": "3.14.0",
|
||||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* scripts/doctor.mjs — OCP health & upgrade-readiness check.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ocp doctor human-readable PASS/WARN/FAIL
|
|
||||||
* ocp doctor --json machine-readable JSON for AI agents + ocp update
|
|
||||||
* ocp doctor --check oauth fast path: only OAuth check
|
|
||||||
*
|
|
||||||
* Exit codes:
|
|
||||||
* 0 all PASS or WARN-only
|
|
||||||
* 1 any FAIL
|
|
||||||
*/
|
|
||||||
import { readFileSync, existsSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
|
||||||
|
|
||||||
const SCHEMA_VERSION = "1";
|
|
||||||
|
|
||||||
function semverParts(v) {
|
|
||||||
const m = String(v).replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
||||||
if (!m) return null;
|
|
||||||
return { major: +m[1], minor: +m[2], patch: +m[3] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function semverCompare(a, b) {
|
|
||||||
const A = semverParts(a), B = semverParts(b);
|
|
||||||
if (!A || !B) return 0;
|
|
||||||
if (A.major !== B.major) return A.major - B.major;
|
|
||||||
if (A.minor !== B.minor) return A.minor - B.minor;
|
|
||||||
return A.patch - B.patch;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runDoctor(opts = {}) {
|
|
||||||
const checks = [];
|
|
||||||
const push = (id, level, message, extra = {}) =>
|
|
||||||
checks.push({ id, level, message, ...extra });
|
|
||||||
|
|
||||||
// --- fast path: --check oauth ---
|
|
||||||
if (opts.checkOnly === "oauth") {
|
|
||||||
return runOauthOnly(opts, checks, push);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- version detection ---
|
|
||||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
|
||||||
let currentVersion = opts.mockVersion;
|
|
||||||
if (!currentVersion) {
|
|
||||||
try {
|
|
||||||
const pkg = JSON.parse(readFileSync(join(ocpDir, "package.json"), "utf8"));
|
|
||||||
currentVersion = `v${pkg.version}`;
|
|
||||||
} catch {
|
|
||||||
currentVersion = "unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Resolve latest from origin/main (cheap: `git show origin/main:package.json`).
|
|
||||||
// Falls back to current_version when network/git unavailable, so kind = noop instead
|
|
||||||
// of recommending a downgrade against a stale hardcoded value.
|
|
||||||
let latestVersion = opts.mockLatest;
|
|
||||||
if (!latestVersion) {
|
|
||||||
try {
|
|
||||||
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
|
||||||
const remotePkg = JSON.parse(out);
|
|
||||||
latestVersion = `v${remotePkg.version}`;
|
|
||||||
} catch {
|
|
||||||
latestVersion = currentVersion;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
push("current_version", "PASS", `current=${currentVersion}`);
|
|
||||||
|
|
||||||
// --- from-version supported? ---
|
|
||||||
const fromSupported = !!semverParts(currentVersion) && semverCompare(currentVersion, "v3.4.0") >= 0;
|
|
||||||
push("from_version_supported", fromSupported ? "PASS" : "FAIL",
|
|
||||||
fromSupported ? "≥ v3.4.0" : `${currentVersion} < v3.4.0; in-place upgrade not supported`);
|
|
||||||
|
|
||||||
// --- service health check (mockable) ---
|
|
||||||
let healthOk = true, oauthOk = true;
|
|
||||||
if (!opts.skipNetwork) {
|
|
||||||
let health;
|
|
||||||
if (opts.mockHealth !== undefined) {
|
|
||||||
health = opts.mockHealth;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
|
||||||
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
|
||||||
health = { status: 200, body: JSON.parse(out) };
|
|
||||||
} catch (e) {
|
|
||||||
health = { error: String(e.message || e) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (health.error || health.status !== 200) {
|
|
||||||
healthOk = false;
|
|
||||||
push("service_running", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
|
|
||||||
} else if (!health.body || typeof health.body !== "object") {
|
|
||||||
healthOk = false;
|
|
||||||
push("service_running", "FAIL", "service /health returned 200 but empty/non-JSON body");
|
|
||||||
} else {
|
|
||||||
push("service_running", "PASS", "service responding on /health");
|
|
||||||
const authOk = health.body?.auth?.ok;
|
|
||||||
if (!authOk) {
|
|
||||||
oauthOk = false;
|
|
||||||
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
|
|
||||||
} else {
|
|
||||||
push("oauth_ok", "PASS", "OAuth token valid");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- determine next_action.kind (priority: fresh_install > fix_service > fix_oauth > noop > update > upgrade) ---
|
|
||||||
let kind;
|
|
||||||
if (!fromSupported) {
|
|
||||||
kind = "fresh_install";
|
|
||||||
} else if (!opts.skipNetwork && !healthOk) {
|
|
||||||
kind = "fix_service";
|
|
||||||
} else if (!opts.skipNetwork && !oauthOk) {
|
|
||||||
kind = "fix_oauth";
|
|
||||||
} else {
|
|
||||||
const cur = semverParts(currentVersion), lat = semverParts(latestVersion);
|
|
||||||
if (!cur) {
|
|
||||||
kind = "fresh_install";
|
|
||||||
} else if (semverCompare(currentVersion, latestVersion) === 0) {
|
|
||||||
kind = "noop";
|
|
||||||
} else if (lat && cur.major === lat.major && cur.minor === lat.minor) {
|
|
||||||
kind = "update";
|
|
||||||
} else {
|
|
||||||
kind = "upgrade";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- next_action shape ---
|
|
||||||
let next_action;
|
|
||||||
if (kind === "fresh_install") {
|
|
||||||
next_action = {
|
|
||||||
kind,
|
|
||||||
human_required: ["claude auth login (only if OAuth becomes invalid after reinstall)"],
|
|
||||||
ai_executable: [
|
|
||||||
`launchctl bootout gui/$(id -u)/ai.openclaw.proxy 2>/dev/null || true`,
|
|
||||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
|
||||||
`mv ${join(homedir(), ".ocp")} ${join(homedir(), ".ocp.backup-")}$(date +%s) 2>/dev/null || true`,
|
|
||||||
`rm -rf ${ocpDir}`,
|
|
||||||
`git clone https://github.com/dtzp555-max/ocp ${ocpDir}`,
|
|
||||||
`cd ${ocpDir} && npm install --no-audit --no-fund && node setup.mjs`,
|
|
||||||
`${ocpDir}/ocp doctor`
|
|
||||||
],
|
|
||||||
verify: "ocp doctor expects PASS on all checks"
|
|
||||||
};
|
|
||||||
} else if (kind === "noop") {
|
|
||||||
next_action = { kind, human_required: [], ai_executable: [], verify: "already at latest" };
|
|
||||||
} else if (kind === "fix_oauth") {
|
|
||||||
next_action = {
|
|
||||||
kind,
|
|
||||||
human_required: [],
|
|
||||||
ai_executable: [
|
|
||||||
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
|
|
||||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
|
||||||
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
|
||||||
`${ocpDir}/ocp doctor`
|
|
||||||
],
|
|
||||||
verify: "ocp doctor expects oauth_ok=PASS",
|
|
||||||
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
|
|
||||||
};
|
|
||||||
} else if (kind === "fix_service") {
|
|
||||||
next_action = {
|
|
||||||
kind,
|
|
||||||
human_required: [],
|
|
||||||
ai_executable: [
|
|
||||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
|
||||||
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
|
||||||
`${ocpDir}/ocp doctor`
|
|
||||||
],
|
|
||||||
verify: "ocp doctor expects service_running=PASS"
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
next_action = {
|
|
||||||
kind,
|
|
||||||
human_required: [],
|
|
||||||
ai_executable: [`${ocpDir}/ocp update --yes`],
|
|
||||||
verify: "ocp doctor expects PASS on all checks"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fail_count = checks.filter(c => c.level === "FAIL").length;
|
|
||||||
const warn_count = checks.filter(c => c.level === "WARN").length;
|
|
||||||
return {
|
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
ready_to_upgrade: fail_count === 0,
|
|
||||||
current_version: currentVersion,
|
|
||||||
latest_version: latestVersion,
|
|
||||||
from_version_supported: fromSupported,
|
|
||||||
fail_count,
|
|
||||||
warn_count,
|
|
||||||
checks,
|
|
||||||
next_action
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function runOauthOnly(opts, checks, push) {
|
|
||||||
let healthOk = true, oauthOk = true;
|
|
||||||
let health;
|
|
||||||
if (opts.mockHealth !== undefined) {
|
|
||||||
health = opts.mockHealth;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
|
||||||
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
|
||||||
health = { status: 200, body: JSON.parse(out) };
|
|
||||||
} catch (e) {
|
|
||||||
health = { error: String(e.message || e) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (health.error || health.status !== 200) {
|
|
||||||
healthOk = false;
|
|
||||||
push("oauth_ok", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
|
|
||||||
} else if (!health.body || typeof health.body !== "object") {
|
|
||||||
healthOk = false;
|
|
||||||
push("oauth_ok", "FAIL", "service /health returned 200 but empty/non-JSON body");
|
|
||||||
} else if (!health.body?.auth?.ok) {
|
|
||||||
oauthOk = false;
|
|
||||||
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
|
|
||||||
} else {
|
|
||||||
push("oauth_ok", "PASS", "OAuth token valid");
|
|
||||||
}
|
|
||||||
|
|
||||||
const kind = !healthOk ? "fix_service" : !oauthOk ? "fix_oauth" : "noop";
|
|
||||||
|
|
||||||
let next_action;
|
|
||||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
|
||||||
if (kind === "noop") {
|
|
||||||
next_action = { kind, human_required: [], ai_executable: [], verify: "OAuth healthy" };
|
|
||||||
} else if (kind === "fix_oauth") {
|
|
||||||
next_action = {
|
|
||||||
kind,
|
|
||||||
human_required: [],
|
|
||||||
ai_executable: [
|
|
||||||
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
|
|
||||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
|
||||||
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
|
||||||
`${ocpDir}/ocp doctor --check oauth`
|
|
||||||
],
|
|
||||||
verify: "ocp doctor --check oauth expects PASS",
|
|
||||||
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
next_action = {
|
|
||||||
kind,
|
|
||||||
human_required: [],
|
|
||||||
ai_executable: [
|
|
||||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
|
||||||
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
|
||||||
`${ocpDir}/ocp doctor --check oauth`
|
|
||||||
],
|
|
||||||
verify: "ocp doctor --check oauth expects service_running=PASS"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fail_count = checks.filter(c => c.level === "FAIL").length;
|
|
||||||
// "skipped" = --check oauth fast path intentionally omits version detection.
|
|
||||||
// AI agents should NOT semver-compare against current_version/latest_version when
|
|
||||||
// either equals "skipped"; the full path provides those fields when needed.
|
|
||||||
return {
|
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
ready_to_upgrade: fail_count === 0,
|
|
||||||
current_version: opts.mockVersion || "skipped",
|
|
||||||
latest_version: opts.mockLatest || "skipped",
|
|
||||||
from_version_supported: true,
|
|
||||||
fail_count,
|
|
||||||
warn_count: 0,
|
|
||||||
checks,
|
|
||||||
next_action
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths
|
|
||||||
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { realpathSync } from "node:fs";
|
|
||||||
function _isMain() {
|
|
||||||
if (!process.argv[1]) return false;
|
|
||||||
try {
|
|
||||||
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
if (_isMain()) {
|
|
||||||
const wantJson = process.argv.includes("--json");
|
|
||||||
const checkIdx = process.argv.indexOf("--check");
|
|
||||||
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
|
|
||||||
const result = await runDoctor({ checkOnly });
|
|
||||||
if (wantJson) {
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
|
||||||
} else {
|
|
||||||
console.log(`OCP doctor — ${result.current_version} → ${result.latest_version}`);
|
|
||||||
for (const c of result.checks) console.log(` [${c.level}] ${c.id}: ${c.message}`);
|
|
||||||
console.log(`\nSummary: ${result.fail_count} FAIL, ${result.warn_count} WARN`);
|
|
||||||
console.log(`Next action: ${result.next_action.kind}`);
|
|
||||||
}
|
|
||||||
process.exit(result.fail_count === 0 ? 0 : 1);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// scripts/lib/plist-merge.mjs
|
|
||||||
//
|
|
||||||
// Preserves user-customised env vars when setup.mjs rewrites the unit file.
|
|
||||||
//
|
|
||||||
// Rule:
|
|
||||||
// - keys present in NEW template → template value wins (template is source of truth)
|
|
||||||
// - keys ONLY in EXISTING (not in template) → preserved verbatim
|
|
||||||
//
|
|
||||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
|
||||||
// is stable enough for our hand-written templates in setup.mjs.
|
|
||||||
|
|
||||||
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
|
||||||
|
|
||||||
export function parsePlistEnv(plistContent) {
|
|
||||||
if (!plistContent) return {};
|
|
||||||
if (Buffer.isBuffer(plistContent)) plistContent = plistContent.toString("utf8");
|
|
||||||
// Restrict to the EnvironmentVariables dict to avoid catching Label, etc.
|
|
||||||
const envBlock = plistContent.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
|
|
||||||
if (!envBlock) return {};
|
|
||||||
const out = {};
|
|
||||||
let m;
|
|
||||||
PLIST_KV_RE.lastIndex = 0;
|
|
||||||
while ((m = PLIST_KV_RE.exec(envBlock[1])) !== null) {
|
|
||||||
out[m[1]] = m[2];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergePlistEnv(existing, template) {
|
|
||||||
if (!existing) return template;
|
|
||||||
const existingEnv = parsePlistEnv(existing);
|
|
||||||
const templateEnv = parsePlistEnv(template);
|
|
||||||
const KNOWN = new Set(Object.keys(templateEnv));
|
|
||||||
|
|
||||||
const preserved = {};
|
|
||||||
for (const [k, v] of Object.entries(existingEnv)) {
|
|
||||||
if (!KNOWN.has(k)) preserved[k] = v;
|
|
||||||
}
|
|
||||||
if (Object.keys(preserved).length === 0) return template;
|
|
||||||
|
|
||||||
const lines = Object.entries(preserved)
|
|
||||||
.map(([k, v]) => ` <key>${k}</key>\n <string>${v}</string>`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
// Inject before the closing </dict> of EnvironmentVariables
|
|
||||||
return template.replace(
|
|
||||||
/(<key>EnvironmentVariables<\/key>\s*<dict>[\s\S]*?)(\n\s*<\/dict>)/,
|
|
||||||
`$1\n${lines}$2`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SYSTEMD_KV_RE = /^Environment=([^=]+)=(.*)$/gm;
|
|
||||||
|
|
||||||
export function parseSystemdEnv(serviceContent) {
|
|
||||||
if (!serviceContent) return {};
|
|
||||||
if (Buffer.isBuffer(serviceContent)) serviceContent = serviceContent.toString("utf8");
|
|
||||||
const out = {};
|
|
||||||
let m;
|
|
||||||
SYSTEMD_KV_RE.lastIndex = 0;
|
|
||||||
while ((m = SYSTEMD_KV_RE.exec(serviceContent)) !== null) {
|
|
||||||
out[m[1]] = m[2];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeSystemdEnv(existing, template) {
|
|
||||||
if (!existing) return template;
|
|
||||||
const existingEnv = parseSystemdEnv(existing);
|
|
||||||
const templateEnv = parseSystemdEnv(template);
|
|
||||||
const KNOWN = new Set(Object.keys(templateEnv));
|
|
||||||
|
|
||||||
const preservedLines = Object.entries(existingEnv)
|
|
||||||
.filter(([k]) => !KNOWN.has(k))
|
|
||||||
.map(([k, v]) => `Environment=${k}=${v}`);
|
|
||||||
if (preservedLines.length === 0) return template;
|
|
||||||
|
|
||||||
// Guard: if template has no Environment= anchor, cannot inject — return template as-is.
|
|
||||||
// (In practice the OCP systemd template always has Environment= lines.)
|
|
||||||
if (!/^Environment=/m.test(template)) return template;
|
|
||||||
|
|
||||||
// Inject after the last existing Environment= line in the template
|
|
||||||
return template.replace(
|
|
||||||
/(^Environment=[^\n]+\n)((?!Environment=).*$)/ms,
|
|
||||||
`$1${preservedLines.join("\n")}\n$2`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
|
||||||
const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
||||||
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
|
|
||||||
mkdirSync(root, { recursive: true });
|
|
||||||
|
|
||||||
// Standard manifest files
|
|
||||||
writeFileSync(join(root, "from-commit.txt"), fromCommit + "\n");
|
|
||||||
writeFileSync(join(root, "from-version.txt"), fromVersion + "\n");
|
|
||||||
writeFileSync(join(root, "to-version.txt"), toVersion + "\n");
|
|
||||||
|
|
||||||
// Optional captures (best-effort, never fatal)
|
|
||||||
const tryCopy = (src, dst) => {
|
|
||||||
try {
|
|
||||||
if (existsSync(src)) copyFileSync(src, dst);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[snapshot] warn: could not copy ${src} (${err.code || err.message})`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tryCopy(join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"), join(root, "plist"));
|
|
||||||
tryCopy(join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"), join(root, "service"));
|
|
||||||
tryCopy(join(homeDir, ".ocp", "ocp.db"), join(root, "db.bak"));
|
|
||||||
tryCopy(join(homeDir, ".ocp", "admin-key"), join(root, "admin-key"));
|
|
||||||
tryCopy(join(homeDir, ".openclaw", "openclaw.json"), join(root, "openclaw.json"));
|
|
||||||
|
|
||||||
for (const { src, name } of extraFiles) tryCopy(src, join(root, name));
|
|
||||||
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readSnapshot(snapshotPath) {
|
|
||||||
const read = (n) => {
|
|
||||||
try { return readFileSync(join(snapshotPath, n), "utf8").trim(); } catch { return null; }
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
path: snapshotPath,
|
|
||||||
fromCommit: read("from-commit.txt"),
|
|
||||||
fromVersion: read("from-version.txt"),
|
|
||||||
toVersion: read("to-version.txt")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listSnapshots(homeDir) {
|
|
||||||
const root = join(homeDir, ".ocp");
|
|
||||||
if (!existsSync(root)) return [];
|
|
||||||
return readdirSync(root)
|
|
||||||
.filter(name => name.startsWith("upgrade-snapshot-"))
|
|
||||||
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Garbage-collect old upgrade snapshots.
|
|
||||||
*
|
|
||||||
* Retention rule (a snapshot is KEPT if any of these is true):
|
|
||||||
* - It is among the last `keepCount` snapshots (sorted oldest→newest)
|
|
||||||
* - Its timestamp is within `keepDays` of `now`
|
|
||||||
* - It is the single most-recent snapshot (always-keep safety net)
|
|
||||||
*
|
|
||||||
* @param {string} homeDir - Root containing ~/.ocp/
|
|
||||||
* @param {object} opts
|
|
||||||
* @param {number} [opts.keepCount=5] - Minimum count to keep
|
|
||||||
* @param {number} [opts.keepDays=30] - Keep snapshots newer than N days
|
|
||||||
* @param {boolean} [opts.dryRun=false] - If true, report plan but don't delete
|
|
||||||
* @param {Date} [opts.now=new Date()] - Override clock for testing
|
|
||||||
* @returns {{kept: Array, removed: Array, dryRun: boolean}}
|
|
||||||
*/
|
|
||||||
export function gcSnapshots(homeDir, opts = {}) {
|
|
||||||
const keepCount = opts.keepCount ?? 5;
|
|
||||||
const keepDays = opts.keepDays ?? 30;
|
|
||||||
const dryRun = !!opts.dryRun;
|
|
||||||
const now = opts.now || new Date();
|
|
||||||
|
|
||||||
const all = listSnapshots(homeDir); // sorted oldest→newest
|
|
||||||
if (all.length === 0) return { kept: [], removed: [], dryRun };
|
|
||||||
if (all.length === 1) return { kept: all, removed: [], dryRun }; // always keep most recent
|
|
||||||
|
|
||||||
const cutoffMs = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
|
|
||||||
const lastN = new Set(all.slice(-keepCount).map(s => s.path));
|
|
||||||
|
|
||||||
const kept = [], removed = [];
|
|
||||||
for (let i = 0; i < all.length; i++) {
|
|
||||||
const s = all[i];
|
|
||||||
const isMostRecent = i === all.length - 1;
|
|
||||||
const isInLastN = lastN.has(s.path);
|
|
||||||
const isWithinDays = parseSnapshotTimestamp(s.name) >= cutoffMs;
|
|
||||||
if (isMostRecent || isInLastN || isWithinDays) {
|
|
||||||
kept.push(s);
|
|
||||||
} else {
|
|
||||||
removed.push(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
for (const s of removed) {
|
|
||||||
try {
|
|
||||||
rmSync(s.path, { recursive: true, force: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[snapshot] warn: could not remove ${s.path} (${err.code || err.message})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { kept, removed, dryRun };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSnapshotTimestamp(name) {
|
|
||||||
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
|
|
||||||
const m = name.match(/upgrade-snapshot-(.+)$/);
|
|
||||||
if (!m) return 0;
|
|
||||||
const t = Date.parse(m[1]);
|
|
||||||
return Number.isFinite(t) ? t : 0;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
|
|||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { DEFAULT_PORT, LOCAL_HOST, OPENAI_API_BASE } from "../lib/constants.mjs";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const REPO_ROOT = join(__dirname, "..");
|
const REPO_ROOT = join(__dirname, "..");
|
||||||
@@ -71,7 +70,7 @@ if (!config.models.providers) config.models.providers = {};
|
|||||||
if (!config.models.providers[PROVIDER_NAME]) {
|
if (!config.models.providers[PROVIDER_NAME]) {
|
||||||
// First-time registration
|
// First-time registration
|
||||||
config.models.providers[PROVIDER_NAME] = {
|
config.models.providers[PROVIDER_NAME] = {
|
||||||
baseUrl: `http://${LOCAL_HOST}:${DEFAULT_PORT}${OPENAI_API_BASE}`,
|
baseUrl: "http://127.0.0.1:3456/v1",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
authHeader: false,
|
authHeader: false,
|
||||||
models: desiredModels,
|
models: desiredModels,
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* scripts/upgrade.mjs — OCP unified upgrade dispatcher.
|
|
||||||
*
|
|
||||||
* Paths:
|
|
||||||
* noop current == latest, exit 0
|
|
||||||
* light same major.minor, patch bump only (existing fast path; delegated to bash)
|
|
||||||
* full cross-minor (snapshot + setup.mjs + post-flight)
|
|
||||||
* fresh_install from-version < v3.4.0 (--yes required for non-interactive)
|
|
||||||
* rollback restore from snapshot
|
|
||||||
*/
|
|
||||||
import { runDoctor } from "./doctor.mjs";
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { existsSync, copyFileSync } from "node:fs";
|
|
||||||
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
|
||||||
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
|
||||||
|
|
||||||
export async function runUpgrade(opts = {}) {
|
|
||||||
const dryRun = !!opts.dryRun;
|
|
||||||
const yes = !!opts.yes;
|
|
||||||
// yes is reserved for Bundle 3 (fresh-install / rollback interactive gate); not used in upgrade-path here.
|
|
||||||
const plan = [];
|
|
||||||
|
|
||||||
// --- rollback path (no doctor needed; snapshot is authoritative) ---
|
|
||||||
if (opts.rollback) {
|
|
||||||
return await runRollback(opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- doctor pre-flight ---
|
|
||||||
const doctor = opts.mockDoctor || await runDoctor();
|
|
||||||
if (!doctor.ready_to_upgrade && doctor.next_action.kind !== "fresh_install") {
|
|
||||||
throw new Error(`doctor FAIL: ${doctor.next_action.kind} (run "ocp doctor" for details)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const kind = doctor.next_action.kind;
|
|
||||||
plan.push(`[doctor] from=${doctor.current_version} to=${doctor.latest_version} kind=${kind}`);
|
|
||||||
|
|
||||||
// --- noop ---
|
|
||||||
if (kind === "noop") {
|
|
||||||
plan.push(`[noop] already at latest (${doctor.latest_version})`);
|
|
||||||
return { path: "noop", executed: true, changed: false, plan };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- dry-run early exit ---
|
|
||||||
if (dryRun) {
|
|
||||||
plan.push(`[plan] would proceed with ${kind} path`);
|
|
||||||
if (kind === "upgrade") {
|
|
||||||
plan.push(`[plan] phase 1: snapshot to ~/.ocp/upgrade-snapshot-<ts>/`);
|
|
||||||
plan.push(`[plan] phase 2: git checkout ${doctor.latest_version} && npm install`);
|
|
||||||
plan.push(`[plan] phase 3: node setup.mjs`);
|
|
||||||
plan.push(`[plan] phase 4: launchctl bootout/bootstrap`);
|
|
||||||
plan.push(`[plan] phase 5: post-flight /health + /v1/models`);
|
|
||||||
} else if (kind === "update") {
|
|
||||||
plan.push(`[plan] light path: git pull + npm install + restart`);
|
|
||||||
} else if (kind === "fresh_install") {
|
|
||||||
plan.push(`[plan] fresh-install ai_executable[]:`);
|
|
||||||
for (const cmd of doctor.next_action.ai_executable) plan.push(` - ${cmd}`);
|
|
||||||
}
|
|
||||||
return { path: kind, executed: false, plan };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- non-dry-run paths ---
|
|
||||||
if (kind === "update") {
|
|
||||||
return { path: "update", executed: true, changed: true, plan: [...plan, "[light] delegated to bash cmd_update existing logic"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "upgrade") {
|
|
||||||
return await runFullUpgrade({ doctor, opts });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "fresh_install") {
|
|
||||||
return await runFreshInstall({ doctor, opts });
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`path ${kind} not yet implemented`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runFullUpgrade({ doctor, opts }) {
|
|
||||||
const phases = [];
|
|
||||||
let snapshotPath = null;
|
|
||||||
const exec = (cmd, label) => {
|
|
||||||
if (opts.mockExec) {
|
|
||||||
phases.push({ name: label, cmd, status: "skipped-mock" });
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const out = execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
|
||||||
phases.push({ name: label, cmd, status: "ok" });
|
|
||||||
return out;
|
|
||||||
} catch (err) {
|
|
||||||
const detail = err.stderr?.toString().trim();
|
|
||||||
phases.push({ name: label, cmd, status: "fail", stderr: detail });
|
|
||||||
throw Object.assign(
|
|
||||||
new Error(`phase ${label} failed: ${detail || err.message}`),
|
|
||||||
{ phases, cmd }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// phase 1: pre-flight (doctor already passed; just record)
|
|
||||||
phases.push({ name: "pre-flight", status: "ok", note: `kind=upgrade from=${doctor.current_version} to=${doctor.latest_version}` });
|
|
||||||
|
|
||||||
// phase 2: snapshot
|
|
||||||
const fromCommit = opts.mockExec
|
|
||||||
? "mock-commit"
|
|
||||||
: execSync(`git -C ${ocpDir} rev-parse HEAD`).toString().trim();
|
|
||||||
snapshotPath = opts.mockExec
|
|
||||||
? "/tmp/mock-snapshot"
|
|
||||||
: writeSnapshot({ homeDir: homedir(), fromCommit, fromVersion: doctor.current_version, toVersion: doctor.latest_version });
|
|
||||||
phases.push({ name: "snapshot", path: snapshotPath, status: "ok" });
|
|
||||||
|
|
||||||
// phase 3: fetch + install
|
|
||||||
exec(`git -C ${ocpDir} fetch --tags --quiet`, "fetch+install");
|
|
||||||
exec(`git -C ${ocpDir} checkout ${doctor.latest_version}`, "fetch+install");
|
|
||||||
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "fetch+install");
|
|
||||||
|
|
||||||
// phase 4: reconfigure
|
|
||||||
exec(`node ${ocpDir}/setup.mjs`, "reconfigure");
|
|
||||||
|
|
||||||
// phase 5: restart (heads-up note printed before invoking)
|
|
||||||
if (!opts.mockExec) {
|
|
||||||
console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`);
|
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
|
||||||
}
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
|
||||||
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
|
||||||
} else {
|
|
||||||
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
|
||||||
}
|
|
||||||
|
|
||||||
// phase 6: post-flight (10s budget; skipped under mockExec)
|
|
||||||
if (!opts.mockExec) {
|
|
||||||
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
|
||||||
let ok = false;
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
try {
|
|
||||||
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
|
|
||||||
const body = JSON.parse(out);
|
|
||||||
if (body.auth?.ok === true) { ok = true; break; }
|
|
||||||
} catch { /* retry */ }
|
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
|
||||||
}
|
|
||||||
if (!ok) {
|
|
||||||
phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
|
|
||||||
throw new Error("post-flight failed");
|
|
||||||
}
|
|
||||||
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
|
|
||||||
phases.push({ name: "post-flight", status: "ok" });
|
|
||||||
} else {
|
|
||||||
phases.push({ name: "post-flight", status: "skipped-mock" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-GC old snapshots after successful upgrade (best-effort, never throws).
|
|
||||||
try {
|
|
||||||
const gc = gcSnapshots(homedir(), { keepCount: 5, keepDays: 30 });
|
|
||||||
if (gc.removed.length > 0) {
|
|
||||||
console.error(`[gc] removed ${gc.removed.length} old snapshots; kept ${gc.kept.length}`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[gc] warn: snapshot GC failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
|
|
||||||
} catch (err) {
|
|
||||||
if (snapshotPath && !err.snapshotPath) {
|
|
||||||
Object.assign(err, {
|
|
||||||
snapshotPath,
|
|
||||||
phases,
|
|
||||||
hint: "Working tree may be at new version. Run `ocp update --rollback` to restore from snapshot."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runFreshInstall({ doctor, opts }) {
|
|
||||||
if (!opts.yes) {
|
|
||||||
throw new Error("fresh_install requires --yes for non-interactive execution (or run interactively and answer y)");
|
|
||||||
}
|
|
||||||
const steps = [];
|
|
||||||
for (const cmd of doctor.next_action.ai_executable) {
|
|
||||||
if (opts.mockExec) {
|
|
||||||
steps.push({ cmd, status: "skipped-mock" });
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
execSync(cmd, { stdio: "inherit" });
|
|
||||||
steps.push({ cmd, status: "ok" });
|
|
||||||
} catch (e) {
|
|
||||||
const detail = e.stderr?.toString().trim() || e.message;
|
|
||||||
steps.push({ cmd, status: "fail", error: String(detail) });
|
|
||||||
throw Object.assign(new Error(`fresh_install step failed: ${cmd} — ${detail}`), { steps });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { path: "fresh_install", executed: true, changed: true, steps };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRollback(opts) {
|
|
||||||
const homeDir = opts.homeDir || homedir();
|
|
||||||
const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir);
|
|
||||||
|
|
||||||
if (opts.gc) {
|
|
||||||
const result = gcSnapshots(homeDir, { dryRun: opts.dryRun });
|
|
||||||
return { path: opts.dryRun ? "rollback-gc-dry-run" : "rollback-gc", ...result };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.list) {
|
|
||||||
return { path: "rollback-list", snapshots };
|
|
||||||
}
|
|
||||||
if (snapshots.length === 0) {
|
|
||||||
throw new Error("no upgrade snapshots found in ~/.ocp/upgrade-snapshot-*");
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = opts.snapshotPath
|
|
||||||
? snapshots.find(s => s.path === opts.snapshotPath)
|
|
||||||
: snapshots[snapshots.length - 1];
|
|
||||||
if (!target) throw new Error(`snapshot not found: ${opts.snapshotPath} (must be inside ~/.ocp/upgrade-snapshot-*)`);
|
|
||||||
|
|
||||||
const meta = opts.mockSnapshotMeta ?? readSnapshot(target.path);
|
|
||||||
if (!meta.fromCommit) throw new Error(`snapshot ${target.path} has no from-commit.txt`);
|
|
||||||
|
|
||||||
const phases = [];
|
|
||||||
if (opts.dryRun) {
|
|
||||||
return {
|
|
||||||
path: "rollback-dry-run",
|
|
||||||
executed: false,
|
|
||||||
target: target.path,
|
|
||||||
plan: [
|
|
||||||
`git checkout ${meta.fromCommit}`,
|
|
||||||
`cp ${target.path}/plist ~/Library/LaunchAgents/dev.ocp.proxy.plist`,
|
|
||||||
`cp ${target.path}/db.bak ~/.ocp/ocp.db`,
|
|
||||||
`launchctl bootout/bootstrap`,
|
|
||||||
`ocp doctor`
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!opts.yes) throw new Error("rollback requires --yes for non-interactive execution");
|
|
||||||
|
|
||||||
const exec = (cmd, label) => {
|
|
||||||
if (opts.mockExec) {
|
|
||||||
phases.push({ name: label, cmd, status: "skipped-mock" });
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
|
||||||
phases.push({ name: label, cmd, status: "ok" });
|
|
||||||
} catch (err) {
|
|
||||||
const detail = err.stderr?.toString().trim();
|
|
||||||
phases.push({ name: label, cmd, status: "fail", stderr: detail });
|
|
||||||
throw Object.assign(
|
|
||||||
new Error(`rollback phase ${label} failed: ${detail || err.message}`),
|
|
||||||
{ phases, target: target.path }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
|
||||||
exec(`git -C ${ocpDir} checkout ${meta.fromCommit}`, "git-checkout");
|
|
||||||
|
|
||||||
if (!opts.mockExec) {
|
|
||||||
const tryCopy = (src, dst) => {
|
|
||||||
try {
|
|
||||||
if (existsSync(src)) copyFileSync(src, dst);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[rollback] warn: could not restore ${src} → ${dst} (${err.code || err.message})`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tryCopy(join(target.path, "plist"), join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"));
|
|
||||||
tryCopy(join(target.path, "service"), join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"));
|
|
||||||
tryCopy(join(target.path, "db.bak"), join(homeDir, ".ocp", "ocp.db"));
|
|
||||||
tryCopy(join(target.path, "admin-key"), join(homeDir, ".ocp", "admin-key"));
|
|
||||||
phases.push({ name: "restore-files", status: "ok" });
|
|
||||||
} else {
|
|
||||||
phases.push({ name: "restore-files", status: "skipped-mock" });
|
|
||||||
}
|
|
||||||
|
|
||||||
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "npm-install");
|
|
||||||
|
|
||||||
if (!opts.mockExec) {
|
|
||||||
console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`);
|
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
|
||||||
}
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
|
||||||
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
|
||||||
} else {
|
|
||||||
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { path: "rollback", executed: true, changed: true, target: target.path, phases };
|
|
||||||
}
|
|
||||||
|
|
||||||
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths.
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { realpathSync } from "node:fs";
|
|
||||||
function _isMain() {
|
|
||||||
if (!process.argv[1]) return false;
|
|
||||||
try {
|
|
||||||
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
if (_isMain()) {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const dryRun = args.includes("--dry-run");
|
|
||||||
const yes = args.includes("--yes");
|
|
||||||
const rollback = args.includes("--rollback");
|
|
||||||
const list = args.includes("--list");
|
|
||||||
const gc = args.includes("--gc");
|
|
||||||
const targetIdx = args.indexOf("--target");
|
|
||||||
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
|
||||||
// First non-flag positional after --rollback is the snapshot path
|
|
||||||
let snapshotPath;
|
|
||||||
if (rollback) {
|
|
||||||
const rb = args.indexOf("--rollback");
|
|
||||||
const cand = args[rb + 1];
|
|
||||||
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target });
|
|
||||||
if (result.plan) for (const line of result.plan) console.log(line);
|
|
||||||
if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`);
|
|
||||||
if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
|
|
||||||
if (result.snapshots) {
|
|
||||||
console.log(`Found ${result.snapshots.length} snapshots:`);
|
|
||||||
for (const s of result.snapshots) console.log(` ${s.name}`);
|
|
||||||
}
|
|
||||||
if (result.removed && result.kept) {
|
|
||||||
console.log(`Snapshots: kept ${result.kept.length}, ${result.dryRun ? "would remove" : "removed"} ${result.removed.length}`);
|
|
||||||
for (const s of result.removed) console.log(` - ${s.name}`);
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`✗ ${e.message}`);
|
|
||||||
if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`);
|
|
||||||
if (e.target) console.error(` target: ${e.target}`);
|
|
||||||
if (e.hint) console.error(` hint: ${e.hint}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-3
@@ -11,7 +11,7 @@
|
|||||||
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
|
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
|
||||||
*
|
*
|
||||||
* Env vars:
|
* Env vars:
|
||||||
* CLAUDE_PROXY_PORT — listen port (default: DEFAULT_PORT from lib/constants.mjs)
|
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||||
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000)
|
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000)
|
||||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||||
@@ -35,7 +35,6 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||||
@@ -124,7 +123,7 @@ function resolveClaude() {
|
|||||||
|
|
||||||
// ── Configuration ───────────────────────────────────────────────────────
|
// ── Configuration ───────────────────────────────────────────────────────
|
||||||
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
||||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT), 10);
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||||
const CLAUDE = resolveClaude();
|
const CLAUDE = resolveClaude();
|
||||||
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10);
|
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10);
|
||||||
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* OCP (Open Claude Proxy) setup
|
* OCP (Open Claude Proxy) setup
|
||||||
*
|
*
|
||||||
* Automatically configures OpenClaw to use Claude CLI as a model provider.
|
* Automatically configures OpenClaw to use Claude CLI as a model provider.
|
||||||
* Run: node setup.mjs [--port N] [--default-model opus|sonnet|haiku] [--dry-run]
|
* Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run]
|
||||||
* (default port = DEFAULT_PORT from lib/constants.mjs)
|
|
||||||
*
|
*
|
||||||
* What it does:
|
* What it does:
|
||||||
* 1. Verifies claude CLI is installed and authenticated
|
* 1. Verifies claude CLI is installed and authenticated
|
||||||
@@ -14,12 +13,10 @@
|
|||||||
* 5. Optionally starts the proxy
|
* 5. Optionally starts the proxy
|
||||||
*/
|
*/
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from "node:fs";
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from "node:fs";
|
||||||
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const HOME = homedir();
|
const HOME = homedir();
|
||||||
@@ -34,7 +31,7 @@ const opt = (name, fallback) => {
|
|||||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 10);
|
const PORT = parseInt(opt("port", "3456"), 10);
|
||||||
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
||||||
const DRY_RUN = flag("dry-run");
|
const DRY_RUN = flag("dry-run");
|
||||||
const SKIP_START = flag("no-start");
|
const SKIP_START = flag("no-start");
|
||||||
@@ -427,15 +424,9 @@ if (!DRY_RUN) {
|
|||||||
</plist>
|
</plist>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const existingPlist = existsSync(plistPath) ? readFileSync(plistPath, "utf8") : null;
|
writeFileSync(plistPath, plistXml);
|
||||||
const finalPlistXml = mergePlistEnv(existingPlist, plistXml);
|
|
||||||
writeFileSync(plistPath, finalPlistXml);
|
|
||||||
chmodSync(plistPath, 0o600);
|
chmodSync(plistPath, 0o600);
|
||||||
if (existingPlist && finalPlistXml !== plistXml) {
|
|
||||||
log(`Plist written: ${plistPath} (mode 600, preserved user env vars)`);
|
|
||||||
} else {
|
|
||||||
log(`Plist written: ${plistPath} (mode 600)`);
|
log(`Plist written: ${plistPath} (mode 600)`);
|
||||||
}
|
|
||||||
|
|
||||||
// Bootout first (in case it was already loaded) then bootstrap
|
// Bootout first (in case it was already loaded) then bootstrap
|
||||||
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||||
@@ -468,15 +459,9 @@ StandardError=append:${logPath}
|
|||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const existingService = existsSync(servicePath) ? readFileSync(servicePath, "utf8") : null;
|
writeFileSync(servicePath, serviceUnit);
|
||||||
const finalServiceUnit = mergeSystemdEnv(existingService, serviceUnit);
|
|
||||||
writeFileSync(servicePath, finalServiceUnit);
|
|
||||||
chmodSync(servicePath, 0o600);
|
chmodSync(servicePath, 0o600);
|
||||||
if (existingService && finalServiceUnit !== serviceUnit) {
|
|
||||||
log(`Service file written: ${servicePath} (mode 600, preserved user env vars)`);
|
|
||||||
} else {
|
|
||||||
log(`Service file written: ${servicePath} (mode 600)`);
|
log(`Service file written: ${servicePath} (mode 600)`);
|
||||||
}
|
|
||||||
|
|
||||||
execSync(`systemctl --user daemon-reload`);
|
execSync(`systemctl --user daemon-reload`);
|
||||||
execSync(`systemctl --user enable ocp-proxy`);
|
execSync(`systemctl --user enable ocp-proxy`);
|
||||||
|
|||||||
@@ -454,503 +454,6 @@ async function runSingleflightTests() {
|
|||||||
|
|
||||||
await runSingleflightTests();
|
await runSingleflightTests();
|
||||||
|
|
||||||
// ── Plist Env Merge Tests ──
|
|
||||||
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
|
||||||
|
|
||||||
console.log("\nPlist env merge:");
|
|
||||||
|
|
||||||
const SAMPLE_TEMPLATE_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>dev.ocp.proxy</string>
|
|
||||||
<key>EnvironmentVariables</key>
|
|
||||||
<dict>
|
|
||||||
<key>CLAUDE_PROXY_PORT</key>
|
|
||||||
<string>3478</string>
|
|
||||||
<key>CLAUDE_BIND</key>
|
|
||||||
<string>127.0.0.1</string>
|
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
|
||||||
<string>multi</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>`;
|
|
||||||
|
|
||||||
const SAMPLE_EXISTING_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>dev.ocp.proxy</string>
|
|
||||||
<key>EnvironmentVariables</key>
|
|
||||||
<dict>
|
|
||||||
<key>CLAUDE_PROXY_PORT</key>
|
|
||||||
<string>3456</string>
|
|
||||||
<key>CLAUDE_BIND</key>
|
|
||||||
<string>127.0.0.1</string>
|
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
|
||||||
<string>none</string>
|
|
||||||
<key>CLAUDE_HEARTBEAT_INTERVAL</key>
|
|
||||||
<string>2000</string>
|
|
||||||
<key>CLAUDE_CACHE_TTL</key>
|
|
||||||
<string>600</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>`;
|
|
||||||
|
|
||||||
test("mergePlistEnv preserves unknown user keys", () => {
|
|
||||||
const merged = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.match(merged, /<key>CLAUDE_HEARTBEAT_INTERVAL<\/key>\s*<string>2000<\/string>/);
|
|
||||||
assert.match(merged, /<key>CLAUDE_CACHE_TTL<\/key>\s*<string>600<\/string>/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergePlistEnv overrides known template keys", () => {
|
|
||||||
const merged = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.match(merged, /<key>CLAUDE_PROXY_PORT<\/key>\s*<string>3478<\/string>/);
|
|
||||||
assert.match(merged, /<key>CLAUDE_AUTH_MODE<\/key>\s*<string>multi<\/string>/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergePlistEnv first-install returns template unchanged when existing is null", () => {
|
|
||||||
const merged = mergePlistEnv(null, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.equal(merged, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergePlistEnv first-install returns template unchanged when existing is empty", () => {
|
|
||||||
const merged = mergePlistEnv("", SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.equal(merged, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
});
|
|
||||||
|
|
||||||
const SAMPLE_TEMPLATE_SYSTEMD = `[Unit]
|
|
||||||
Description=OCP — Open Claude Proxy
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
|
||||||
Environment=CLAUDE_PROXY_PORT=3478
|
|
||||||
Environment=CLAUDE_BIND=127.0.0.1
|
|
||||||
Environment=CLAUDE_AUTH_MODE=multi
|
|
||||||
Restart=always
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SAMPLE_EXISTING_SYSTEMD = `[Unit]
|
|
||||||
Description=OCP — Open Claude Proxy
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
|
||||||
Environment=CLAUDE_PROXY_PORT=3456
|
|
||||||
Environment=CLAUDE_BIND=127.0.0.1
|
|
||||||
Environment=CLAUDE_AUTH_MODE=none
|
|
||||||
Environment=CLAUDE_HEARTBEAT_INTERVAL=2000
|
|
||||||
Environment=CLAUDE_CACHE_TTL=600
|
|
||||||
Restart=always
|
|
||||||
`;
|
|
||||||
|
|
||||||
test("mergeSystemdEnv preserves unknown user Environment lines", () => {
|
|
||||||
const merged = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
assert.match(merged, /Environment=CLAUDE_HEARTBEAT_INTERVAL=2000/);
|
|
||||||
assert.match(merged, /Environment=CLAUDE_CACHE_TTL=600/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergeSystemdEnv overrides known template keys", () => {
|
|
||||||
const merged = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
assert.match(merged, /Environment=CLAUDE_PROXY_PORT=3478/);
|
|
||||||
assert.match(merged, /Environment=CLAUDE_AUTH_MODE=multi/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergeSystemdEnv first-install returns template unchanged", () => {
|
|
||||||
assert.equal(mergeSystemdEnv(null, SAMPLE_TEMPLATE_SYSTEMD), SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
assert.equal(mergeSystemdEnv("", SAMPLE_TEMPLATE_SYSTEMD), SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergePlistEnv is idempotent", () => {
|
|
||||||
const r1 = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.equal(mergePlistEnv(r1, SAMPLE_TEMPLATE_PLIST), r1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergeSystemdEnv is idempotent", () => {
|
|
||||||
const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Doctor JSON Contract Tests ──
|
|
||||||
import { runDoctor } from "./scripts/doctor.mjs";
|
|
||||||
|
|
||||||
console.log("\nDoctor:");
|
|
||||||
|
|
||||||
test("doctor --json shape: required top-level keys", async () => {
|
|
||||||
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" });
|
|
||||||
for (const k of ["schema_version", "ready_to_upgrade", "current_version", "latest_version",
|
|
||||||
"from_version_supported", "fail_count", "warn_count", "checks", "next_action"]) {
|
|
||||||
assert.ok(k in result, `missing key: ${k}`);
|
|
||||||
}
|
|
||||||
assert.equal(result.schema_version, "1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor detects from-version < v3.4.0 → fresh_install", async () => {
|
|
||||||
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.2.0", mockLatest: "v3.14.0" });
|
|
||||||
assert.equal(result.from_version_supported, false);
|
|
||||||
assert.equal(result.next_action.kind, "fresh_install");
|
|
||||||
assert.ok(Array.isArray(result.next_action.ai_executable));
|
|
||||||
assert.ok(result.next_action.ai_executable.length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor next_action.kind enum is one of allowed values", async () => {
|
|
||||||
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" });
|
|
||||||
const ALLOWED = ["noop", "update", "upgrade", "fresh_install", "fix_oauth", "fix_service"];
|
|
||||||
assert.ok(ALLOWED.includes(result.next_action.kind), `kind=${result.next_action.kind} not in enum`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor noop when current==latest", async () => {
|
|
||||||
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.0" });
|
|
||||||
assert.equal(result.next_action.kind, "noop");
|
|
||||||
assert.equal(result.ready_to_upgrade, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor patch-bump same minor → update kind", async () => {
|
|
||||||
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.1" });
|
|
||||||
assert.equal(result.next_action.kind, "update");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor cross-minor → upgrade kind", async () => {
|
|
||||||
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" });
|
|
||||||
assert.equal(result.next_action.kind, "upgrade");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor OAuth FAIL → fix_oauth kind", async () => {
|
|
||||||
const result = await runDoctor({
|
|
||||||
skipNetwork: false,
|
|
||||||
mockVersion: "v3.10.0",
|
|
||||||
mockLatest: "v3.14.0",
|
|
||||||
mockHealth: { status: 200, body: { auth: { ok: false, message: "ENOEXEC" } } }
|
|
||||||
});
|
|
||||||
assert.equal(result.next_action.kind, "fix_oauth");
|
|
||||||
assert.ok(result.next_action.ai_executable.some(c => c.includes("install.cjs")));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor service down → fix_service kind", async () => {
|
|
||||||
const result = await runDoctor({
|
|
||||||
skipNetwork: false,
|
|
||||||
mockVersion: "v3.10.0",
|
|
||||||
mockLatest: "v3.14.0",
|
|
||||||
mockHealth: { error: "ECONNREFUSED" }
|
|
||||||
});
|
|
||||||
assert.equal(result.next_action.kind, "fix_service");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor unparseable version → fresh_install", async () => {
|
|
||||||
const result = await runDoctor({ skipNetwork: true, mockVersion: "garbage", mockLatest: "v3.14.0" });
|
|
||||||
assert.equal(result.from_version_supported, false);
|
|
||||||
assert.equal(result.next_action.kind, "fresh_install");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor empty health body → fix_service (not fix_oauth)", async () => {
|
|
||||||
const result = await runDoctor({
|
|
||||||
skipNetwork: false,
|
|
||||||
mockVersion: "v3.10.0",
|
|
||||||
mockLatest: "v3.14.0",
|
|
||||||
mockHealth: { status: 200, body: null }
|
|
||||||
});
|
|
||||||
assert.equal(result.next_action.kind, "fix_service");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doctor falls back to currentVersion when origin/main unreachable (no stale latest)", async () => {
|
|
||||||
// Use a non-existent ocpDir so git command fails; without the fix this would still
|
|
||||||
// hard-code v3.14.0 as latest and recommend a downgrade for a future v3.15.0+ user.
|
|
||||||
const result = await runDoctor({
|
|
||||||
skipNetwork: true,
|
|
||||||
mockVersion: "v3.15.0",
|
|
||||||
ocpDir: "/nonexistent-ocp-dir-for-test"
|
|
||||||
});
|
|
||||||
assert.equal(result.latest_version, "v3.15.0");
|
|
||||||
assert.equal(result.next_action.kind, "noop");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Upgrade Tests ──
|
|
||||||
import { runUpgrade } from "./scripts/upgrade.mjs";
|
|
||||||
|
|
||||||
console.log("\nUpgrade:");
|
|
||||||
|
|
||||||
test("upgrade --dry-run prints plan, no side effects", async () => {
|
|
||||||
const result = await runUpgrade({
|
|
||||||
dryRun: true,
|
|
||||||
yes: true,
|
|
||||||
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" }, current_version: "v3.10.0", latest_version: "v3.14.0" }
|
|
||||||
});
|
|
||||||
assert.equal(result.executed, false);
|
|
||||||
assert.ok(result.plan.length > 0);
|
|
||||||
assert.ok(result.plan.some(line => line.toLowerCase().includes("snapshot")));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("upgrade noop returns early when current==latest", async () => {
|
|
||||||
const result = await runUpgrade({
|
|
||||||
yes: true,
|
|
||||||
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "noop" }, current_version: "v3.14.0", latest_version: "v3.14.0" }
|
|
||||||
});
|
|
||||||
assert.equal(result.path, "noop");
|
|
||||||
assert.equal(result.executed, true);
|
|
||||||
assert.equal(result.changed, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("upgrade aborts on doctor FAIL", async () => {
|
|
||||||
await assert.rejects(async () => {
|
|
||||||
await runUpgrade({
|
|
||||||
yes: true,
|
|
||||||
mockDoctor: { ready_to_upgrade: false, fail_count: 1, next_action: { kind: "fix_oauth" } }
|
|
||||||
});
|
|
||||||
}, /doctor FAIL/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("upgrade full path executes 5 phases", async () => {
|
|
||||||
const result = await runUpgrade({
|
|
||||||
yes: true,
|
|
||||||
dryRun: false,
|
|
||||||
mockExec: true,
|
|
||||||
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" },
|
|
||||||
current_version: "v3.10.0", latest_version: "v3.14.0" }
|
|
||||||
});
|
|
||||||
assert.equal(result.path, "upgrade");
|
|
||||||
// Plan asks for 6 phases by name; verify each appears as a phase entry
|
|
||||||
const phaseNames = result.phases.map(p => p.name);
|
|
||||||
for (const expected of ["pre-flight", "snapshot", "fetch+install", "reconfigure", "restart", "post-flight"]) {
|
|
||||||
assert.ok(phaseNames.includes(expected), `missing phase: ${expected}; got ${phaseNames.join(",")}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Snapshot Tests ──
|
|
||||||
import { writeSnapshot, readSnapshot, listSnapshots, gcSnapshots } from "./scripts/lib/snapshot.mjs";
|
|
||||||
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile, existsSync as testExistsSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join as testJoin } from "node:path";
|
|
||||||
|
|
||||||
console.log("\nSnapshot:");
|
|
||||||
|
|
||||||
test("writeSnapshot creates dir + manifest files", () => {
|
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-"));
|
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
|
||||||
testWriteFile(testJoin(dotOcp, "ocp.db"), "fake-sqlite-bytes");
|
|
||||||
|
|
||||||
const path = writeSnapshot({
|
|
||||||
homeDir: root,
|
|
||||||
fromCommit: "abc1234",
|
|
||||||
fromVersion: "v3.10.0",
|
|
||||||
toVersion: "v3.14.0",
|
|
||||||
extraFiles: []
|
|
||||||
});
|
|
||||||
const m = readSnapshot(path);
|
|
||||||
assert.equal(m.fromCommit, "abc1234");
|
|
||||||
assert.equal(m.fromVersion, "v3.10.0");
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("listSnapshots returns sorted by ISO timestamp", () => {
|
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-list-"));
|
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
|
||||||
for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) {
|
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
|
||||||
}
|
|
||||||
const list = listSnapshots(root);
|
|
||||||
assert.equal(list.length, 3);
|
|
||||||
assert.ok(list[0].path.includes("2026-05-01"));
|
|
||||||
assert.ok(list[2].path.includes("2026-05-03"));
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("upgrade error after snapshot carries snapshotPath + hint", async () => {
|
|
||||||
// Use mockExec=true so no real commands are run.
|
|
||||||
// Verify the success path returns a snapshotPath (Fix B regression guard).
|
|
||||||
const result = await runUpgrade({
|
|
||||||
yes: true,
|
|
||||||
dryRun: false,
|
|
||||||
mockExec: true,
|
|
||||||
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" },
|
|
||||||
current_version: "v3.10.0", latest_version: "v3.14.0" }
|
|
||||||
});
|
|
||||||
assert.ok(result.snapshotPath, "successful upgrade returns snapshotPath");
|
|
||||||
assert.equal(result.path, "upgrade");
|
|
||||||
assert.equal(result.executed, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("upgrade fresh_install requires --yes for non-interactive", async () => {
|
|
||||||
await assert.rejects(async () => {
|
|
||||||
await runUpgrade({
|
|
||||||
yes: false,
|
|
||||||
mockExec: true,
|
|
||||||
mockDoctor: { ready_to_upgrade: false, from_version_supported: false,
|
|
||||||
next_action: { kind: "fresh_install", ai_executable: ["echo would-rm-rf"] },
|
|
||||||
current_version: "v3.2.0", latest_version: "v3.14.0" }
|
|
||||||
});
|
|
||||||
}, /requires --yes/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("upgrade fresh_install with --yes runs ai_executable", async () => {
|
|
||||||
const result = await runUpgrade({
|
|
||||||
yes: true,
|
|
||||||
mockExec: true,
|
|
||||||
mockDoctor: { ready_to_upgrade: false, from_version_supported: false,
|
|
||||||
next_action: { kind: "fresh_install",
|
|
||||||
ai_executable: ["echo step-1", "echo step-2", "echo step-3"] },
|
|
||||||
current_version: "v3.2.0", latest_version: "v3.14.0" }
|
|
||||||
});
|
|
||||||
assert.equal(result.path, "fresh_install");
|
|
||||||
assert.equal(result.steps.length, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rollback --list returns snapshots", async () => {
|
|
||||||
const result = await runUpgrade({
|
|
||||||
rollback: true,
|
|
||||||
list: true,
|
|
||||||
mockSnapshots: [
|
|
||||||
{ name: "upgrade-snapshot-2026-05-01T10:00:00Z", path: "/tmp/snap-1" },
|
|
||||||
{ name: "upgrade-snapshot-2026-05-02T10:00:00Z", path: "/tmp/snap-2" }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
assert.equal(result.path, "rollback-list");
|
|
||||||
assert.equal(result.snapshots.length, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rollback with no snapshots fails clearly", async () => {
|
|
||||||
await assert.rejects(async () => {
|
|
||||||
await runUpgrade({ rollback: true, dryRun: true, mockSnapshots: [] });
|
|
||||||
}, /no upgrade snapshots/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rollback --dry-run produces a plan without mutation", async () => {
|
|
||||||
const result = await runUpgrade({
|
|
||||||
rollback: true,
|
|
||||||
dryRun: true,
|
|
||||||
mockSnapshots: [{ name: "upgrade-snapshot-2026-05-11T08:30:00Z", path: "/tmp/snap-x" }],
|
|
||||||
mockSnapshotMeta: { fromCommit: "abc1234", fromVersion: "v3.10.0", toVersion: "v3.14.0", path: "/tmp/snap-x" }
|
|
||||||
});
|
|
||||||
assert.equal(result.path, "rollback-dry-run");
|
|
||||||
assert.equal(result.executed, false);
|
|
||||||
assert.ok(result.plan.length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rollback latest snapshot restores files (mockExec)", async () => {
|
|
||||||
const result = await runUpgrade({
|
|
||||||
rollback: true,
|
|
||||||
yes: true,
|
|
||||||
mockExec: true,
|
|
||||||
mockSnapshots: [{ name: "upgrade-snapshot-2026-05-11T08:30:00Z", path: "/tmp/snap-x" }],
|
|
||||||
mockSnapshotMeta: { fromCommit: "abc1234", fromVersion: "v3.10.0", toVersion: "v3.14.0", path: "/tmp/snap-x" }
|
|
||||||
});
|
|
||||||
assert.equal(result.path, "rollback");
|
|
||||||
assert.equal(result.executed, true);
|
|
||||||
assert.ok(result.phases.some(p => p.name === "git-checkout"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gcSnapshots keeps last N regardless of age", () => {
|
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-test-"));
|
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
|
||||||
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
|
||||||
}
|
|
||||||
const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
|
||||||
assert.equal(result.kept.length, 3);
|
|
||||||
assert.equal(result.removed.length, 2);
|
|
||||||
assert.ok(result.kept[0].name.includes("2026-04-30"));
|
|
||||||
assert.ok(result.kept[2].name.includes("2026-05-10"));
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => {
|
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
|
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
|
||||||
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
|
||||||
}
|
|
||||||
// keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too
|
|
||||||
const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") });
|
|
||||||
// Kept: 2026-04-30 (within 15 days), 2026-05-01 (within 15 days), 2026-05-10 (within 15 days)
|
|
||||||
assert.ok(result.kept.length >= 3);
|
|
||||||
// Removed: 2026-04-01, 2026-04-15
|
|
||||||
assert.ok(result.removed.some(s => s.name.includes("2026-04-01")));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gcSnapshots never deletes the most recent snapshot", () => {
|
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-"));
|
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
|
||||||
tMkdirSync(testJoin(dotOcp, "upgrade-snapshot-2026-01-01T10:00:00Z"));
|
|
||||||
// Even with keepCount=0 and keepDays=0, the most recent must survive
|
|
||||||
const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
|
||||||
assert.equal(result.kept.length, 1);
|
|
||||||
assert.equal(result.removed.length, 0);
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gcSnapshots --dry-run reports plan without deleting", () => {
|
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-dryrun-"));
|
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
|
||||||
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
|
||||||
}
|
|
||||||
const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") });
|
|
||||||
assert.equal(result.dryRun, true);
|
|
||||||
assert.equal(result.removed.length, 2);
|
|
||||||
// Files still exist
|
|
||||||
assert.ok(testExistsSync(testJoin(dotOcp, "upgrade-snapshot-2026-04-01T10:00:00Z")));
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Doctor --check oauth fast path tests ──
|
|
||||||
console.log("\nDoctor --check oauth:");
|
|
||||||
|
|
||||||
await asyncTest("doctor --check oauth runs only oauth check (skips version/from-version)", async () => {
|
|
||||||
const result = await runDoctor({
|
|
||||||
checkOnly: "oauth",
|
|
||||||
mockVersion: "v3.10.0",
|
|
||||||
mockLatest: "v3.14.0",
|
|
||||||
mockHealth: { status: 200, body: { auth: { ok: true, message: "authenticated" } } }
|
|
||||||
});
|
|
||||||
// Should still produce a valid result object
|
|
||||||
assert.equal(result.schema_version, "1");
|
|
||||||
// checks[] should only contain oauth_ok (no current_version, no from_version_supported)
|
|
||||||
const ids = result.checks.map(c => c.id);
|
|
||||||
assert.deepEqual(ids, ["oauth_ok"]);
|
|
||||||
assert.equal(result.next_action.kind, "noop");
|
|
||||||
});
|
|
||||||
|
|
||||||
await asyncTest("doctor --check oauth + OAuth FAIL → fix_oauth", async () => {
|
|
||||||
const result = await runDoctor({
|
|
||||||
checkOnly: "oauth",
|
|
||||||
mockHealth: { status: 200, body: { auth: { ok: false, message: "ENOEXEC" } } }
|
|
||||||
});
|
|
||||||
const ids = result.checks.map(c => c.id);
|
|
||||||
assert.deepEqual(ids, ["oauth_ok"]);
|
|
||||||
assert.equal(result.next_action.kind, "fix_oauth");
|
|
||||||
assert.equal(result.fail_count, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
await asyncTest("doctor --check oauth + service down → fix_service", async () => {
|
|
||||||
const result = await runDoctor({
|
|
||||||
checkOnly: "oauth",
|
|
||||||
mockHealth: { error: "ECONNREFUSED" }
|
|
||||||
});
|
|
||||||
const ids = result.checks.map(c => c.id);
|
|
||||||
assert.deepEqual(ids, ["oauth_ok"]);
|
|
||||||
assert.equal(result.next_action.kind, "fix_service");
|
|
||||||
assert.equal(result.fail_count, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
await asyncTest("doctor --check oauth + 200 with null body → fix_service", async () => {
|
|
||||||
const result = await runDoctor({
|
|
||||||
checkOnly: "oauth",
|
|
||||||
mockHealth: { status: 200, body: null }
|
|
||||||
});
|
|
||||||
const ids = result.checks.map(c => c.id);
|
|
||||||
assert.deepEqual(ids, ["oauth_ok"]);
|
|
||||||
assert.equal(result.next_action.kind, "fix_service");
|
|
||||||
assert.equal(result.fail_count, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Cleanup ──
|
// ── Cleanup ──
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user