claude-code-cache-fix 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -20
- package/bin/claude-via-proxy.mjs +57 -0
- package/bin/install-service.mjs +476 -0
- package/package.json +3 -2
- package/proxy/extensions/overage-warning.mjs +385 -0
- package/proxy/extensions/upstream-change-detection.mjs +533 -0
- package/proxy/extensions/usage-log.mjs +252 -23
- package/proxy/extensions.json +1 -0
- package/proxy/rates.mjs +16 -0
- package/templates/cache-fix-proxy-healthcheck.service.template +7 -0
- package/templates/cache-fix-proxy-healthcheck.timer.template +14 -0
- package/templates/cache-fix-proxy.service.template +17 -0
- package/templates/com.cnighswonger.cache-fix-proxy.plist.template +33 -0
package/README.md
CHANGED
|
@@ -45,43 +45,84 @@ Extensions are hot-reloadable — add, remove, or modify `.mjs` files in `proxy/
|
|
|
45
45
|
|
|
46
46
|
### Running as a service
|
|
47
47
|
|
|
48
|
-
**
|
|
48
|
+
**Recommended (Linux/macOS) — `install-service` subcommand:**
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
```bash
|
|
51
|
+
cache-fix-proxy install-service
|
|
52
|
+
```
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
[Unit]
|
|
54
|
-
Description=Claude Code Cache Fix Proxy (v3.x)
|
|
55
|
-
After=network.target
|
|
54
|
+
Detects your platform and writes the appropriate config:
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
ExecStart=/usr/local/bin/node /path/to/claude-code-cache-fix/proxy/server.mjs
|
|
60
|
-
Restart=on-failure
|
|
61
|
-
RestartSec=5
|
|
62
|
-
Environment=CACHE_FIX_PROXY_PORT=9801
|
|
56
|
+
- **Linux** → `~/.config/systemd/user/cache-fix-proxy.service` (systemd user unit)
|
|
57
|
+
- **macOS** → `~/Library/LaunchAgents/com.cnighswonger.cache-fix-proxy.plist` (launchd agent)
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
WantedBy=default.target
|
|
66
|
-
```
|
|
59
|
+
The output prints the next-step commands to enable and start the service. On Linux:
|
|
67
60
|
|
|
68
61
|
```bash
|
|
69
62
|
systemctl --user daemon-reload
|
|
70
63
|
systemctl --user enable --now cache-fix-proxy
|
|
64
|
+
systemctl --user enable --now cache-fix-proxy-healthcheck.timer # auto-recovery — see below
|
|
65
|
+
sudo loginctl enable-linger $USER # optional: start on boot, not just on login
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Auto-recovery (Linux):** `install-service` also drops a healthcheck companion (`cache-fix-proxy-healthcheck.service` + `.timer`). The timer fires every 2 minutes; the oneshot service runs `curl -fs http://127.0.0.1:<port>/health` and `systemctl --user start cache-fix-proxy.service` if the probe fails. This recovers the proxy from any stop — clean or unclean, expected or unexpected — within 2 minutes. Background: `Restart=on-failure` doesn't fire on clean stops, so before this companion existed, a `systemctl stop` from any source (including unidentified ones during an Anthropic outage on 2026-04-25) would leave the proxy down indefinitely. macOS doesn't need the companion — launchd's `KeepAlive` already auto-restarts on any exit.
|
|
69
|
+
|
|
70
|
+
On macOS:
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
```bash
|
|
73
|
+
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.cnighswonger.cache-fix-proxy.plist
|
|
74
|
+
launchctl enable gui/$(id -u)/com.cnighswonger.cache-fix-proxy
|
|
75
|
+
launchctl kickstart gui/$(id -u)/com.cnighswonger.cache-fix-proxy
|
|
74
76
|
```
|
|
75
77
|
|
|
76
|
-
|
|
78
|
+
The installed config picks up `CACHE_FIX_PROXY_PORT`, `CACHE_FIX_PROXY_UPSTREAM`, and `CACHE_FIX_DEBUG` from the env at install time. Re-run `install-service --force` to regenerate after env changes, or edit the service file directly. Pair with `cache-fix-proxy uninstall-service` to remove cleanly (stops, disables, deletes).
|
|
79
|
+
|
|
80
|
+
The service runs `cache-fix-proxy server` in the foreground, which is just the proxy without the wrapper-mode claude launcher.
|
|
77
81
|
|
|
78
|
-
**
|
|
82
|
+
**Manual (any platform):**
|
|
79
83
|
|
|
80
84
|
```bash
|
|
81
|
-
nohup
|
|
85
|
+
nohup cache-fix-proxy server > /tmp/cache-fix-proxy.log 2>&1 &
|
|
82
86
|
echo 'export ANTHROPIC_BASE_URL=http://127.0.0.1:9801' >> ~/.bashrc
|
|
83
87
|
```
|
|
84
88
|
|
|
89
|
+
### Docker
|
|
90
|
+
|
|
91
|
+
A multi-arch (amd64, arm64) container image is published to GitHub Container Registry on every release tag.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
docker run -d --name cache-fix-proxy \
|
|
95
|
+
--restart=always \
|
|
96
|
+
-p 9801:9801 \
|
|
97
|
+
ghcr.io/cnighswonger/claude-code-cache-fix:latest
|
|
98
|
+
|
|
99
|
+
# Then in your shell:
|
|
100
|
+
export ANTHROPIC_BASE_URL=http://127.0.0.1:9801
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Use `--restart=always` instead of the systemd healthcheck companion — Docker handles auto-recovery natively. Mount nothing; the container is stateless. Override the default port with `-e CACHE_FIX_PROXY_PORT=...`. Override the upstream (e.g. to chain through llm-relay) with `-e CACHE_FIX_PROXY_UPSTREAM=http://host.docker.internal:8080`. The image runs as the unprivileged `node` user (uid 1000) and exposes a `HEALTHCHECK` Docker can use for liveness.
|
|
104
|
+
|
|
105
|
+
For corporate environments behind an SSL-inspecting proxy, mount your CA bundle and set the env vars:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
docker run -d --name cache-fix-proxy --restart=always -p 9801:9801 \
|
|
109
|
+
-e HTTPS_PROXY=http://proxy.corp.example:8080 \
|
|
110
|
+
-e CACHE_FIX_PROXY_CA_FILE=/etc/ssl/corp-ca.pem \
|
|
111
|
+
-v /path/to/zscaler-root.pem:/etc/ssl/corp-ca.pem:ro \
|
|
112
|
+
ghcr.io/cnighswonger/claude-code-cache-fix:latest
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Image tags: `latest`, `3`, `3.2`, `3.2.0` (semver-ladder, so `3` always points to the newest 3.x). `latest` always tracks the newest tagged release.
|
|
116
|
+
|
|
117
|
+
**Linux note:** the chained-upstream `host.docker.internal` example below is automatic on Docker Desktop (macOS / Windows). On plain Linux Docker Engine you usually need `--add-host=host.docker.internal:host-gateway` so the name resolves to the host bridge. Without it, the container's name lookup fails and the proxy can't reach the upstream service running on the host. Example chaining cache-fix proxy through `llm-relay` running on the host:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
docker run -d --name cache-fix-proxy --restart=always -p 9801:9801 \
|
|
121
|
+
--add-host=host.docker.internal:host-gateway \
|
|
122
|
+
-e CACHE_FIX_PROXY_UPSTREAM=http://host.docker.internal:8080 \
|
|
123
|
+
ghcr.io/cnighswonger/claude-code-cache-fix:latest
|
|
124
|
+
```
|
|
125
|
+
|
|
85
126
|
### Health check
|
|
86
127
|
|
|
87
128
|
```bash
|
package/bin/claude-via-proxy.mjs
CHANGED
|
@@ -9,6 +9,63 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
9
9
|
const SERVER_PATH = resolve(__dirname, "../proxy/server.mjs");
|
|
10
10
|
|
|
11
11
|
const args = process.argv.slice(2);
|
|
12
|
+
const SUBCOMMAND = args[0];
|
|
13
|
+
|
|
14
|
+
// Subcommand dispatch (must come before the wrapper-arg parser so subcommand
|
|
15
|
+
// names don't get treated as claude args). Returns null when no subcommand
|
|
16
|
+
// matched, signaling fall-through to wrapper mode below.
|
|
17
|
+
async function dispatch() {
|
|
18
|
+
if (SUBCOMMAND === "server") {
|
|
19
|
+
return new Promise((resolveP) => {
|
|
20
|
+
const serverProc = spawn(process.execPath, [SERVER_PATH, ...args.slice(1)], {
|
|
21
|
+
stdio: "inherit",
|
|
22
|
+
env: process.env,
|
|
23
|
+
});
|
|
24
|
+
serverProc.on("close", (code) => resolveP(code ?? 0));
|
|
25
|
+
serverProc.on("error", (err) => {
|
|
26
|
+
process.stderr.write(`Failed to start proxy server: ${err.message}\n`);
|
|
27
|
+
resolveP(1);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (SUBCOMMAND === "install-service") {
|
|
32
|
+
const force = args.includes("--force");
|
|
33
|
+
const { install } = await import("./install-service.mjs");
|
|
34
|
+
return install({ force });
|
|
35
|
+
}
|
|
36
|
+
if (SUBCOMMAND === "uninstall-service") {
|
|
37
|
+
const { uninstall } = await import("./install-service.mjs");
|
|
38
|
+
return uninstall();
|
|
39
|
+
}
|
|
40
|
+
if (SUBCOMMAND === "--help" || SUBCOMMAND === "-h" || SUBCOMMAND === "help") {
|
|
41
|
+
process.stdout.write(
|
|
42
|
+
"Usage: cache-fix-proxy [subcommand] [args]\n\n" +
|
|
43
|
+
"Subcommands:\n" +
|
|
44
|
+
" (no subcommand) Spawn the proxy + launch claude with ANTHROPIC_BASE_URL set.\n" +
|
|
45
|
+
" Pass any claude args after optional --proxy-port / --proxy-upstream.\n" +
|
|
46
|
+
" server Run just the proxy in the foreground (for systemd/launchd ExecStart).\n" +
|
|
47
|
+
" install-service Install a systemd user service (Linux) or launchd agent (macOS).\n" +
|
|
48
|
+
" Pass --force to overwrite an existing config.\n" +
|
|
49
|
+
" uninstall-service Stop, disable, and remove the installed service.\n" +
|
|
50
|
+
" help Show this help.\n\n" +
|
|
51
|
+
"Wrapper-mode flags:\n" +
|
|
52
|
+
" --proxy-port <N> Port for the spawned proxy (default 9801)\n" +
|
|
53
|
+
" --proxy-upstream <URL> Upstream URL the proxy forwards to (default api.anthropic.com)\n" +
|
|
54
|
+
"\nEnvironment:\n" +
|
|
55
|
+
" CACHE_FIX_PROXY_PORT Port for the proxy server\n" +
|
|
56
|
+
" CACHE_FIX_PROXY_UPSTREAM Upstream URL\n" +
|
|
57
|
+
" CACHE_FIX_DEBUG=1 Verbose proxy logging\n" +
|
|
58
|
+
" CACHE_FIX_CLAUDE_CMD Override the `claude` command for the wrapper\n",
|
|
59
|
+
);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const subcommandExit = await dispatch();
|
|
66
|
+
if (subcommandExit !== null) process.exit(subcommandExit);
|
|
67
|
+
|
|
68
|
+
// No subcommand matched → wrapper mode (back-compat with v3.0.x behavior).
|
|
12
69
|
let proxyPort = 9801;
|
|
13
70
|
let proxyUpstream = undefined;
|
|
14
71
|
const claudeArgs = [];
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// install-service / uninstall-service subcommands.
|
|
2
|
+
//
|
|
3
|
+
// Detects platform and installs an appropriate service definition for the
|
|
4
|
+
// cache-fix proxy:
|
|
5
|
+
// - linux → systemd user service at ~/.config/systemd/user/cache-fix-proxy.service
|
|
6
|
+
// - darwin → launchd agent at ~/Library/LaunchAgents/com.cnighswonger.cache-fix-proxy.plist
|
|
7
|
+
// - other → prints manual instructions and exits non-zero
|
|
8
|
+
//
|
|
9
|
+
// Pure helpers exported for tests; orchestration lives in main().
|
|
10
|
+
|
|
11
|
+
import { readFile, writeFile, mkdir, unlink, stat } from "node:fs/promises";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { dirname, resolve, join } from "node:path";
|
|
15
|
+
import { homedir, platform } from "node:os";
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const TEMPLATE_DIR = resolve(__dirname, "..", "templates");
|
|
19
|
+
const SERVER_PATH = resolve(__dirname, "..", "proxy", "server.mjs");
|
|
20
|
+
|
|
21
|
+
function getDefaults() {
|
|
22
|
+
return {
|
|
23
|
+
port: validatePort(process.env.CACHE_FIX_PROXY_PORT || "9801"),
|
|
24
|
+
upstream: process.env.CACHE_FIX_PROXY_UPSTREAM || "",
|
|
25
|
+
debug: process.env.CACHE_FIX_DEBUG || "",
|
|
26
|
+
workingDir: resolve(__dirname, ".."),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Validate that a port string is a plain decimal integer in [1, 65535].
|
|
31
|
+
// We render this value into both a systemd Environment= line (safe) AND a
|
|
32
|
+
// /bin/sh -c command in the healthcheck oneshot — DANGEROUS without
|
|
33
|
+
// validation: shell metacharacters or quotes in a port string would let a
|
|
34
|
+
// hostile env var change the executed command. Throw on invalid input so
|
|
35
|
+
// callers report it cleanly via reportFsError.
|
|
36
|
+
function validatePort(raw) {
|
|
37
|
+
if (typeof raw !== "string" && typeof raw !== "number") {
|
|
38
|
+
throw new InvalidPortError(`port must be a number or numeric string, got ${typeof raw}`);
|
|
39
|
+
}
|
|
40
|
+
const s = String(raw).trim();
|
|
41
|
+
if (!/^\d+$/.test(s)) {
|
|
42
|
+
throw new InvalidPortError(`port must be a positive integer (got ${JSON.stringify(raw)})`);
|
|
43
|
+
}
|
|
44
|
+
const n = Number(s);
|
|
45
|
+
if (n < 1 || n > 65535) {
|
|
46
|
+
throw new InvalidPortError(`port must be in 1..65535 (got ${n})`);
|
|
47
|
+
}
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class InvalidPortError extends Error {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "InvalidPortError";
|
|
55
|
+
this.code = "EINVAL";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getPaths(plat = platform()) {
|
|
60
|
+
if (plat === "linux") {
|
|
61
|
+
return {
|
|
62
|
+
kind: "systemd",
|
|
63
|
+
configDir: join(homedir(), ".config", "systemd", "user"),
|
|
64
|
+
configFile: "cache-fix-proxy.service",
|
|
65
|
+
label: "cache-fix-proxy",
|
|
66
|
+
// Healthcheck companion units (oneshot service + timer) for
|
|
67
|
+
// auto-recovery if the proxy is ever stopped from any cause:
|
|
68
|
+
// a crash, an external `systemctl stop`, an OOM, anything.
|
|
69
|
+
// The timer runs the service every 2 minutes; the oneshot does
|
|
70
|
+
// a curl /health probe and `systemctl --user start` if it fails.
|
|
71
|
+
healthcheckServiceFile: "cache-fix-proxy-healthcheck.service",
|
|
72
|
+
healthcheckTimerFile: "cache-fix-proxy-healthcheck.timer",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (plat === "darwin") {
|
|
76
|
+
return {
|
|
77
|
+
kind: "launchd",
|
|
78
|
+
configDir: join(homedir(), "Library", "LaunchAgents"),
|
|
79
|
+
configFile: "com.cnighswonger.cache-fix-proxy.plist",
|
|
80
|
+
label: "com.cnighswonger.cache-fix-proxy",
|
|
81
|
+
logDir: join(homedir(), "Library", "Logs"),
|
|
82
|
+
// launchd's KeepAlive already auto-restarts the agent on any exit
|
|
83
|
+
// (clean or unclean), so a separate healthcheck isn't needed on macOS.
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { kind: "unsupported", platform: plat };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderSystemdTemplate(template, vars) {
|
|
90
|
+
const upstreamLine = vars.upstream
|
|
91
|
+
? `Environment=CACHE_FIX_PROXY_UPSTREAM=${vars.upstream}`
|
|
92
|
+
: "";
|
|
93
|
+
const debugLine = vars.debug
|
|
94
|
+
? `Environment=CACHE_FIX_DEBUG=${vars.debug}`
|
|
95
|
+
: "";
|
|
96
|
+
// Allow callers to wire a Requires= line (e.g. another service the proxy
|
|
97
|
+
// chains to). Empty string by default so the unit has no extra deps.
|
|
98
|
+
const requiresLine = vars.requires
|
|
99
|
+
? `Requires=${vars.requires}\nAfter=${vars.requires}`
|
|
100
|
+
: "";
|
|
101
|
+
return template
|
|
102
|
+
.replaceAll("{{NODE}}", vars.node)
|
|
103
|
+
.replaceAll("{{SERVER_PATH}}", vars.serverPath)
|
|
104
|
+
.replaceAll("{{PORT}}", vars.port)
|
|
105
|
+
.replaceAll("{{UPSTREAM_LINE}}", upstreamLine)
|
|
106
|
+
.replaceAll("{{DEBUG_LINE}}", debugLine)
|
|
107
|
+
.replaceAll("{{REQUIRES_LINE}}", requiresLine)
|
|
108
|
+
.replaceAll("{{WORKING_DIR}}", vars.workingDir)
|
|
109
|
+
// Collapse triple newlines from empty optional lines down to single blank
|
|
110
|
+
.replace(/\n\n\n+/g, "\n\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderLaunchdTemplate(template, vars) {
|
|
114
|
+
const upstreamPlist = vars.upstream
|
|
115
|
+
? ` <key>CACHE_FIX_PROXY_UPSTREAM</key>\n <string>${vars.upstream}</string>`
|
|
116
|
+
: "";
|
|
117
|
+
const debugPlist = vars.debug
|
|
118
|
+
? ` <key>CACHE_FIX_DEBUG</key>\n <string>${vars.debug}</string>`
|
|
119
|
+
: "";
|
|
120
|
+
return template
|
|
121
|
+
.replaceAll("{{NODE}}", vars.node)
|
|
122
|
+
.replaceAll("{{SERVER_PATH}}", vars.serverPath)
|
|
123
|
+
.replaceAll("{{PORT}}", vars.port)
|
|
124
|
+
.replaceAll("{{UPSTREAM_PLIST}}", upstreamPlist)
|
|
125
|
+
.replaceAll("{{DEBUG_PLIST}}", debugPlist)
|
|
126
|
+
.replaceAll("{{WORKING_DIR}}", vars.workingDir)
|
|
127
|
+
.replaceAll("{{LOG_DIR}}", vars.logDir)
|
|
128
|
+
.replace(/\n\n+/g, "\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderHealthcheckServiceTemplate(template, vars) {
|
|
132
|
+
return template.replaceAll("{{PORT}}", vars.port);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderHealthcheckTimerTemplate(template) {
|
|
136
|
+
// No placeholders today, but keep the function for symmetry + future expansion.
|
|
137
|
+
return template;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function fileExists(path) {
|
|
141
|
+
try {
|
|
142
|
+
await stat(path);
|
|
143
|
+
return true;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runCmd(cmd, args, opts = {}) {
|
|
150
|
+
return new Promise((resolveP) => {
|
|
151
|
+
const p = spawn(cmd, args, { stdio: "inherit", ...opts });
|
|
152
|
+
p.on("close", (code) => resolveP(code ?? 0));
|
|
153
|
+
p.on("error", () => resolveP(127));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function installSystemd({ paths, defaults, force = false } = {}) {
|
|
158
|
+
paths = paths || getPaths("linux");
|
|
159
|
+
defaults = defaults || getDefaults();
|
|
160
|
+
const targetPath = join(paths.configDir, paths.configFile);
|
|
161
|
+
if ((await fileExists(targetPath)) && !force) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
reason: "already-installed",
|
|
165
|
+
path: targetPath,
|
|
166
|
+
hint: "Re-run with --force to overwrite, or `cache-fix-proxy uninstall-service` first.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const template = await readFile(
|
|
170
|
+
join(TEMPLATE_DIR, "cache-fix-proxy.service.template"),
|
|
171
|
+
"utf-8",
|
|
172
|
+
);
|
|
173
|
+
const rendered = renderSystemdTemplate(template, {
|
|
174
|
+
node: process.execPath,
|
|
175
|
+
serverPath: SERVER_PATH,
|
|
176
|
+
port: defaults.port,
|
|
177
|
+
upstream: defaults.upstream,
|
|
178
|
+
debug: defaults.debug,
|
|
179
|
+
workingDir: defaults.workingDir,
|
|
180
|
+
requires: "",
|
|
181
|
+
});
|
|
182
|
+
await mkdir(paths.configDir, { recursive: true });
|
|
183
|
+
await writeFile(targetPath, rendered);
|
|
184
|
+
|
|
185
|
+
// Healthcheck companion: oneshot service + timer. Auto-recovery from any
|
|
186
|
+
// proxy stop, including clean stops where Restart=on-failure does NOT fire
|
|
187
|
+
// (see incident analysis: 2026-04-25 01:46:53 UTC — proxy was stopped by
|
|
188
|
+
// an unidentified caller during the Anthropic outage and stayed down for
|
|
189
|
+
// 10 hours because no auto-recovery existed).
|
|
190
|
+
//
|
|
191
|
+
// If the healthcheck install fails (template missing, write error, etc.),
|
|
192
|
+
// roll back the main unit so the user isn't left in a half-installed
|
|
193
|
+
// state. Without rollback the proxy unit would exist but the auto-recovery
|
|
194
|
+
// story we just promised in the install message would be incomplete.
|
|
195
|
+
let healthcheckPaths;
|
|
196
|
+
try {
|
|
197
|
+
healthcheckPaths = await installSystemdHealthcheck({ paths, defaults, force });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
try {
|
|
200
|
+
await unlink(targetPath);
|
|
201
|
+
} catch {
|
|
202
|
+
/* best-effort rollback */
|
|
203
|
+
}
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
path: targetPath,
|
|
210
|
+
healthcheck: healthcheckPaths,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function installSystemdHealthcheck({ paths, defaults, force = false } = {}) {
|
|
215
|
+
paths = paths || getPaths("linux");
|
|
216
|
+
defaults = defaults || getDefaults();
|
|
217
|
+
const servicePath = join(paths.configDir, paths.healthcheckServiceFile);
|
|
218
|
+
const timerPath = join(paths.configDir, paths.healthcheckTimerFile);
|
|
219
|
+
|
|
220
|
+
// Symmetric existence check: if EITHER the service file OR the timer file
|
|
221
|
+
// already exists, refuse to overwrite without force. Catches both the
|
|
222
|
+
// "service exists, timer missing" and "timer exists, service missing"
|
|
223
|
+
// half-states — those are the artifacts most likely to need explicit
|
|
224
|
+
// operator review (e.g. a previous install crashed mid-write, or the
|
|
225
|
+
// operator has hand-edited one of the two).
|
|
226
|
+
const serviceExists = await fileExists(servicePath);
|
|
227
|
+
const timerExists = await fileExists(timerPath);
|
|
228
|
+
if ((serviceExists || timerExists) && !force) {
|
|
229
|
+
const which = serviceExists && timerExists
|
|
230
|
+
? "both files"
|
|
231
|
+
: serviceExists
|
|
232
|
+
? "service file"
|
|
233
|
+
: "timer file";
|
|
234
|
+
return {
|
|
235
|
+
installed: false,
|
|
236
|
+
reason: "already-installed",
|
|
237
|
+
servicePath,
|
|
238
|
+
timerPath,
|
|
239
|
+
hint: `${which} already present. Re-run with --force to overwrite, or \`cache-fix-proxy uninstall-service\` first.`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const serviceTpl = await readFile(
|
|
244
|
+
join(TEMPLATE_DIR, "cache-fix-proxy-healthcheck.service.template"),
|
|
245
|
+
"utf-8",
|
|
246
|
+
);
|
|
247
|
+
const timerTpl = await readFile(
|
|
248
|
+
join(TEMPLATE_DIR, "cache-fix-proxy-healthcheck.timer.template"),
|
|
249
|
+
"utf-8",
|
|
250
|
+
);
|
|
251
|
+
await writeFile(servicePath, renderHealthcheckServiceTemplate(serviceTpl, { port: defaults.port }));
|
|
252
|
+
await writeFile(timerPath, renderHealthcheckTimerTemplate(timerTpl));
|
|
253
|
+
return { installed: true, servicePath, timerPath };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function installLaunchd({ paths, defaults, force = false } = {}) {
|
|
257
|
+
paths = paths || getPaths("darwin");
|
|
258
|
+
defaults = defaults || getDefaults();
|
|
259
|
+
const targetPath = join(paths.configDir, paths.configFile);
|
|
260
|
+
if ((await fileExists(targetPath)) && !force) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
reason: "already-installed",
|
|
264
|
+
path: targetPath,
|
|
265
|
+
hint: "Re-run with --force to overwrite, or `cache-fix-proxy uninstall-service` first.",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const template = await readFile(
|
|
269
|
+
join(TEMPLATE_DIR, "com.cnighswonger.cache-fix-proxy.plist.template"),
|
|
270
|
+
"utf-8",
|
|
271
|
+
);
|
|
272
|
+
const rendered = renderLaunchdTemplate(template, {
|
|
273
|
+
node: process.execPath,
|
|
274
|
+
serverPath: SERVER_PATH,
|
|
275
|
+
port: defaults.port,
|
|
276
|
+
upstream: defaults.upstream,
|
|
277
|
+
debug: defaults.debug,
|
|
278
|
+
workingDir: defaults.workingDir,
|
|
279
|
+
logDir: paths.logDir,
|
|
280
|
+
});
|
|
281
|
+
await mkdir(paths.configDir, { recursive: true });
|
|
282
|
+
await writeFile(targetPath, rendered);
|
|
283
|
+
return { ok: true, path: targetPath };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function uninstallSystemd({ paths } = {}) {
|
|
287
|
+
paths = paths || getPaths("linux");
|
|
288
|
+
const targetPath = join(paths.configDir, paths.configFile);
|
|
289
|
+
if (!(await fileExists(targetPath))) {
|
|
290
|
+
return { ok: false, reason: "not-installed", path: targetPath };
|
|
291
|
+
}
|
|
292
|
+
await unlink(targetPath);
|
|
293
|
+
// Also remove the healthcheck companion if it exists. Best-effort —
|
|
294
|
+
// missing files are not an error here.
|
|
295
|
+
const healthcheckRemoved = await uninstallSystemdHealthcheck({ paths });
|
|
296
|
+
return { ok: true, path: targetPath, healthcheck: healthcheckRemoved };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function uninstallSystemdHealthcheck({ paths } = {}) {
|
|
300
|
+
paths = paths || getPaths("linux");
|
|
301
|
+
const servicePath = join(paths.configDir, paths.healthcheckServiceFile);
|
|
302
|
+
const timerPath = join(paths.configDir, paths.healthcheckTimerFile);
|
|
303
|
+
let removed = 0;
|
|
304
|
+
for (const p of [timerPath, servicePath]) {
|
|
305
|
+
if (await fileExists(p)) {
|
|
306
|
+
try {
|
|
307
|
+
await unlink(p);
|
|
308
|
+
removed++;
|
|
309
|
+
} catch {
|
|
310
|
+
/* best-effort */
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { removed, servicePath, timerPath };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function uninstallLaunchd({ paths } = {}) {
|
|
318
|
+
paths = paths || getPaths("darwin");
|
|
319
|
+
const targetPath = join(paths.configDir, paths.configFile);
|
|
320
|
+
if (!(await fileExists(targetPath))) {
|
|
321
|
+
return { ok: false, reason: "not-installed", path: targetPath };
|
|
322
|
+
}
|
|
323
|
+
await unlink(targetPath);
|
|
324
|
+
return { ok: true, path: targetPath };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function install({ force = false } = {}) {
|
|
328
|
+
const paths = getPaths();
|
|
329
|
+
if (paths.kind === "unsupported") {
|
|
330
|
+
process.stderr.write(
|
|
331
|
+
`[install-service] Unsupported platform: ${paths.platform}\n` +
|
|
332
|
+
`Manual install: run \`node ${SERVER_PATH}\` under your platform's service manager.\n`,
|
|
333
|
+
);
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
if (paths.kind === "systemd") {
|
|
337
|
+
let r;
|
|
338
|
+
try {
|
|
339
|
+
r = await installSystemd({ paths, force });
|
|
340
|
+
} catch (err) {
|
|
341
|
+
return reportFsError("install-service", err);
|
|
342
|
+
}
|
|
343
|
+
if (!r.ok) {
|
|
344
|
+
process.stderr.write(`[install-service] ${r.reason}: ${r.path}\n`);
|
|
345
|
+
if (r.hint) process.stderr.write(` ${r.hint}\n`);
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
const hcLines =
|
|
349
|
+
r.healthcheck?.installed
|
|
350
|
+
? `Wrote healthcheck companion: ${r.healthcheck.servicePath}\n` +
|
|
351
|
+
`Wrote healthcheck timer: ${r.healthcheck.timerPath}\n\n`
|
|
352
|
+
: r.healthcheck?.reason === "already-installed"
|
|
353
|
+
? `Healthcheck companion already installed (use --force to overwrite).\n\n`
|
|
354
|
+
: "";
|
|
355
|
+
process.stdout.write(
|
|
356
|
+
`Wrote systemd unit: ${r.path}\n` +
|
|
357
|
+
hcLines +
|
|
358
|
+
`Next steps:\n` +
|
|
359
|
+
` systemctl --user daemon-reload\n` +
|
|
360
|
+
` systemctl --user enable --now cache-fix-proxy\n` +
|
|
361
|
+
` systemctl --user enable --now cache-fix-proxy-healthcheck.timer # auto-recovery if proxy is ever stopped\n` +
|
|
362
|
+
` loginctl enable-linger ${process.env.USER || "<your-user>"} # optional: start on boot vs login\n`,
|
|
363
|
+
);
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
if (paths.kind === "launchd") {
|
|
367
|
+
let r;
|
|
368
|
+
try {
|
|
369
|
+
r = await installLaunchd({ paths, force });
|
|
370
|
+
} catch (err) {
|
|
371
|
+
return reportFsError("install-service", err);
|
|
372
|
+
}
|
|
373
|
+
if (!r.ok) {
|
|
374
|
+
process.stderr.write(`[install-service] ${r.reason}: ${r.path}\n`);
|
|
375
|
+
if (r.hint) process.stderr.write(` ${r.hint}\n`);
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
process.stdout.write(
|
|
379
|
+
`Wrote launchd plist: ${r.path}\n\n` +
|
|
380
|
+
`Next steps:\n` +
|
|
381
|
+
` launchctl bootstrap gui/$(id -u) ${r.path}\n` +
|
|
382
|
+
` launchctl enable gui/$(id -u)/com.cnighswonger.cache-fix-proxy\n` +
|
|
383
|
+
` launchctl kickstart gui/$(id -u)/com.cnighswonger.cache-fix-proxy\n`,
|
|
384
|
+
);
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
return 1;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Translate raw fs / validation errors into operator-friendly one-liners.
|
|
391
|
+
// Returns the exit code so callers can pass it straight back.
|
|
392
|
+
function reportFsError(prefix, err) {
|
|
393
|
+
const code = err?.code ?? "";
|
|
394
|
+
let hint = "";
|
|
395
|
+
if (err?.name === "InvalidPortError") hint = err.message;
|
|
396
|
+
else if (code === "ENOENT") hint = "file or directory not found";
|
|
397
|
+
else if (code === "EACCES" || code === "EPERM") hint = "permission denied";
|
|
398
|
+
else if (code === "ENOSPC") hint = "no space left on device";
|
|
399
|
+
else hint = err?.message || String(err);
|
|
400
|
+
process.stderr.write(`[${prefix}] ${hint}${err?.path ? `: ${err.path}` : ""}\n`);
|
|
401
|
+
return 1;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function uninstall() {
|
|
405
|
+
const paths = getPaths();
|
|
406
|
+
if (paths.kind === "unsupported") {
|
|
407
|
+
process.stderr.write(`[uninstall-service] Unsupported platform: ${paths.platform}\n`);
|
|
408
|
+
return 1;
|
|
409
|
+
}
|
|
410
|
+
if (paths.kind === "systemd") {
|
|
411
|
+
// Best-effort stop + disable for the healthcheck companion FIRST so it
|
|
412
|
+
// doesn't immediately restart the proxy we're about to stop.
|
|
413
|
+
await runCmd("systemctl", ["--user", "stop", "cache-fix-proxy-healthcheck.timer"]);
|
|
414
|
+
await runCmd("systemctl", ["--user", "disable", "cache-fix-proxy-healthcheck.timer"]);
|
|
415
|
+
// Then stop + disable the main service.
|
|
416
|
+
await runCmd("systemctl", ["--user", "stop", "cache-fix-proxy"]);
|
|
417
|
+
await runCmd("systemctl", ["--user", "disable", "cache-fix-proxy"]);
|
|
418
|
+
let r;
|
|
419
|
+
try {
|
|
420
|
+
r = await uninstallSystemd({ paths });
|
|
421
|
+
} catch (err) {
|
|
422
|
+
return reportFsError("uninstall-service", err);
|
|
423
|
+
}
|
|
424
|
+
if (!r.ok) {
|
|
425
|
+
process.stderr.write(`[uninstall-service] ${r.reason}: ${r.path}\n`);
|
|
426
|
+
return 1;
|
|
427
|
+
}
|
|
428
|
+
await runCmd("systemctl", ["--user", "daemon-reload"]);
|
|
429
|
+
const hcMsg =
|
|
430
|
+
r.healthcheck?.removed > 0
|
|
431
|
+
? ` (+ ${r.healthcheck.removed} healthcheck file${r.healthcheck.removed === 1 ? "" : "s"})`
|
|
432
|
+
: "";
|
|
433
|
+
process.stdout.write(`Removed: ${r.path}${hcMsg}\n`);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
if (paths.kind === "launchd") {
|
|
437
|
+
const targetPath = join(paths.configDir, paths.configFile);
|
|
438
|
+
await runCmd("launchctl", ["bootout", `gui/${process.getuid()}`, targetPath]);
|
|
439
|
+
let r;
|
|
440
|
+
try {
|
|
441
|
+
r = await uninstallLaunchd({ paths });
|
|
442
|
+
} catch (err) {
|
|
443
|
+
return reportFsError("uninstall-service", err);
|
|
444
|
+
}
|
|
445
|
+
if (!r.ok) {
|
|
446
|
+
process.stderr.write(`[uninstall-service] ${r.reason}: ${r.path}\n`);
|
|
447
|
+
return 1;
|
|
448
|
+
}
|
|
449
|
+
process.stdout.write(`Removed: ${r.path}\n`);
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
return 1;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export {
|
|
456
|
+
// Pure helpers (test surface)
|
|
457
|
+
renderSystemdTemplate,
|
|
458
|
+
renderLaunchdTemplate,
|
|
459
|
+
renderHealthcheckServiceTemplate,
|
|
460
|
+
renderHealthcheckTimerTemplate,
|
|
461
|
+
getPaths,
|
|
462
|
+
getDefaults,
|
|
463
|
+
validatePort,
|
|
464
|
+
InvalidPortError,
|
|
465
|
+
installSystemd,
|
|
466
|
+
installSystemdHealthcheck,
|
|
467
|
+
installLaunchd,
|
|
468
|
+
uninstallSystemd,
|
|
469
|
+
uninstallSystemdHealthcheck,
|
|
470
|
+
uninstallLaunchd,
|
|
471
|
+
// Orchestration
|
|
472
|
+
install,
|
|
473
|
+
uninstall,
|
|
474
|
+
TEMPLATE_DIR,
|
|
475
|
+
SERVER_PATH,
|
|
476
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-cache-fix",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Cache optimization proxy and interceptor for Claude Code. Fixes prompt cache bugs, stabilizes prefix, reduces quota burn.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": "./preload.mjs",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"tools/",
|
|
15
15
|
"claude-fixed.bat",
|
|
16
16
|
"proxy/",
|
|
17
|
-
"bin/"
|
|
17
|
+
"bin/",
|
|
18
|
+
"templates/"
|
|
18
19
|
],
|
|
19
20
|
"engines": {
|
|
20
21
|
"node": ">=18"
|