Compare commits

..
Author SHA1 Message Date
taodengandClaude Opus 4.7 ea86f2a304 fix(scripts): CLI entrypoint guard resilient to symlinked install paths
Bug found via integration test on MacBook Pro (macOS /tmp → /private/tmp):
`import.meta.url === \`file://\${process.argv[1]}\`` evaluates false when
the install path traverses a symlink, because import.meta.url is canonicalised
but process.argv[1] is not. Result: ./ocp doctor (and ./ocp update via
upgrade.mjs) exit silently with code 0 and no output, instead of running.

Fix: use fileURLToPath + realpathSync on both sides of the comparison.
Affects any install at a symlinked path (/tmp, NFS mounts, /var/ paths,
docker bind mounts, etc.). Normal ~/ocp installs were unaffected.

No cli.js citation needed: this is OCP-internal CLI dispatch with no
corresponding cli.js operation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 06:54:12 +10:00
taodengandClaude Opus 4.7 2a3376d237 docs(readme): show --yes in rollback usage examples
Per Iron Rule 10 reviewer nit on PR #91: live rollback requires --yes
even for interactive humans. Update §Upgrading examples to show the
canonical human form. (AI agents pass --yes by convention; humans were
hitting a confusing "requires --yes" error following the prior README.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 04:18:30 +10:00
taodengandClaude Sonnet 4.6 95d70d5865 docs(release): v3.15.0 — README AI prompt blocks + Upgrading rewrite + CHANGELOG
§Installation, §Upgrading, §Troubleshooting each start with a copy-paste
AI prompt block for Claude Code / Cursor / Copilot. The Upgrading section
explains the three paths (light / full / fresh-install) and rollback usage.

All Commands table gains an `ocp doctor` row.

package.json bumped to 3.15.0.

CHANGELOG.md gains the v3.15.0 entry covering doctor, the cross-version
update path, --rollback, fresh-install routing, and AI prompt blocks.
Notes the dependency on PR #90 (plist env merge bug fix, already merged).

No cli.js citation needed: docs + version bump only, no server.mjs change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:11:55 +10:00
taodengandClaude Opus 4.7 2105ae8779 fix(ocp): forward all args to cmd_update so multi-flag invocations work
Bug found via runtime smoke test:
  ./ocp update --rollback --list → "no snapshots" (wrong; should list)

Root cause: dispatch was `cmd_update "\${1:-}"` (only first arg). When
user typed `--rollback --list`, cmd_update only received `--rollback`,
the shift left $@ empty, and exec node ... --rollback got no flags.
Other commands using "\${1:-}" don't need multi-arg, but cmd_update now
does (--rollback --list, --rollback --dry-run, --target X --yes, etc.).

Change: dispatch is now `cmd_update "\$@"`. cmd_update internals already
handle multi-arg correctly (\$1 == --check fast path; \$1 == --rollback
shift+forward; otherwise doctor-driven).

Verified:
  ./ocp update --check         → existing behaviour preserved
  ./ocp update --rollback --list → "Found 0 snapshots:" exit 0
  ./ocp update --rollback --dry-run → no-snapshot error exit 1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 04:08:37 +10:00
taodengandClaude Sonnet 4.6 42fcf88fa5 refactor(ocp): cmd_update dispatches via doctor; --rollback added; light path preserved
cmd_update now calls scripts/doctor.mjs to determine which path to take:
  noop          → "already at latest" exit 0
  update        → existing light path (git pull + npm install + restart),
                  extracted into _cmd_update_light helper to keep the daily
                  case fast and shell-only
  upgrade       → exec node scripts/upgrade.mjs (full path with snapshot
                  + post-flight)
  fresh_install → exec node scripts/upgrade.mjs (gated by --yes)
  fix_oauth/fix_service → print error referring user to `ocp doctor`

cmd_update --rollback path: exec node scripts/upgrade.mjs --rollback "$@"
forwards remaining args (--list, --dry-run, optional snapshot path).

cmd_update_help expanded to document new flags.

cmd_update --check fast path is preserved exactly (no doctor call there).

No cli.js citation needed: this is OCP-internal CLI dispatch with no
corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:06:42 +10:00
taodengandClaude Opus 4.7 6ef6b7c9d8 chore(upgrade): nit fixes from Bundle 3 code-quality review
3 micro-fixes on 48e9408:
1. Remove unused mkdirSync import
2. snapshot-not-found error message hints "must be inside ~/.ocp/upgrade-snapshot-*"
3. runFreshInstall failure now includes e.stderr (or e.message fallback) in the
   thrown error and steps[].error so non-interactive callers see the actual reason

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 04:04:29 +10:00
taodengandClaude Sonnet 4.6 48e9408def feat(upgrade): fresh-install + rollback paths
Implements the two missing branches of runUpgrade dispatcher:

- runFreshInstall: gated by --yes, runs doctor.next_action.ai_executable
  steps in order, fails fast on first error, attaches steps[] to thrown
  errors. Accepts mockExec for unit tests.
- runRollback: locates latest or named snapshot in ~/.ocp/, reads
  from-commit.txt, restores plist + db + admin-key + service file (with
  per-file warn lines on copy failure), git-checkouts the from-commit,
  npm installs at that revision, restarts the service. --list shows all
  snapshots; --dry-run prints the plan without mutation.

Both paths use the same exec() error-wrap pattern as runFullUpgrade
(stderr capture, phases attached to thrown errors, restart heads-up).

CLI entrypoint extended to parse --rollback / --list / --target / and
optional positional snapshot path after --rollback.

6 unit tests cover: --yes gate, fresh_install ai_executable run,
--rollback --list, no-snapshots error, --rollback --dry-run, mock-exec
restore.

No cli.js citation needed: this is OCP-internal upgrade tooling with
no corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:02:30 +10:00
taodengandClaude Sonnet 4.6 7959f25801 fix(upgrade): error path completeness + observability
5 issues raised by code-quality reviewer on c12013a:

A. exec() wrapper now captures stderr from execSync failures and
   re-throws with `phase X failed: <stderr>` instead of the terse
   "Command failed: ..." default. Operators see the actual git/npm
   error.
B. runFullUpgrade body wrapped in try/catch; any error after phase 2
   (snapshot written) carries snapshotPath + phases + hint pointing
   at `ocp update --rollback`. Aligns with the post-flight failure
   pattern.
C. CLI entrypoint now prints snapshotPath + hint on error.

Plus minor:
- snapshot.mjs tryCopy logs a [snapshot] warn line instead of silently
  swallowing copy errors (e.g. permission-denied admin-key)
- heads-up window 1s → 3s, more operable per the policy intent
- opts.yes intent comment added (Bundle 3 will use)

One regression test added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:58:32 +10:00
taodengandClaude Sonnet 4.6 c12013a094 feat(upgrade): add scripts/upgrade.mjs + scripts/lib/snapshot.mjs
Implements the upgrade dispatcher (noop / dry-run / light delegation /
full path) and the snapshot writer/reader/list module. Full path snapshots
plist + db + admin-key + openclaw.json before mutating, runs the 6 phases
(pre-flight, snapshot, fetch+install, reconfigure, restart, post-flight),
and emits a heads-up before launchctl bootout per
notify_before_prod_service_restart.md policy.

mockExec/mockDoctor injection points let tests verify the phase ordering
without touching the real shell. fresh_install + rollback paths are
deferred to Bundle 3.

No cli.js citation needed: this is OCP-internal upgrade tooling with no
corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:54:15 +10:00
taodengandClaude Sonnet 4.6 c4e5374046 fix(doctor): handle unparseable version + empty health body
Three issues raised by code-quality reviewer on b65201b:

1. semverCompare returned 0 for unparseable input, causing fromSupported=true
   and kind=noop for an install with unreadable package.json. Now treats
   unparseable currentVersion as fresh_install candidate.
2. mockHealth: { status: 200, body: null } routed to fix_oauth (because
   health.body?.auth?.ok was undefined → falsy). 200 with empty body is
   server-broken, not OAuth-broken; now routes to fix_service.
3. Removed unused KIND_ENUM declaration (dead code).

Two regression tests added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:51:03 +10:00
taodengandClaude Sonnet 4.6 414b473eb7 feat(ocp): wire cmd_doctor into bash CLI; dispatch to scripts/doctor.mjs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:46:11 +10:00
taodengandClaude Sonnet 4.6 b65201b395 feat(doctor): add ocp doctor with --json + next_action contract
Implements scripts/doctor.mjs with semver-aware path selection
(noop/update/upgrade/fresh_install/fix_oauth/fix_service) and the JSON
contract documented in the design spec.

Service health + OAuth checks integrated; mockable via opts.mockHealth
for unit tests. 8 unit tests cover the kind dispatch tree and the
next_action shape for each kind.

No cli.js citation needed: this is OCP-internal tooling with no
corresponding cli.js operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:45:34 +10:00
15 changed files with 22 additions and 583 deletions
-79
View File
@@ -4,11 +4,6 @@ on:
pull_request:
paths:
- 'server.mjs'
- 'setup.mjs'
- 'scripts/**'
- 'lib/**'
- 'ocp'
- 'ocp-connect'
- '.github/workflows/alignment.yml'
jobs:
@@ -71,80 +66,6 @@ jobs:
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:
name: commit message citation (soft check)
runs-on: ubuntu-latest
+1 -131
View File
@@ -1,136 +1,6 @@
# 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
## v3.15.0 — 2026-XX-XX (release date filled at tag time)
### Features
+1 -2
View File
@@ -855,8 +855,7 @@ Future `ocp update` invocations sync automatically.
| Variable | Default | Description |
|----------|---------|-------------|
| `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_PROXY_PORT` | `3456` | Listen port |
| `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` |
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
-33
View File
@@ -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}`;
-2
View File
@@ -709,8 +709,6 @@ Usage:
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
}
+3 -13
View File
@@ -1,19 +1,9 @@
/**
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
* 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.
* Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response.
*/
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
function mono(text) { return "```\n" + text + "\n```"; }
+2 -2
View File
@@ -2,7 +2,7 @@
"id": "ocp",
"name": "OCP Commands",
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
"version": "3.16.2",
"version": "3.12.0",
"configSchema": {
"type": "object",
"additionalProperties": false,
@@ -10,7 +10,7 @@
"proxyUrl": {
"type": "string",
"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
View File
@@ -1,6 +1,6 @@
{
"name": "open-claude-proxy",
"version": "3.16.4",
"version": "3.15.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.",
"type": "module",
"bin": {
+3 -101
View File
@@ -15,7 +15,6 @@ 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";
@@ -38,11 +37,6 @@ export async function runDoctor(opts = {}) {
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;
@@ -54,19 +48,7 @@ export async function runDoctor(opts = {}) {
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;
}
}
const latestVersion = opts.mockLatest || "v3.14.0";
push("current_version", "PASS", `current=${currentVersion}`);
// --- from-version supported? ---
@@ -82,7 +64,7 @@ export async function runDoctor(opts = {}) {
health = opts.mockHealth;
} else {
try {
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
const port = process.env.CLAUDE_PROXY_PORT || "3478";
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) {
@@ -196,84 +178,6 @@ 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
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
import { fileURLToPath } from "node:url";
@@ -286,9 +190,7 @@ function _isMain() {
}
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 });
const result = await runDoctor();
if (wantJson) {
console.log(JSON.stringify(result, null, 2));
} else {
+1 -64
View File
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
@@ -50,66 +50,3 @@ export function listSnapshots(homeDir) {
.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;
}
+1 -2
View File
@@ -9,7 +9,6 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
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 REPO_ROOT = join(__dirname, "..");
@@ -71,7 +70,7 @@ if (!config.models.providers) config.models.providers = {};
if (!config.models.providers[PROVIDER_NAME]) {
// First-time registration
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",
authHeader: false,
models: desiredModels,
+3 -24
View File
@@ -14,8 +14,7 @@ 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";
import { writeSnapshot, listSnapshots, readSnapshot } from "./lib/snapshot.mjs";
export async function runUpgrade(opts = {}) {
const dryRun = !!opts.dryRun;
@@ -135,7 +134,7 @@ async function runFullUpgrade({ doctor, opts }) {
// phase 6: post-flight (10s budget; skipped under mockExec)
if (!opts.mockExec) {
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
const port = process.env.CLAUDE_PROXY_PORT || "3478";
let ok = false;
for (let i = 0; i < 10; i++) {
try {
@@ -155,16 +154,6 @@ async function runFullUpgrade({ doctor, opts }) {
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) {
@@ -204,11 +193,6 @@ 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 };
}
@@ -311,7 +295,6 @@ if (_isMain()) {
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
@@ -322,7 +305,7 @@ if (_isMain()) {
if (cand && !cand.startsWith("--")) snapshotPath = cand;
}
try {
const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target });
const result = await runUpgrade({ dryRun, yes, rollback, list, 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}`);
@@ -330,10 +313,6 @@ if (_isMain()) {
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}`);
+2 -3
View File
@@ -11,7 +11,7 @@
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
*
* 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_TIMEOUT — per-request timeout in ms (default: 600000)
* 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 { 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 { DEFAULT_PORT } from "./lib/constants.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -124,7 +123,7 @@ function resolveClaude() {
// ── Configuration ───────────────────────────────────────────────────────
// 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();
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10);
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
+2 -4
View File
@@ -3,8 +3,7 @@
* OCP (Open Claude Proxy) setup
*
* Automatically configures OpenClaw to use Claude CLI as a model provider.
* Run: node setup.mjs [--port N] [--default-model opus|sonnet|haiku] [--dry-run]
* (default port = DEFAULT_PORT from lib/constants.mjs)
* Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run]
*
* What it does:
* 1. Verifies claude CLI is installed and authenticated
@@ -19,7 +18,6 @@ import { execSync } from "node:child_process";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { DEFAULT_PORT } from "./lib/constants.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOME = homedir();
@@ -34,7 +32,7 @@ const opt = (name, 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 DRY_RUN = flag("dry-run");
const SKIP_START = flag("no-start");
+2 -122
View File
@@ -655,18 +655,6 @@ test("doctor empty health body → fix_service (not fix_oauth)", async () => {
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";
@@ -719,8 +707,8 @@ test("upgrade full path executes 5 phases", async () => {
});
// ── 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 { writeSnapshot, readSnapshot, listSnapshots } from "./scripts/lib/snapshot.mjs";
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile } from "node:fs";
import { tmpdir } from "node:os";
import { join as testJoin } from "node:path";
@@ -843,114 +831,6 @@ test("rollback latest snapshot restores files (mockExec)", async () => {
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 ──
closeDb();