mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e25160527 | ||
|
|
49c6d32e3b | ||
|
|
7766fa0868 | ||
|
|
70faeff067 | ||
|
|
7a69d72886 | ||
|
|
a8601a6d30 | ||
|
|
cd6ec2a212 |
@@ -4,6 +4,11 @@ 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:
|
||||||
@@ -66,6 +71,80 @@ 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
|
||||||
|
|||||||
+131
-1
@@ -1,6 +1,136 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v3.15.0 — 2026-XX-XX (release date filled at tag time)
|
## 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
|
### Features
|
||||||
|
|
||||||
|
|||||||
@@ -855,7 +855,8 @@ Future `ocp update` invocations sync automatically.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port (server-side). Also consumed by the OpenClaw `ocp-plugin` to dial the local proxy. |
|
||||||
|
| `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) |
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 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}`;
|
||||||
@@ -709,6 +709,8 @@ Usage:
|
|||||||
ocp update --rollback --list List available snapshots
|
ocp update --rollback --list List available snapshots
|
||||||
ocp update --rollback <path> Restore a specific snapshot
|
ocp update --rollback <path> Restore a specific snapshot
|
||||||
ocp update --rollback --dry-run Preview rollback plan
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -1,9 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* 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 at http://127.0.0.1:3456 and formats the response.
|
* Calls the local claude-proxy 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
|
||||||
const PROXY = "http://127.0.0.1:3456";
|
|| (process.env.CLAUDE_PROXY_PORT ? `http://127.0.0.1:${process.env.CLAUDE_PROXY_PORT}` : "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.12.0",
|
"version": "3.16.2",
|
||||||
"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"
|
"description": "URL of the Claude proxy. Overridable via OCP_PROXY_URL or CLAUDE_PROXY_PORT env."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.15.0",
|
"version": "3.16.4",
|
||||||
"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": {
|
||||||
|
|||||||
+101
-3
@@ -15,6 +15,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
const SCHEMA_VERSION = "1";
|
const SCHEMA_VERSION = "1";
|
||||||
|
|
||||||
@@ -37,6 +38,11 @@ export async function runDoctor(opts = {}) {
|
|||||||
const push = (id, level, message, extra = {}) =>
|
const push = (id, level, message, extra = {}) =>
|
||||||
checks.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 ---
|
// --- version detection ---
|
||||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
let currentVersion = opts.mockVersion;
|
let currentVersion = opts.mockVersion;
|
||||||
@@ -48,7 +54,19 @@ export async function runDoctor(opts = {}) {
|
|||||||
currentVersion = "unknown";
|
currentVersion = "unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const latestVersion = opts.mockLatest || "v3.14.0";
|
// 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}`);
|
push("current_version", "PASS", `current=${currentVersion}`);
|
||||||
|
|
||||||
// --- from-version supported? ---
|
// --- from-version supported? ---
|
||||||
@@ -64,7 +82,7 @@ export async function runDoctor(opts = {}) {
|
|||||||
health = opts.mockHealth;
|
health = opts.mockHealth;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const port = process.env.CLAUDE_PROXY_PORT || "3478";
|
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();
|
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) };
|
health = { status: 200, body: JSON.parse(out) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -178,6 +196,84 @@ export async function runDoctor(opts = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths
|
||||||
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
|
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -190,7 +286,9 @@ function _isMain() {
|
|||||||
}
|
}
|
||||||
if (_isMain()) {
|
if (_isMain()) {
|
||||||
const wantJson = process.argv.includes("--json");
|
const wantJson = process.argv.includes("--json");
|
||||||
const result = await runDoctor();
|
const checkIdx = process.argv.indexOf("--check");
|
||||||
|
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
|
||||||
|
const result = await runDoctor({ checkOnly });
|
||||||
if (wantJson) {
|
if (wantJson) {
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
||||||
@@ -50,3 +50,66 @@ export function listSnapshots(homeDir) {
|
|||||||
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.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,6 +9,7 @@ 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, "..");
|
||||||
@@ -70,7 +71,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://127.0.0.1:3456/v1",
|
baseUrl: `http://${LOCAL_HOST}:${DEFAULT_PORT}${OPENAI_API_BASE}`,
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
authHeader: false,
|
authHeader: false,
|
||||||
models: desiredModels,
|
models: desiredModels,
|
||||||
|
|||||||
+24
-3
@@ -14,7 +14,8 @@ import { execSync } from "node:child_process";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { existsSync, copyFileSync } from "node:fs";
|
import { existsSync, copyFileSync } from "node:fs";
|
||||||
import { writeSnapshot, listSnapshots, readSnapshot } from "./lib/snapshot.mjs";
|
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
||||||
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
export async function runUpgrade(opts = {}) {
|
export async function runUpgrade(opts = {}) {
|
||||||
const dryRun = !!opts.dryRun;
|
const dryRun = !!opts.dryRun;
|
||||||
@@ -134,7 +135,7 @@ async function runFullUpgrade({ doctor, opts }) {
|
|||||||
|
|
||||||
// phase 6: post-flight (10s budget; skipped under mockExec)
|
// phase 6: post-flight (10s budget; skipped under mockExec)
|
||||||
if (!opts.mockExec) {
|
if (!opts.mockExec) {
|
||||||
const port = process.env.CLAUDE_PROXY_PORT || "3478";
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
let ok = false;
|
let ok = false;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +155,16 @@ async function runFullUpgrade({ doctor, opts }) {
|
|||||||
phases.push({ name: "post-flight", status: "skipped-mock" });
|
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 };
|
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (snapshotPath && !err.snapshotPath) {
|
if (snapshotPath && !err.snapshotPath) {
|
||||||
@@ -193,6 +204,11 @@ async function runRollback(opts) {
|
|||||||
const homeDir = opts.homeDir || homedir();
|
const homeDir = opts.homeDir || homedir();
|
||||||
const snapshots = opts.mockSnapshots ?? listSnapshots(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) {
|
if (opts.list) {
|
||||||
return { path: "rollback-list", snapshots };
|
return { path: "rollback-list", snapshots };
|
||||||
}
|
}
|
||||||
@@ -295,6 +311,7 @@ if (_isMain()) {
|
|||||||
const yes = args.includes("--yes");
|
const yes = args.includes("--yes");
|
||||||
const rollback = args.includes("--rollback");
|
const rollback = args.includes("--rollback");
|
||||||
const list = args.includes("--list");
|
const list = args.includes("--list");
|
||||||
|
const gc = args.includes("--gc");
|
||||||
const targetIdx = args.indexOf("--target");
|
const targetIdx = args.indexOf("--target");
|
||||||
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
||||||
// First non-flag positional after --rollback is the snapshot path
|
// First non-flag positional after --rollback is the snapshot path
|
||||||
@@ -305,7 +322,7 @@ if (_isMain()) {
|
|||||||
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await runUpgrade({ dryRun, yes, rollback, list, snapshotPath, target });
|
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.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.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.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
|
||||||
@@ -313,6 +330,10 @@ if (_isMain()) {
|
|||||||
console.log(`Found ${result.snapshots.length} snapshots:`);
|
console.log(`Found ${result.snapshots.length} snapshots:`);
|
||||||
for (const s of result.snapshots) console.log(` ${s.name}`);
|
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);
|
process.exit(0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`✗ ${e.message}`);
|
console.error(`✗ ${e.message}`);
|
||||||
|
|||||||
+3
-2
@@ -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: 3456)
|
* CLAUDE_PROXY_PORT — listen port (default: DEFAULT_PORT from lib/constants.mjs)
|
||||||
* 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,6 +35,7 @@ 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"));
|
||||||
@@ -123,7 +124,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 || "3456", 10);
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT), 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,7 +3,8 @@
|
|||||||
* 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 3456] [--default-model opus|sonnet|haiku] [--dry-run]
|
* Run: node setup.mjs [--port N] [--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
|
||||||
@@ -18,6 +19,7 @@ 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();
|
||||||
@@ -32,7 +34,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", "3456"), 10);
|
const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 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");
|
||||||
|
|||||||
+122
-2
@@ -655,6 +655,18 @@ test("doctor empty health body → fix_service (not fix_oauth)", async () => {
|
|||||||
assert.equal(result.next_action.kind, "fix_service");
|
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 ──
|
// ── Upgrade Tests ──
|
||||||
import { runUpgrade } from "./scripts/upgrade.mjs";
|
import { runUpgrade } from "./scripts/upgrade.mjs";
|
||||||
|
|
||||||
@@ -707,8 +719,8 @@ test("upgrade full path executes 5 phases", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Snapshot Tests ──
|
// ── Snapshot Tests ──
|
||||||
import { writeSnapshot, readSnapshot, listSnapshots } from "./scripts/lib/snapshot.mjs";
|
import { writeSnapshot, readSnapshot, listSnapshots, gcSnapshots } from "./scripts/lib/snapshot.mjs";
|
||||||
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile } from "node:fs";
|
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile, existsSync as testExistsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join as testJoin } from "node:path";
|
import { join as testJoin } from "node:path";
|
||||||
|
|
||||||
@@ -831,6 +843,114 @@ test("rollback latest snapshot restores files (mockExec)", async () => {
|
|||||||
assert.ok(result.phases.some(p => p.name === "git-checkout"));
|
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