mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
b038d3ceac
Maintainer-approved P2 restructure. Principle: what a new user needs in the first
10 minutes stays in README; the operations manual moves to docs/. Content MOVED,
not rewritten — an independent verifier swept all 20 original sections, 60+
distinctive facts, and all table rows against the new corpus: zero content loss.
New files (verbatim moves + two mandated dedup merges):
- docs/lan-mode.md (396) — LAN setup, key management, quotas, anonymous access,
deployment/security model + honest limits, client connect
- docs/tui-mode.md (196) — full TUI section + the four giant env-cell essays as
prose subsections; opens with the single-user SECURITY
warning + the PAUSED billing-split status banner
- docs/troubleshooting.md (136) — full troubleshooting; canonical 401/credential-
isolation explanation (union of the 4 prior copies)
- docs/upgrading.md (79) — upgrade paths, snapshots, rollback, auto-sync
README keeps: pitch (byte-identical), new TOC, 62-line Quickstart, How It Works
verbatim (incl. #171 billing-status note + workload fit), the three release_kit-
pinned tables (Available Models / API Endpoints / all 37 Environment Variables
rows — 4 giant TUI cells now one-line pointers), All Commands, slim Troubleshooting
(bootstrap quirks retained per release_kit bootstrap_quirk_policy), summary stubs
linking each moved doc, Repository Layout (+4 doc rows), Governance, Support.
Dedup (canonical copies): sdk-cli vs subscription-pool table → docs/tui-mode.md;
credential-isolated-home / permanent-401 → docs/troubleshooting.md#tui-401.
Link retargets: docs/runbooks/615-canary.md, docs/runbooks/tui-flip-rollback.md,
setup.mjs (one banner string; node --check clean). 126 links across the touched
files verified resolving; repo-wide grep shows no reference to a removed anchor.
npm test: 332 passed / 0 failed.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
572 lines
24 KiB
JavaScript
Executable File
572 lines
24 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* 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)
|
|
*
|
|
* What it does:
|
|
* 1. Verifies claude CLI is installed and authenticated
|
|
* 2. Patches openclaw.json — adds claude-local provider + models
|
|
* 3. Patches auth-profiles.json — adds dummy auth entry
|
|
* 4. Creates start.sh for easy launch
|
|
* 5. Optionally starts the proxy
|
|
*/
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from "node:fs";
|
|
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
|
import { execSync } from "node:child_process";
|
|
import { 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();
|
|
const OPENCLAW_DIR = process.env.OPENCLAW_STATE_DIR || join(HOME, ".openclaw");
|
|
const CONFIG_PATH = join(OPENCLAW_DIR, "openclaw.json");
|
|
|
|
// ── Parse args ──────────────────────────────────────────────────────────
|
|
const args = process.argv.slice(2);
|
|
const flag = (name) => args.includes(`--${name}`);
|
|
const opt = (name, fallback) => {
|
|
const i = args.indexOf(`--${name}`);
|
|
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
};
|
|
|
|
const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 10);
|
|
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
|
const DRY_RUN = flag("dry-run");
|
|
const SKIP_START = flag("no-start");
|
|
const PROVIDER_NAME = opt("provider-name", "claude-local");
|
|
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
|
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
|
|
|
// ── Service-env injection: CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY ──
|
|
// These are read from the user's shell env at install time and written into
|
|
// the service unit (plist / systemd) so the daemon picks them up on boot.
|
|
|
|
// CLAUDE_BIN — detect at install time; omit if not found (server.mjs fallback)
|
|
let CLAUDE_BIN_INJECT = null;
|
|
if (process.env.CLAUDE_BIN) {
|
|
CLAUDE_BIN_INJECT = process.env.CLAUDE_BIN;
|
|
} else {
|
|
try {
|
|
const detected = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
if (detected && existsSync(detected)) {
|
|
CLAUDE_BIN_INJECT = detected;
|
|
}
|
|
} catch { /* which not available or claude not on PATH — omit */ }
|
|
}
|
|
|
|
// OCP_ADMIN_KEY — omit entirely when empty/unset; don't write empty string
|
|
const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
|
|
|
// PROXY_ANONYMOUS_KEY — same pattern
|
|
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
|
|
|
// ── Inject-value helpers ─────────────────────────────────────────────────
|
|
// Escape a value for safe inclusion in a plist <string>…</string> body.
|
|
function xmlEscape(v) {
|
|
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
}
|
|
// Validate an injected service value: no control chars (a newline would inject a
|
|
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
|
|
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
|
|
function assertSafeInjectValue(name, v) {
|
|
if (v == null) return v;
|
|
if (/[\x00-\x1f]/.test(String(v))) {
|
|
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
|
|
process.exit(1);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
// Validate all three INJECT values before they are written into any service unit.
|
|
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
|
|
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
|
|
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
|
|
|
|
// ── Models: derived from models.json (single source of truth) ──────────
|
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
|
|
|
const MODEL_ID_MAP = modelsConfig.aliases;
|
|
const DEFAULT_MODEL_ID = MODEL_ID_MAP[DEFAULT_MODEL] || MODEL_ID_MAP.opus;
|
|
|
|
const MODELS = modelsConfig.models.map(m => ({
|
|
id: m.id,
|
|
name: m.openclawName,
|
|
reasoning: m.reasoning,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: m.contextWindow,
|
|
maxTokens: m.maxTokens,
|
|
}));
|
|
|
|
const MODEL_ALIASES = Object.fromEntries(
|
|
modelsConfig.models.map(m => [`${PROVIDER_NAME}/${m.id}`, { alias: m.displayName }])
|
|
);
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
function log(msg) { console.log(` ✓ ${msg}`); }
|
|
function warn(msg) { console.log(` ⚠ ${msg}`); }
|
|
function fail(msg) { console.error(` ✗ ${msg}`); process.exit(1); }
|
|
|
|
function readJSON(path) {
|
|
return JSON.parse(readFileSync(path, "utf-8"));
|
|
}
|
|
|
|
function writeJSON(path, data) {
|
|
if (DRY_RUN) {
|
|
console.log(` [dry-run] would write ${path}`);
|
|
return;
|
|
}
|
|
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
}
|
|
|
|
// ── Step 1: Verify prerequisites ────────────────────────────────────────
|
|
console.log("\n🔍 Checking prerequisites...\n");
|
|
|
|
// Check node version
|
|
const nodeVer = parseInt(process.versions.node.split(".")[0], 10);
|
|
if (nodeVer < 18) fail(`Node.js >= 18 required (found ${process.versions.node})`);
|
|
log(`Node.js ${process.versions.node}`);
|
|
|
|
// Check claude CLI
|
|
try {
|
|
const ver = execSync("claude --version 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
log(`Claude CLI: ${ver}`);
|
|
} catch {
|
|
fail("Claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code");
|
|
}
|
|
|
|
// Check claude auth (quick test)
|
|
// NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
|
|
// split, every `claude -p` call draws from the Agent SDK credit pool rather than the
|
|
// Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
|
|
// Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
|
|
if (process.env.OCP_SKIP_AUTH_TEST === "1") {
|
|
warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
|
|
} else {
|
|
try {
|
|
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
|
encoding: "utf-8",
|
|
timeout: 30000,
|
|
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
|
}).trim();
|
|
if (out.length > 0) {
|
|
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
|
}
|
|
} catch (e) {
|
|
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
|
warn("Make sure you're logged in: claude login");
|
|
}
|
|
}
|
|
|
|
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
|
const OPENCLAW_PRESENT = existsSync(CONFIG_PATH);
|
|
if (OPENCLAW_PRESENT) {
|
|
log(`OpenClaw config: ${CONFIG_PATH}`);
|
|
} else {
|
|
warn(`OpenClaw not detected at ${CONFIG_PATH} — skipping OpenClaw integration.`);
|
|
warn(`To register OCP with OpenClaw later, install OpenClaw and re-run \`node setup.mjs\`,`);
|
|
warn(`or run \`ocp update\` if OpenClaw is installed afterward.`);
|
|
}
|
|
|
|
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
|
|
if (OPENCLAW_PRESENT) {
|
|
console.log("\n📝 Configuring OpenClaw...\n");
|
|
|
|
const config = readJSON(CONFIG_PATH);
|
|
|
|
// Ensure models.providers exists
|
|
if (!config.models) config.models = {};
|
|
if (!config.models.providers) config.models.providers = {};
|
|
|
|
// Add/update claude-local provider
|
|
config.models.providers[PROVIDER_NAME] = {
|
|
baseUrl: `http://127.0.0.1:${PORT}/v1`,
|
|
api: "openai-completions",
|
|
authHeader: false,
|
|
models: MODELS,
|
|
};
|
|
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
|
|
|
|
// Ensure auth profile in config
|
|
if (!config.auth) config.auth = {};
|
|
if (!config.auth.profiles) config.auth.profiles = {};
|
|
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
|
|
provider: PROVIDER_NAME,
|
|
mode: "api_key",
|
|
};
|
|
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
|
|
|
|
// Add models to agents.defaults.models
|
|
if (!config.agents) config.agents = {};
|
|
if (!config.agents.defaults) config.agents.defaults = {};
|
|
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
|
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
|
config.agents.defaults.models[key] = val;
|
|
}
|
|
log(`Model aliases added to agents.defaults.models`);
|
|
|
|
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
|
|
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
|
|
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
|
|
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
|
|
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
|
|
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
|
|
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
|
|
config.agents.defaults.llm.idleTimeoutSeconds = 0;
|
|
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
|
|
} else {
|
|
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
|
|
}
|
|
|
|
writeJSON(CONFIG_PATH, config);
|
|
log(`Config saved`);
|
|
|
|
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
|
|
console.log("\n🔑 Configuring auth profiles...\n");
|
|
|
|
// Find all agent auth-profiles.json files
|
|
const agentsDir = join(OPENCLAW_DIR, "agents");
|
|
const agentDirs = existsSync(agentsDir)
|
|
? readdirSync(agentsDir).filter((d) => {
|
|
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
|
|
return existsSync(ap);
|
|
})
|
|
: [];
|
|
|
|
for (const agentId of agentDirs) {
|
|
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
|
|
try {
|
|
const ap = readJSON(apPath);
|
|
if (!ap.profiles) ap.profiles = {};
|
|
|
|
// Add claude-local profile if missing
|
|
if (!ap.profiles[`${PROVIDER_NAME}:default`]) {
|
|
ap.profiles[`${PROVIDER_NAME}:default`] = {
|
|
type: "api_key",
|
|
provider: PROVIDER_NAME,
|
|
key: "local-proxy-no-auth",
|
|
};
|
|
}
|
|
|
|
// Add to lastGood if missing
|
|
if (!ap.lastGood) ap.lastGood = {};
|
|
if (!ap.lastGood[PROVIDER_NAME]) {
|
|
ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`;
|
|
}
|
|
|
|
writeJSON(apPath, ap);
|
|
log(`Agent "${agentId}" auth profile updated`);
|
|
} catch (e) {
|
|
warn(`Skipped agent "${agentId}": ${e.message}`);
|
|
}
|
|
}
|
|
|
|
if (agentDirs.length === 0) {
|
|
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
|
}
|
|
}
|
|
|
|
// ── Step 4: Create start.sh ─────────────────────────────────────────────
|
|
console.log("\n🚀 Creating launcher...\n");
|
|
|
|
const serverPath = join(__dirname, "server.mjs");
|
|
const logDir = join(OPENCLAW_DIR, "logs");
|
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
|
|
const startSh = `#!/bin/bash
|
|
# Start OCP (Open Claude Proxy) if not already running
|
|
PORT=\${CLAUDE_PROXY_PORT:-${PORT}}
|
|
if ! lsof -i :\$PORT -sTCP:LISTEN &>/dev/null; then
|
|
unset CLAUDECODE
|
|
nohup node "${serverPath}" \\
|
|
>> "${logDir}/claude-proxy.log" \\
|
|
2>> "${logDir}/claude-proxy.err.log" &
|
|
echo "claude-proxy started on port \$PORT (pid $!)"
|
|
else
|
|
echo "claude-proxy already running on port \$PORT"
|
|
fi
|
|
`;
|
|
|
|
const startPath = join(__dirname, "start.sh");
|
|
if (!DRY_RUN) {
|
|
writeFileSync(startPath, startSh);
|
|
execSync(`chmod +x "${startPath}"`);
|
|
}
|
|
log(`Launcher: ${startPath}`);
|
|
|
|
// ── Step 5: Summary ─────────────────────────────────────────────────────
|
|
const banner = [
|
|
`╔══════════════════════════════════════════════════════════════╗`,
|
|
`║ Setup complete! ║`,
|
|
`╠══════════════════════════════════════════════════════════════╣`,
|
|
`║ ║`,
|
|
`║ Provider: ${PROVIDER_NAME.padEnd(44)}║`,
|
|
`║ Port: ${String(PORT).padEnd(44)}║`,
|
|
`║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║`,
|
|
`║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║`,
|
|
`║ ║`,
|
|
`║ Start proxy: ║`,
|
|
`║ bash ${startPath.replace(HOME, "~").padEnd(50)}║`,
|
|
`║ ║`,
|
|
`║ Or directly: ║`,
|
|
`║ node ${serverPath.replace(HOME, "~").padEnd(49)}║`,
|
|
`║ ║`,
|
|
];
|
|
if (OPENCLAW_PRESENT) {
|
|
banner.push(
|
|
`║ Set as default model in openclaw.json: ║`,
|
|
`║ agents.defaults.model.primary = ║`,
|
|
`║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║`,
|
|
`║ ║`,
|
|
`║ Then restart gateway: ║`,
|
|
`║ openclaw gateway restart ║`,
|
|
`║ ║`,
|
|
);
|
|
} else {
|
|
banner.push(
|
|
`║ OpenClaw not detected — running in standalone mode. ║`,
|
|
`║ Point your IDE (Cline / Cursor / Continue / OpenCode / ║`,
|
|
`║ Aider / OpenClaw) at: ║`,
|
|
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}║`,
|
|
`║ ║`,
|
|
`║ See docs/lan-mode.md for per-IDE client setup. ║`,
|
|
`║ ║`,
|
|
);
|
|
}
|
|
banner.push(`╚══════════════════════════════════════════════════════════════╝`);
|
|
console.log("\n" + banner.join("\n") + "\n");
|
|
|
|
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
|
|
|
// Log service-env injection plan (shown in both dry-run and live mode)
|
|
console.log("\n🔧 Service unit env vars to inject:\n");
|
|
if (CLAUDE_BIN_INJECT) {
|
|
log(`CLAUDE_BIN: ${CLAUDE_BIN_INJECT}`);
|
|
} else {
|
|
log(`CLAUDE_BIN: (not found — server.mjs will auto-detect at runtime)`);
|
|
}
|
|
if (OCP_ADMIN_KEY_INJECT) {
|
|
log(`OCP_ADMIN_KEY: injected (length: ${OCP_ADMIN_KEY_INJECT.length})`);
|
|
} else {
|
|
log(`OCP_ADMIN_KEY: (unset — admin endpoints disabled)`);
|
|
}
|
|
if (PROXY_ANON_KEY_INJECT) {
|
|
log(`PROXY_ANONYMOUS_KEY: injected (set)`);
|
|
} else {
|
|
log(`PROXY_ANONYMOUS_KEY: (unset — anonymous access disabled)`);
|
|
}
|
|
|
|
if (DRY_RUN) {
|
|
console.log("\n [dry-run] would write service unit with above env vars\n");
|
|
}
|
|
|
|
if (!DRY_RUN) {
|
|
console.log("\n🔄 Installing auto-start on login...\n");
|
|
|
|
const platform = process.platform;
|
|
// Use stable symlink path instead of versioned Cellar path (e.g. /opt/homebrew/opt/node/bin/node
|
|
// instead of /opt/homebrew/Cellar/node/25.8.0/bin/node) so the plist survives node upgrades.
|
|
let nodeBin = process.execPath;
|
|
if (platform === "darwin" && nodeBin.includes("/Cellar/")) {
|
|
const stable = nodeBin.replace(/\/Cellar\/[^/]+\/[^/]+\//, "/opt/");
|
|
if (existsSync(stable)) {
|
|
nodeBin = stable;
|
|
log(`Using stable node path: ${nodeBin}`);
|
|
}
|
|
}
|
|
|
|
// Ensure logs dir exists
|
|
const logsDir = join(OPENCLAW_DIR, "logs");
|
|
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
|
|
|
// Use neutral service names to avoid OpenClaw gateway's extra-service detection.
|
|
// OpenClaw scans LaunchAgent plists and systemd units for "openclaw" / "clawdbot"
|
|
// markers and flags them as conflicting gateway-like services. Using "dev.ocp.*"
|
|
// and "ocp-proxy" keeps the proxy invisible to that heuristic.
|
|
const OCP_HOME = join(HOME, ".ocp");
|
|
const ocpLogsDir = join(OCP_HOME, "logs");
|
|
// mode 0700: with `recursive`, this call can create ~/.ocp ITSELF on a fresh install, and
|
|
// without an explicit mode that parent lands at the umask default (world-listable 0755).
|
|
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true, mode: 0o700 });
|
|
|
|
// Uninstall legacy service names if present (upgrade path)
|
|
if (platform === "darwin") {
|
|
const legacyPlist = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
|
|
if (existsSync(legacyPlist)) {
|
|
try { execSync(`launchctl bootout gui/$(id -u) "${legacyPlist}" 2>/dev/null`); } catch { /* ignore */ }
|
|
try { unlinkSync(legacyPlist); } catch { /* ignore */ }
|
|
log(`Removed legacy plist: ai.openclaw.proxy`);
|
|
}
|
|
} else if (platform === "linux") {
|
|
const legacyService = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
|
|
if (existsSync(legacyService)) {
|
|
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
|
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
|
try { unlinkSync(legacyService); } catch { /* ignore */ }
|
|
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
|
|
log(`Removed legacy systemd service: openclaw-proxy`);
|
|
}
|
|
}
|
|
|
|
if (platform === "darwin") {
|
|
// macOS: launchd
|
|
const plistDir = join(HOME, "Library", "LaunchAgents");
|
|
if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
|
|
|
|
const plistPath = join(plistDir, "dev.ocp.proxy.plist");
|
|
const logPath = join(ocpLogsDir, "proxy.log");
|
|
|
|
const plistXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>dev.ocp.proxy</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>${nodeBin}</string>
|
|
<string>${serverPath}</string>
|
|
</array>
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
<key>CLAUDE_PROXY_PORT</key>
|
|
<string>${xmlEscape(PORT)}</string>
|
|
<key>CLAUDE_BIND</key>
|
|
<string>${xmlEscape(BIND_ADDRESS)}</string>
|
|
<key>CLAUDE_AUTH_MODE</key>
|
|
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
|
|
<key>CLAUDE_BIN</key>
|
|
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
|
<key>OCP_ADMIN_KEY</key>
|
|
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
|
<key>PROXY_ANONYMOUS_KEY</key>
|
|
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
|
|
</dict>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>KeepAlive</key>
|
|
<true/>
|
|
<key>StandardOutPath</key>
|
|
<string>${logPath}</string>
|
|
<key>StandardErrorPath</key>
|
|
<string>${logPath}</string>
|
|
</dict>
|
|
</plist>
|
|
`;
|
|
|
|
const existingPlist = existsSync(plistPath) ? readFileSync(plistPath, "utf8") : null;
|
|
const finalPlistXml = mergePlistEnv(existingPlist, plistXml);
|
|
writeFileSync(plistPath, finalPlistXml);
|
|
chmodSync(plistPath, 0o600);
|
|
if (existingPlist && finalPlistXml !== plistXml) {
|
|
log(`Plist written: ${plistPath} (mode 600, preserved user env vars)`);
|
|
} else {
|
|
log(`Plist written: ${plistPath} (mode 600)`);
|
|
}
|
|
|
|
// Bootout first (in case it was already loaded) then bootstrap
|
|
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
|
execSync(`launchctl bootstrap gui/$(id -u) "${plistPath}"`);
|
|
log(`launchctl loaded dev.ocp.proxy`);
|
|
|
|
} else if (platform === "linux") {
|
|
// Linux: systemd user service
|
|
const systemdDir = join(HOME, ".config", "systemd", "user");
|
|
if (!existsSync(systemdDir)) mkdirSync(systemdDir, { recursive: true });
|
|
|
|
const servicePath = join(systemdDir, "ocp-proxy.service");
|
|
const logPath = join(ocpLogsDir, "proxy.log");
|
|
|
|
const serviceUnit = `[Unit]
|
|
Description=OCP — Open Claude Proxy
|
|
After=network.target
|
|
|
|
[Service]
|
|
ExecStart=${nodeBin} ${serverPath}
|
|
Environment=CLAUDE_PROXY_PORT=${PORT}
|
|
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
|
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}${CLAUDE_BIN_INJECT ? `\nEnvironment=CLAUDE_BIN=${CLAUDE_BIN_INJECT}` : ""}${OCP_ADMIN_KEY_INJECT ? `\nEnvironment=OCP_ADMIN_KEY=${OCP_ADMIN_KEY_INJECT}` : ""}${PROXY_ANON_KEY_INJECT ? `\nEnvironment=PROXY_ANONYMOUS_KEY=${PROXY_ANON_KEY_INJECT}` : ""}
|
|
Restart=always
|
|
RestartSec=5
|
|
StandardOutput=append:${logPath}
|
|
StandardError=append:${logPath}
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
`;
|
|
|
|
const existingService = existsSync(servicePath) ? readFileSync(servicePath, "utf8") : null;
|
|
const finalServiceUnit = mergeSystemdEnv(existingService, serviceUnit);
|
|
writeFileSync(servicePath, finalServiceUnit);
|
|
chmodSync(servicePath, 0o600);
|
|
if (existingService && finalServiceUnit !== serviceUnit) {
|
|
log(`Service file written: ${servicePath} (mode 600, preserved user env vars)`);
|
|
} else {
|
|
log(`Service file written: ${servicePath} (mode 600)`);
|
|
}
|
|
|
|
execSync(`systemctl --user daemon-reload`);
|
|
execSync(`systemctl --user enable ocp-proxy`);
|
|
execSync(`systemctl --user start ocp-proxy`);
|
|
log(`systemd user service enabled and started`);
|
|
|
|
} else {
|
|
warn(`Auto-start not supported on ${platform} — start manually with: bash ${startPath}`);
|
|
}
|
|
|
|
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n");
|
|
|
|
// ── Step 8: Post-install health verification ───────────────────────────
|
|
if (!SKIP_START) {
|
|
console.log("⏳ Waiting for server to bind...\n");
|
|
await new Promise(r => setTimeout(r, 3000));
|
|
|
|
const healthUrl = `http://127.0.0.1:${PORT}/health`;
|
|
let verified = false;
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
const res = await fetch(healthUrl, { signal: controller.signal });
|
|
clearTimeout(timer);
|
|
|
|
if (res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
console.log(` ✓ Health check passed (${healthUrl})`);
|
|
console.log(` version: ${body.version ?? "unknown"}`);
|
|
console.log(` authMode: ${body.authMode ?? "unknown"}`);
|
|
|
|
// Verify bind socket
|
|
try {
|
|
const bindCheck = process.platform === "linux"
|
|
? execSync(`ss -tlnp 2>/dev/null | grep ':${PORT}'`, { encoding: "utf-8" }).trim()
|
|
: execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
if (bindCheck) {
|
|
console.log(` bind: ${bindCheck.split("\n")[0]}`);
|
|
}
|
|
} catch { /* bind check is best-effort */ }
|
|
|
|
verified = true;
|
|
} else {
|
|
warn(`Health check returned HTTP ${res.status} — service may not have started cleanly`);
|
|
}
|
|
} catch (e) {
|
|
const isTimeout = e.name === "AbortError" || (e.cause && e.cause.code === "UND_ERR_CONNECT_TIMEOUT");
|
|
warn(`Health check failed: ${isTimeout ? "timeout (5s)" : e.message}`);
|
|
}
|
|
|
|
if (!verified) {
|
|
const logHint = process.platform === "linux"
|
|
? "journalctl --user -u ocp-proxy -n 50"
|
|
: `tail -n 100 ~/.ocp/logs/proxy.log`;
|
|
console.error(`\n ✗ Server did not respond on port ${PORT} within 5 seconds.`);
|
|
console.error(` Check service logs:\n ${logHint}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|