claude-code-cache-fix 3.1.0 → 3.1.1

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 CHANGED
@@ -45,40 +45,44 @@ Extensions are hot-reloadable — add, remove, or modify `.mjs` files in `proxy/
45
45
 
46
46
  ### Running as a service
47
47
 
48
- **Linux (systemdrecommended):**
48
+ **Recommended (Linux/macOS)`install-service` subcommand:**
49
49
 
50
- Create `~/.config/systemd/user/cache-fix-proxy.service`:
50
+ ```bash
51
+ cache-fix-proxy install-service
52
+ ```
51
53
 
52
- ```ini
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
- [Service]
58
- Type=simple
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
- [Install]
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
+ ```
71
67
 
72
- # Optional: start on boot (before login)
73
- sudo loginctl enable-linger $USER
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
+
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
- A `cache-fix-proxy install-service` subcommand is planned for v3.1.0 ([#48](https://github.com/cnighswonger/claude-code-cache-fix/issues/48)).
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
- **Fallback (any OS):**
82
+ **Manual (any platform):**
79
83
 
80
84
  ```bash
81
- nohup node "$(npm root -g)/claude-code-cache-fix/proxy/server.mjs" > /tmp/cache-fix-proxy.log 2>&1 &
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
 
@@ -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.1.0",
3
+ "version": "3.1.1",
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"
@@ -0,0 +1,7 @@
1
+ [Unit]
2
+ Description=Claude Code Cache Fix Proxy — health check + auto-restart
3
+ After=network.target
4
+
5
+ [Service]
6
+ Type=oneshot
7
+ ExecStart=/bin/sh -c 'curl -fs --max-time 3 http://127.0.0.1:{{PORT}}/health > /dev/null || systemctl --user start cache-fix-proxy.service'
@@ -0,0 +1,14 @@
1
+ [Unit]
2
+ Description=Claude Code Cache Fix Proxy — health check timer (every 2 min)
3
+ Documentation=https://github.com/cnighswonger/claude-code-cache-fix
4
+
5
+ [Timer]
6
+ # Run 30s after boot, then every 2 minutes thereafter. Off-round minute
7
+ # so the firing distribution doesn't pile up on :00.
8
+ OnBootSec=30s
9
+ OnUnitActiveSec=2min
10
+ AccuracySec=15s
11
+ Unit=cache-fix-proxy-healthcheck.service
12
+
13
+ [Install]
14
+ WantedBy=timers.target
@@ -0,0 +1,17 @@
1
+ [Unit]
2
+ Description=Claude Code Cache Fix Proxy
3
+ After=network.target
4
+ {{REQUIRES_LINE}}
5
+
6
+ [Service]
7
+ Type=simple
8
+ ExecStart={{NODE}} {{SERVER_PATH}}
9
+ Restart=on-failure
10
+ RestartSec=5
11
+ Environment=CACHE_FIX_PROXY_PORT={{PORT}}
12
+ {{UPSTREAM_LINE}}
13
+ {{DEBUG_LINE}}
14
+ WorkingDirectory={{WORKING_DIR}}
15
+
16
+ [Install]
17
+ WantedBy=default.target
@@ -0,0 +1,33 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.cnighswonger.cache-fix-proxy</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>{{NODE}}</string>
10
+ <string>{{SERVER_PATH}}</string>
11
+ </array>
12
+ <key>EnvironmentVariables</key>
13
+ <dict>
14
+ <key>CACHE_FIX_PROXY_PORT</key>
15
+ <string>{{PORT}}</string>
16
+ {{UPSTREAM_PLIST}}
17
+ {{DEBUG_PLIST}}
18
+ </dict>
19
+ <key>WorkingDirectory</key>
20
+ <string>{{WORKING_DIR}}</string>
21
+ <key>RunAtLoad</key>
22
+ <true/>
23
+ <key>KeepAlive</key>
24
+ <dict>
25
+ <key>SuccessfulExit</key>
26
+ <false/>
27
+ </dict>
28
+ <key>StandardOutPath</key>
29
+ <string>{{LOG_DIR}}/cache-fix-proxy.log</string>
30
+ <key>StandardErrorPath</key>
31
+ <string>{{LOG_DIR}}/cache-fix-proxy.err</string>
32
+ </dict>
33
+ </plist>