@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.
- package/README.ko.md +1 -1
- package/README.md +3 -2
- package/README.zh-CN.md +1 -1
- package/gui/dist/assets/{index-C9y3iMF1.js → index-Dt5t57MW.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli.ts +38 -7
- package/src/codex-catalog.ts +36 -15
- package/src/codex-inject.ts +64 -8
- package/src/codex-paths.ts +59 -0
- package/src/service.ts +77 -14
package/gui/dist/index.html
CHANGED
|
@@ -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-
|
|
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
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)
|
|
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
|
-
(
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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() {
|
package/src/codex-catalog.ts
CHANGED
|
@@ -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
|
|
44
|
-
if (
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|
package/src/codex-inject.ts
CHANGED
|
@@ -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
|
|
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, "&")
|
|
38
|
+
.replace(/</g, "<")
|
|
39
|
+
.replace(/>/g, ">")
|
|
40
|
+
.replace(/"/g, """)
|
|
41
|
+
.replace(/'/g, "'");
|
|
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
|
-
|
|
86
|
-
|
|
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 {
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|