@bitkyc08/opencodex 0.2.0 → 0.2.2

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.
@@ -6,7 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <meta name="color-scheme" content="dark" />
8
8
  <title>opencodex · proxy dashboard</title>
9
- <script type="module" crossorigin src="/assets/index-C9y3iMF1.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-Dt5t57MW.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-C1wlp1SM.css">
11
11
  </head>
12
12
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { execFileSync } from "node:child_process";
2
3
  import { restoreNativeCodex } from "./codex-inject";
3
4
  import { loadConfig, readPid, removePid, writePid } from "./config";
4
5
  import { serviceCommand } from "./service";
@@ -37,9 +38,12 @@ async function syncModelsToCodex(port?: number) {
37
38
  const { injectCodexConfig } = await import("./codex-inject");
38
39
  const result = await injectCodexConfig(p, config);
39
40
  try {
40
- const { syncCatalogModels } = await import("./codex-catalog");
41
+ const { invalidateCodexModelsCache, syncCatalogModels } = await import("./codex-catalog");
41
42
  const cat = await syncCatalogModels(config);
42
- if (cat.added > 0) console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
43
+ if (cat.added > 0) {
44
+ invalidateCodexModelsCache();
45
+ console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
46
+ }
43
47
  } catch (e) {
44
48
  console.error("catalog sync skipped:", e instanceof Error ? e.message : String(e));
45
49
  }
@@ -85,32 +89,59 @@ function handleStart() {
85
89
  }
86
90
 
87
91
  function killProxy(pid: number): void {
92
+ if (!isProcessAlive(pid)) return;
88
93
  if (process.platform === "win32") {
94
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
89
95
  try {
90
- (require("node:child_process") as typeof import("node:child_process"))
91
- .execSync(`taskkill /PID ${pid} /T /F`, { stdio: "pipe" });
92
- } catch { /* process already gone */ }
96
+ execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe" });
97
+ } catch (err) {
98
+ if (isProcessAlive(pid)) throw err;
99
+ }
93
100
  } else {
94
101
  process.kill(pid, "SIGTERM");
102
+ if (!waitForExit(pid, 5000)) process.kill(pid, "SIGKILL");
103
+ }
104
+ if (!waitForExit(pid, 5000)) throw new Error(`process ${pid} did not exit`);
105
+ }
106
+
107
+ function isProcessAlive(pid: number): boolean {
108
+ try {
109
+ process.kill(pid, 0);
110
+ return true;
111
+ } catch {
112
+ return false;
95
113
  }
96
114
  }
97
115
 
116
+ function waitForExit(pid: number, timeoutMs: number): boolean {
117
+ const deadline = Date.now() + timeoutMs;
118
+ const marker = new Int32Array(new SharedArrayBuffer(4));
119
+ while (Date.now() < deadline) {
120
+ if (!isProcessAlive(pid)) return true;
121
+ Atomics.wait(marker, 0, 0, 50);
122
+ }
123
+ return !isProcessAlive(pid);
124
+ }
125
+
98
126
  function handleStop() {
99
127
  const pid = readPid();
128
+ let stopFailed = false;
100
129
  if (pid) {
101
130
  try {
102
131
  killProxy(pid);
103
132
  console.log(`✅ Proxy (PID ${pid}) stopped.`);
133
+ removePid();
104
134
  } catch {
105
- console.log("Proxy process not found.");
135
+ stopFailed = true;
136
+ console.error(`❌ Failed to stop proxy (PID ${pid}).`);
106
137
  }
107
- removePid();
108
138
  } else {
109
139
  console.log("No running proxy found.");
110
140
  }
111
141
  // Recover native Codex so plain `codex` keeps working while the proxy is down.
112
142
  const r = restoreNativeCodex();
113
143
  console.log(`↩️ ${r.message}`);
144
+ if (stopFailed) process.exit(1);
114
145
  }
115
146
 
116
147
  function handleStatus() {
@@ -2,12 +2,11 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFil
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { atomicWriteFile } from "./config";
5
+ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./codex-paths";
5
6
  import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
6
7
  import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
7
8
  import type { OcxConfig, OcxProviderConfig } from "./types";
8
9
 
9
- const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
10
- const DEFAULT_CATALOG_PATH = join(homedir(), ".codex", "opencodex-catalog.json");
11
10
  const OCX_DIR = join(homedir(), ".opencodex");
12
11
  const CATALOG_BACKUP_PATH = join(OCX_DIR, "catalog-backup.json");
13
12
 
@@ -40,8 +39,8 @@ export function readCodexCatalogPath(): string {
40
39
  try {
41
40
  if (existsSync(CODEX_CONFIG_PATH)) {
42
41
  const toml = readFileSync(CODEX_CONFIG_PATH, "utf-8");
43
- const m = toml.match(/^\s*model_catalog_json\s*=\s*"([^"]+)"/m);
44
- if (m) return m[1];
42
+ const path = readRootTomlString(toml, "model_catalog_json");
43
+ if (path) return resolveCodexConfigPath(path);
45
44
  }
46
45
  } catch { /* ignore */ }
47
46
  return DEFAULT_CATALOG_PATH;
@@ -55,13 +54,36 @@ function readCatalog(path: string): { models?: RawEntry[]; [k: string]: unknown
55
54
  } catch { return null; }
56
55
  }
57
56
 
57
+ function normalizeServiceTiers(entry: RawEntry): RawEntry {
58
+ if (entry.service_tier === "priority") entry.service_tier = "fast";
59
+ if (Array.isArray(entry.service_tiers)) {
60
+ entry.service_tiers = entry.service_tiers.map(tier => {
61
+ if (tier && typeof tier === "object" && "id" in tier && tier.id === "priority") {
62
+ return { ...tier, id: "fast" };
63
+ }
64
+ return tier;
65
+ });
66
+ }
67
+ return entry;
68
+ }
69
+
70
+ function loadCatalogForSync(path: string): { models?: RawEntry[]; [k: string]: unknown } | null {
71
+ const catalog = readCatalog(path);
72
+ if (catalog) return catalog;
73
+ return readCatalog(CODEX_MODELS_CACHE_PATH);
74
+ }
75
+
76
+ function readCurrentCatalogOrCache(): { models?: RawEntry[]; [k: string]: unknown } | null {
77
+ return readCatalog(readCodexCatalogPath()) ?? readCatalog(CODEX_MODELS_CACHE_PATH);
78
+ }
79
+
58
80
  /**
59
81
  * A full native entry from the on-disk catalog, used as a clone template so injected
60
82
  * entries carry EVERY field Codex's strict parser requires (e.g. `base_instructions`).
61
83
  * Returns a deep copy, or null if no catalog/native entry exists.
62
84
  */
63
85
  export function loadCatalogTemplate(): RawEntry | null {
64
- const cat = readCatalog(readCodexCatalogPath());
86
+ const cat = readCurrentCatalogOrCache();
65
87
  const native = cat?.models?.find(
66
88
  m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
67
89
  );
@@ -109,16 +131,16 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
109
131
  e.supported_reasoning_levels = ROUTED_REASONING_LEVELS.map(l => byEffort.get(l.effort) ?? { ...l });
110
132
  e.default_reasoning_level = "medium";
111
133
  }
112
- return e;
134
+ return normalizeServiceTiers(e);
113
135
  }
114
136
  // Fallback when no template is available (best-effort; strict parser may need more).
115
- return {
137
+ return normalizeServiceTiers({
116
138
  slug, display_name: slug, description: desc,
117
139
  default_reasoning_level: "medium",
118
140
  supported_reasoning_levels: ROUTED_REASONING_LEVELS.map(l => ({ ...l })),
119
141
  shell_type: "shell_command", visibility: "list", supported_in_api: true,
120
142
  priority, base_instructions: "You are a helpful coding assistant.",
121
- };
143
+ });
122
144
  }
123
145
 
124
146
  /**
@@ -149,7 +171,7 @@ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[
149
171
 
150
172
  /** Bare picker-visible native slugs in the live Codex catalog (drives the subagent picker UI). */
151
173
  export function listCatalogNativeSlugs(): string[] {
152
- const cat = readCatalog(readCodexCatalogPath());
174
+ const cat = readCurrentCatalogOrCache();
153
175
  return (cat?.models ?? [])
154
176
  .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list")
155
177
  .map(m => m.slug as string);
@@ -244,7 +266,7 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
244
266
  */
245
267
  export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> {
246
268
  const catalogPath = readCodexCatalogPath();
247
- const catalog = readCatalog(catalogPath);
269
+ const catalog = loadCatalogForSync(catalogPath);
248
270
  if (!catalog) return { added: 0, path: catalogPath };
249
271
 
250
272
  const template = (catalog.models ?? []).find(
@@ -272,9 +294,9 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
272
294
  .map(m => {
273
295
  const slug = m.slug as string;
274
296
  const priority = rank.has(slug) ? rank.get(slug)! : (baseline.get(slug) ?? (m.priority as number));
275
- return { ...m, priority };
297
+ return normalizeServiceTiers({ ...m, priority });
276
298
  });
277
- catalog.models = [...native, ...goEntries];
299
+ catalog.models = [...native, ...goEntries].map(m => normalizeServiceTiers(m));
278
300
 
279
301
  try {
280
302
  if (!existsSync(OCX_DIR)) mkdirSync(OCX_DIR, { recursive: true });
@@ -306,13 +328,12 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
306
328
  }
307
329
 
308
330
  /**
309
- * Delete Codex's models cache (~/.codex/models_cache.json) so the next turn re-fetches /v1/models.
331
+ * Delete Codex's models cache ($CODEX_HOME/models_cache.json) so the next turn re-fetches /v1/models.
310
332
  * Codex caches the model list for 5 min (DEFAULT_MODEL_CACHE_TTL); invalidating makes catalog edits
311
333
  * (enable/disable, subagent reorder) apply on the next turn instead of waiting for the TTL.
312
334
  */
313
335
  export function invalidateCodexModelsCache(): void {
314
336
  try {
315
- const p = join(homedir(), ".codex", "models_cache.json");
316
- if (existsSync(p)) unlinkSync(p);
337
+ if (existsSync(CODEX_MODELS_CACHE_PATH)) unlinkSync(CODEX_MODELS_CACHE_PATH);
317
338
  } catch { /* best-effort */ }
318
339
  }
@@ -1,14 +1,9 @@
1
1
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
2
  import { atomicWriteFile } from "./config";
5
3
  import { restoreCodexCatalog } from "./codex-catalog";
4
+ import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, tomlString } from "./codex-paths";
6
5
  import type { OcxConfig } from "./types";
7
6
 
8
- const CODEX_HOME = join(homedir(), ".codex");
9
- const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
10
- const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
11
-
12
7
  const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
13
8
 
14
9
  /**
@@ -70,11 +65,65 @@ function setRootModelProvider(content: string): string {
70
65
  return lines.join("\n");
71
66
  }
72
67
 
73
- function buildProfileFile(port: number): string {
68
+ function readRootModelCatalogPath(content: string): string | null {
69
+ return readRootTomlString(content, "model_catalog_json");
70
+ }
71
+
72
+ function setRootModelCatalogPath(content: string, catalogPath: string): string {
73
+ if (readRootModelCatalogPath(content)) return content;
74
+ const lines = content.split("\n");
75
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
76
+ const key = `model_catalog_json = ${tomlString(catalogPath)}`;
77
+ if (firstTable === -1) {
78
+ return content.replace(/\n+$/, "") + "\n" + key + "\n";
79
+ }
80
+ let insertAt = firstTable;
81
+ while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
82
+ lines.splice(insertAt, 0, key);
83
+ return lines.join("\n");
84
+ }
85
+
86
+ function removeProfileSection(content: string): string {
87
+ const lines = content.split("\n");
88
+ const filtered: string[] = [];
89
+ let inProfile = false;
90
+ for (const line of lines) {
91
+ if (line.trim() === "[profiles.opencodex]") {
92
+ inProfile = true;
93
+ continue;
94
+ }
95
+ if (inProfile) {
96
+ if (line.startsWith("[") && line.trim() !== "[profiles.opencodex]") {
97
+ inProfile = false;
98
+ filtered.push(line);
99
+ }
100
+ continue;
101
+ }
102
+ filtered.push(line);
103
+ }
104
+ return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
105
+ }
106
+
107
+ function normalizeServiceTier(content: string): string {
108
+ return content.replace(/^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, '$1"fast"');
109
+ }
110
+
111
+ function stripDefaultCatalogPath(content: string): string {
112
+ return content
113
+ .split("\n")
114
+ .filter(line => {
115
+ const m = line.match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
116
+ return !m || parseTomlString(m[1]) !== DEFAULT_CATALOG_PATH;
117
+ })
118
+ .join("\n");
119
+ }
120
+
121
+ function buildProfileFile(port: number, catalogPath: string): string {
74
122
  return [
75
123
  "# OpenCodex proxy profile — use with: codex --profile opencodex",
76
124
  `# Routes all model requests through the opencodex proxy at localhost:${port}`,
77
125
  'model_provider = "opencodex"',
126
+ `model_catalog_json = ${tomlString(catalogPath)}`,
78
127
  "",
79
128
  ].join("\n");
80
129
  }
@@ -92,7 +141,12 @@ export async function injectCodexConfig(port: number, _config?: OcxConfig): Prom
92
141
  if (content.includes("[model_providers.opencodex]")) {
93
142
  content = removeOcxSection(content);
94
143
  }
144
+ content = removeProfileSection(content);
95
145
  content = stripExistingModelProvider(content);
146
+ content = normalizeServiceTier(content);
147
+
148
+ const catalogPath = readRootModelCatalogPath(content) ?? DEFAULT_CATALOG_PATH;
149
+ content = setRootModelCatalogPath(content, catalogPath);
96
150
 
97
151
  // 1) Root key BEFORE the first table header (must be a global, not nested under a table).
98
152
  content = setRootModelProvider(content);
@@ -100,7 +154,7 @@ export async function injectCodexConfig(port: number, _config?: OcxConfig): Prom
100
154
  content = content.trimEnd() + "\n" + buildProviderTableBlock(port);
101
155
 
102
156
  writeFileSync(CODEX_CONFIG_PATH, content, "utf-8");
103
- writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port), "utf-8");
157
+ writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath), "utf-8");
104
158
 
105
159
  return {
106
160
  success: true,
@@ -141,9 +195,11 @@ export function stripOpencodexConfig(content: string): string {
141
195
  if (out.includes("[model_providers.opencodex]")) {
142
196
  out = removeOcxSection(out);
143
197
  }
198
+ out = removeProfileSection(out);
144
199
  // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
145
200
  // must match the detection regex above, or a detected line could survive un-removed.
146
201
  out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
202
+ out = stripDefaultCatalogPath(out);
147
203
  return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
148
204
  }
149
205
 
@@ -0,0 +1,59 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+
5
+ function resolveCodexHome(): string {
6
+ const raw = process.env.CODEX_HOME?.trim();
7
+ if (raw) {
8
+ const path = resolve(raw);
9
+ let stat;
10
+ try {
11
+ stat = statSync(path);
12
+ } catch (err) {
13
+ const message = err instanceof Error ? err.message : String(err);
14
+ throw new Error(`CODEX_HOME points to ${raw}, but that path could not be read: ${message}`);
15
+ }
16
+ if (!stat.isDirectory()) {
17
+ throw new Error(`CODEX_HOME points to ${raw}, but that path is not a directory`);
18
+ }
19
+ return realpathSync.native(path);
20
+ }
21
+
22
+ return join(homedir(), ".codex");
23
+ }
24
+
25
+ export const CODEX_HOME = resolveCodexHome();
26
+ export const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
27
+ export const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
28
+ export const DEFAULT_CATALOG_PATH = join(CODEX_HOME, "opencodex-catalog.json");
29
+ export const CODEX_MODELS_CACHE_PATH = join(CODEX_HOME, "models_cache.json");
30
+
31
+ export function tomlString(value: string): string {
32
+ return JSON.stringify(value);
33
+ }
34
+
35
+ export function parseTomlString(raw: string): string {
36
+ if (raw.startsWith("\"")) {
37
+ try {
38
+ return JSON.parse(raw) as string;
39
+ } catch {
40
+ return raw.slice(1, -1);
41
+ }
42
+ }
43
+ return raw.slice(1, -1);
44
+ }
45
+
46
+ export function readRootTomlString(content: string, key: string): string | null {
47
+ const lines = content.split("\n");
48
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
49
+ const rootLines = firstTable === -1 ? lines : lines.slice(0, firstTable);
50
+ for (const line of rootLines) {
51
+ const m = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(\"(?:\\\\.|[^\"])*\"|'[^']*')`));
52
+ if (m) return parseTomlString(m[1]);
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export function resolveCodexConfigPath(path: string): string {
58
+ return isAbsolute(path) ? path : join(CODEX_HOME, path);
59
+ }
package/src/service.ts CHANGED
@@ -28,10 +28,25 @@ function logPath(): string {
28
28
  return join(getConfigDir(), "service.log");
29
29
  }
30
30
 
31
+ function windowsServiceScriptPath(): string {
32
+ return join(getConfigDir(), "opencodex-service.cmd");
33
+ }
34
+
35
+ function plistString(value: string): string {
36
+ return value
37
+ .replace(/&/g, "&amp;")
38
+ .replace(/</g, "&lt;")
39
+ .replace(/>/g, "&gt;")
40
+ .replace(/"/g, "&quot;")
41
+ .replace(/'/g, "&apos;");
42
+ }
43
+
31
44
  export function buildPlist(): string {
32
45
  const { bun, cli } = cliEntry();
33
46
  const log = logPath();
34
47
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
48
+ const codexHome = process.env.CODEX_HOME?.trim();
49
+ const codexHomeXml = codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : "";
35
50
  return `<?xml version="1.0" encoding="UTF-8"?>
36
51
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37
52
  <plist version="1.0">
@@ -39,8 +54,8 @@ export function buildPlist(): string {
39
54
  <key>Label</key><string>${LABEL}</string>
40
55
  <key>ProgramArguments</key>
41
56
  <array>
42
- <string>${bun}</string>
43
- <string>${cli}</string>
57
+ <string>${plistString(bun)}</string>
58
+ <string>${plistString(cli)}</string>
44
59
  <string>start</string>
45
60
  </array>
46
61
  <key>RunAtLoad</key><true/>
@@ -48,19 +63,57 @@ export function buildPlist(): string {
48
63
  <key>EnvironmentVariables</key>
49
64
  <dict>
50
65
  <key>OCX_SERVICE</key><string>1</string>
51
- <key>PATH</key><string>${path}</string>
52
- </dict>
53
- <key>StandardOutPath</key><string>${log}</string>
54
- <key>StandardErrorPath</key><string>${log}</string>
66
+ <key>PATH</key><string>${plistString(path)}</string>
67
+ ${codexHomeXml ? `${codexHomeXml}\n` : ""} </dict>
68
+ <key>StandardOutPath</key><string>${plistString(log)}</string>
69
+ <key>StandardErrorPath</key><string>${plistString(log)}</string>
55
70
  </dict>
56
71
  </plist>
57
72
  `;
58
73
  }
59
74
 
75
+ function systemdQuote(value: string): string {
76
+ return `"${value
77
+ .replace(/\\/g, "\\\\")
78
+ .replace(/"/g, "\\\"")
79
+ .replace(/%/g, "%%")
80
+ .replace(/\n/g, "\\n")}"`;
81
+ }
82
+
83
+ function systemdEnvironmentAssignment(name: string, value: string | undefined): string | null {
84
+ if (!value) return null;
85
+ return `Environment=${systemdQuote(`${name}=${value}`)}`;
86
+ }
87
+
60
88
  function sh(cmd: string): string {
61
89
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
62
90
  }
63
91
 
92
+ function windowsBatchValue(value: string): string {
93
+ return value.replace(/%/g, "%%").replace(/[\r\n]/g, "");
94
+ }
95
+
96
+ function windowsBatchSet(name: string, value: string | undefined): string | null {
97
+ if (!value) return null;
98
+ return `set "${name}=${windowsBatchValue(value)}"`;
99
+ }
100
+
101
+ export function buildWindowsServiceScript(): string {
102
+ const { bun, cli } = cliEntry();
103
+ const path = process.env.PATH ?? "";
104
+ const lines = [
105
+ "@echo off",
106
+ "setlocal",
107
+ windowsBatchSet("OCX_SERVICE", "1"),
108
+ windowsBatchSet("PATH", path),
109
+ windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim()),
110
+ `"${bun}" "${cli}" start`,
111
+ "set \"OCX_EXIT=%ERRORLEVEL%\"",
112
+ "endlocal & exit /b %OCX_EXIT%",
113
+ ].filter((line): line is string => Boolean(line));
114
+ return `${lines.join("\r\n")}\r\n`;
115
+ }
116
+
64
117
  // ── macOS (launchd) ──
65
118
  function installLaunchd(): void {
66
119
  const dir = join(homedir(), "Library", "LaunchAgents");
@@ -82,14 +135,19 @@ function uninstallLaunchd(): void {
82
135
 
83
136
  // ── Windows (Task Scheduler) ──
84
137
  function installWindows(): void {
85
- const { bun, cli } = cliEntry();
86
- sh(`schtasks /create /tn ${TASK} /tr "\\"${bun}\\" \\"${cli}\\" start" /sc onlogon /rl highest /f`);
138
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
139
+ const script = windowsServiceScriptPath();
140
+ writeFileSync(script, buildWindowsServiceScript(), "utf8");
141
+ sh(`schtasks /create /tn ${TASK} /tr "\\"${script}\\"" /sc onlogon /rl highest /f`);
87
142
  sh(`schtasks /run /tn ${TASK}`);
88
143
  }
89
144
  function startWindows(): void { sh(`schtasks /run /tn ${TASK}`); }
90
145
  function stopWindows(): void { try { sh(`schtasks /end /tn ${TASK}`); } catch { /* not running */ } }
91
146
  function statusWindows(): string { try { return sh(`schtasks /query /tn ${TASK}`); } catch { return ""; } }
92
- function uninstallWindows(): void { try { sh(`schtasks /delete /tn ${TASK} /f`); } catch { /* absent */ } }
147
+ function uninstallWindows(): void {
148
+ try { sh(`schtasks /delete /tn ${TASK} /f`); } catch { /* absent */ }
149
+ if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
150
+ }
93
151
 
94
152
  // ── Linux (systemd user unit) ──
95
153
  function unitDir(): string {
@@ -104,6 +162,12 @@ export function buildUnit(): string {
104
162
  const { bun, cli } = cliEntry();
105
163
  const log = logPath();
106
164
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
165
+ const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
166
+ const envLines = [
167
+ systemdEnvironmentAssignment("OCX_SERVICE", "1"),
168
+ systemdEnvironmentAssignment("PATH", path),
169
+ codexHome,
170
+ ].filter((line): line is string => Boolean(line)).join("\n");
107
171
  return `[Unit]
108
172
  Description=OpenCodex Proxy Server
109
173
  After=network-online.target
@@ -111,13 +175,12 @@ Wants=network-online.target
111
175
 
112
176
  [Service]
113
177
  Type=simple
114
- ExecStart=${bun} ${cli} start
178
+ ExecStart=${systemdQuote(bun)} ${systemdQuote(cli)} start
115
179
  Restart=on-failure
116
180
  RestartSec=5
117
- Environment=OCX_SERVICE=1
118
- Environment=PATH=${path}
119
- StandardOutput=append:${log}
120
- StandardError=append:${log}
181
+ ${envLines}
182
+ StandardOutput=${systemdQuote(`append:${log}`)}
183
+ StandardError=${systemdQuote(`append:${log}`)}
121
184
 
122
185
  [Install]
123
186
  WantedBy=default.target