@bitkyc08/opencodex 2.5.5-preview.1 → 2.5.5-preview.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/src/codex-shim.ts CHANGED
@@ -3,7 +3,6 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync,
3
3
  import { getConfigDir } from "./config";
4
4
 
5
5
  const SHIM_MARKER = "opencodex codex autostart shim";
6
- const STATE_PATH = join(getConfigDir(), "codex-shim.json");
7
6
  const CODEX_INTERNAL_COMMANDS = [
8
7
  "app-server",
9
8
  "archive",
@@ -34,6 +33,13 @@ interface ShimState {
34
33
  wrapperPath: string;
35
34
  originalPath: string;
36
35
  backupPath: string;
36
+ wrappers?: ShimFileState[];
37
+ }
38
+
39
+ interface ShimFileState {
40
+ wrapperPath: string;
41
+ originalPath: string;
42
+ backupPath: string;
37
43
  }
38
44
 
39
45
  function cliEntry(): { bun: string; cli: string } {
@@ -69,6 +75,33 @@ function findCodexOnPath(): string | null {
69
75
  return null;
70
76
  }
71
77
 
78
+ function findWindowsCodexTargets(): ShimFileState[] | null {
79
+ for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
80
+ const cmd = join(dir, "codex.cmd");
81
+ const ps1 = join(dir, "codex.ps1");
82
+ const targets: ShimFileState[] = [];
83
+ for (const path of [cmd, ps1]) {
84
+ if (!existsSync(path) || isShim(path)) continue;
85
+ try {
86
+ if (!lstatSync(path).isDirectory()) {
87
+ targets.push({ wrapperPath: path, originalPath: path, backupPath: backupPathFor(path) });
88
+ }
89
+ } catch { /* keep scanning */ }
90
+ }
91
+ if (targets.length > 0) return targets;
92
+
93
+ const exe = join(dir, "codex.exe");
94
+ if (!existsSync(exe) || isShim(exe)) continue;
95
+ try {
96
+ if (lstatSync(exe).isDirectory()) continue;
97
+ const wrapperPath = join(dir, "codex.cmd");
98
+ if (existsSync(wrapperPath) && !isShim(wrapperPath)) continue;
99
+ return [{ wrapperPath, originalPath: exe, backupPath: backupPathFor(exe) }];
100
+ } catch { /* keep scanning */ }
101
+ }
102
+ return null;
103
+ }
104
+
72
105
  function backupPathFor(path: string): string {
73
106
  const ext = extname(path);
74
107
  return ext ? `${path.slice(0, -ext.length)}.opencodex-real${ext}` : `${path}.opencodex-real`;
@@ -107,83 +140,167 @@ if /I "%~1"=="-V" goto run_codex\r
107
140
  `;
108
141
  }
109
142
 
143
+ function psString(value: string): string {
144
+ return `'${value.replace(/'/g, "''")}'`;
145
+ }
146
+
147
+ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
148
+ const internalCommands = CODEX_INTERNAL_COMMANDS.map(command => psString(command)).join(", ");
149
+ return `#!/usr/bin/env pwsh
150
+ # ${SHIM_MARKER}
151
+ $internalCommands = @(${internalCommands})
152
+ $firstArg = if ($args.Count -gt 0) { [string]$args[0] } else { "" }
153
+ $skipEnsure = $env:OCX_SHIM_BYPASS -or $internalCommands -contains $firstArg -or @("--help", "-h", "--version", "-V") -contains $firstArg
154
+ if (-not $skipEnsure) {
155
+ & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null
156
+ }
157
+ & ${psString(realCodexPath)} @args
158
+ exit $LASTEXITCODE
159
+ `;
160
+ }
161
+
110
162
  function readState(): ShimState | null {
111
163
  try {
112
- return JSON.parse(readFileSync(STATE_PATH, "utf8")) as ShimState;
164
+ return JSON.parse(readFileSync(statePath(), "utf8")) as ShimState;
113
165
  } catch {
114
166
  return null;
115
167
  }
116
168
  }
117
169
 
170
+ function statePath(): string {
171
+ return join(getConfigDir(), "codex-shim.json");
172
+ }
173
+
118
174
  function writeState(state: ShimState): void {
119
175
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
120
- writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n", "utf8");
176
+ writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n", "utf8");
121
177
  }
122
178
 
123
179
  function writeShim(wrapperPath: string, realCodexPath: string): void {
124
180
  const { bun, cli } = cliEntry();
125
181
  if (process.platform === "win32") {
126
- writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli), "utf8");
182
+ if (wrapperPath.toLowerCase().endsWith(".ps1")) {
183
+ writeFileSync(wrapperPath, buildWindowsPowerShellCodexShim(realCodexPath, bun, cli), "utf8");
184
+ } else {
185
+ writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli), "utf8");
186
+ }
127
187
  } else {
128
188
  writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli), "utf8");
129
189
  chmodSync(wrapperPath, 0o755);
130
190
  }
131
191
  }
132
192
 
193
+ function stateFiles(state: ShimState): ShimFileState[] {
194
+ return state.wrappers?.length
195
+ ? state.wrappers
196
+ : [{ wrapperPath: state.wrapperPath, originalPath: state.originalPath, backupPath: state.backupPath }];
197
+ }
198
+
199
+ function primaryState(files: ShimFileState[]): ShimState {
200
+ const first = files[0];
201
+ return { platform: process.platform, ...first, wrappers: files };
202
+ }
203
+
204
+ function replaceOwnedBackup(sourcePath: string, backupPath: string): void {
205
+ const oldBackupPath = `${backupPath}.old-${process.pid}`;
206
+ if (existsSync(oldBackupPath)) unlinkSync(oldBackupPath);
207
+ if (existsSync(backupPath)) renameSync(backupPath, oldBackupPath);
208
+ try {
209
+ renameSync(sourcePath, backupPath);
210
+ if (existsSync(oldBackupPath)) unlinkSync(oldBackupPath);
211
+ } catch (error) {
212
+ if (!existsSync(backupPath) && existsSync(oldBackupPath)) renameSync(oldBackupPath, backupPath);
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ function refreshShimFile(file: ShimFileState): boolean {
218
+ if (existsSync(file.wrapperPath) && !isShim(file.wrapperPath)) {
219
+ if (file.wrapperPath !== file.originalPath) return false;
220
+ replaceOwnedBackup(file.wrapperPath, file.backupPath);
221
+ writeShim(file.wrapperPath, file.backupPath);
222
+ return true;
223
+ }
224
+ if (!existsSync(file.wrapperPath) && existsSync(file.backupPath)) {
225
+ writeShim(file.wrapperPath, file.backupPath);
226
+ return true;
227
+ }
228
+ if (file.originalPath !== file.wrapperPath && existsSync(file.originalPath) && existsSync(file.wrapperPath) && isShim(file.wrapperPath)) {
229
+ replaceOwnedBackup(file.originalPath, file.backupPath);
230
+ writeShim(file.wrapperPath, file.backupPath);
231
+ return true;
232
+ }
233
+ return false;
234
+ }
235
+
133
236
  export function installCodexShim(): { installed: boolean; message: string } {
134
237
  const existing = readState();
135
- if (existing && existsSync(existing.wrapperPath) && existsSync(existing.backupPath) && isShim(existing.wrapperPath)) {
136
- if (process.platform === "win32" && existing.originalPath && existsSync(existing.originalPath)) {
137
- renameSync(existing.originalPath, existing.backupPath);
138
- writeShim(existing.wrapperPath, existing.backupPath);
139
- writeState({ ...existing, platform: process.platform });
238
+ if (existing) {
239
+ const files = stateFiles(existing);
240
+ let refreshed = false;
241
+ for (const file of files) refreshed = refreshShimFile(file) || refreshed;
242
+ const allInstalled = files.every(file => existsSync(file.wrapperPath) && existsSync(file.backupPath) && isShim(file.wrapperPath));
243
+ if (refreshed || allInstalled) {
244
+ writeState(primaryState(files));
245
+ if (refreshed) {
246
+ return {
247
+ installed: true,
248
+ message: `Codex update detected. Backed up new launcher and refreshed shim at ${files.map(f => f.wrapperPath).join(", ")}.`,
249
+ };
250
+ }
140
251
  return {
141
- installed: true,
142
- message: `Codex update detected. Backed up new binary and refreshed shim at ${existing.wrapperPath}.`,
252
+ installed: false,
253
+ message: `Codex autostart shim already installed at ${files.map(f => f.wrapperPath).join(", ")}.`,
143
254
  };
144
255
  }
145
- return { installed: false, message: `Codex autostart shim already installed at ${existing.wrapperPath}.` };
146
- }
147
- if (existing && existsSync(existing.backupPath) && (!existsSync(existing.wrapperPath) || !isShim(existing.wrapperPath))) {
148
- if (existsSync(existing.wrapperPath)) unlinkSync(existing.wrapperPath);
149
- writeShim(existing.wrapperPath, existing.backupPath);
150
- writeState({ ...existing, platform: process.platform });
151
- return {
152
- installed: true,
153
- message: `Codex autostart shim repaired at ${existing.wrapperPath}. Original remains at ${existing.backupPath}.`,
154
- };
155
256
  }
156
257
 
157
- const originalPath = findCodexOnPath();
158
- if (!originalPath) return { installed: false, message: "Could not find a codex executable on PATH." };
258
+ const targets = process.platform === "win32"
259
+ ? findWindowsCodexTargets()
260
+ : (() => {
261
+ const originalPath = findCodexOnPath();
262
+ return originalPath ? [{ wrapperPath: originalPath, originalPath, backupPath: backupPathFor(originalPath) }] : null;
263
+ })();
264
+ if (!targets) return { installed: false, message: "Could not find a codex executable on PATH." };
159
265
 
160
- const backupPath = backupPathFor(originalPath);
161
- if (existsSync(backupPath)) return { installed: false, message: `Refusing to overwrite existing backup: ${backupPath}` };
162
-
163
- const wrapperPath = process.platform === "win32" ? join(dirname(originalPath), "codex.cmd") : originalPath;
164
- renameSync(originalPath, backupPath);
165
- writeShim(wrapperPath, backupPath);
166
- writeState({ platform: process.platform, wrapperPath, originalPath, backupPath });
167
- return { installed: true, message: `Codex autostart shim installed at ${wrapperPath}. Original saved at ${backupPath}.` };
266
+ for (const target of targets) {
267
+ if (existsSync(target.backupPath)) return { installed: false, message: `Refusing to overwrite existing backup: ${target.backupPath}` };
268
+ }
269
+ for (const target of targets) {
270
+ renameSync(target.originalPath, target.backupPath);
271
+ writeShim(target.wrapperPath, target.backupPath);
272
+ }
273
+ writeState(primaryState(targets));
274
+ return {
275
+ installed: true,
276
+ message: `Codex autostart shim installed at ${targets.map(t => t.wrapperPath).join(", ")}. Original saved at ${targets.map(t => t.backupPath).join(", ")}.`,
277
+ };
168
278
  }
169
279
 
170
280
  export function uninstallCodexShim(): { removed: boolean; message: string } {
171
281
  const state = readState();
172
282
  if (!state) return { removed: false, message: "Codex autostart shim is not installed." };
173
- if (existsSync(state.wrapperPath) && isShim(state.wrapperPath)) unlinkSync(state.wrapperPath);
174
- if (existsSync(state.backupPath) && !existsSync(state.originalPath)) renameSync(state.backupPath, state.originalPath);
175
- if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH);
176
- return { removed: true, message: `Codex autostart shim removed. Restored ${state.originalPath}.` };
283
+ const files = stateFiles(state);
284
+ for (const file of files) {
285
+ if (existsSync(file.wrapperPath) && isShim(file.wrapperPath)) unlinkSync(file.wrapperPath);
286
+ }
287
+ for (const file of files) {
288
+ if (existsSync(file.backupPath) && !existsSync(file.originalPath)) renameSync(file.backupPath, file.originalPath);
289
+ }
290
+ if (existsSync(statePath())) unlinkSync(statePath());
291
+ return { removed: true, message: `Codex autostart shim removed. Restored ${files.map(f => f.originalPath).join(", ")}.` };
177
292
  }
178
293
 
179
294
  export function codexShimStatus(): string {
180
295
  const state = readState();
181
296
  if (!state) return "Codex autostart shim is not installed.";
182
- const wrapper = existsSync(state.wrapperPath)
183
- ? isShim(state.wrapperPath)
184
- ? "shim present"
185
- : "present but not an opencodex shim"
186
- : "missing";
187
- const backup = existsSync(state.backupPath) ? "present" : "missing";
188
- return `Codex autostart shim: wrapper ${wrapper} at ${state.wrapperPath}; original backup ${backup} at ${state.backupPath}.`;
297
+ return stateFiles(state).map(file => {
298
+ const wrapper = existsSync(file.wrapperPath)
299
+ ? isShim(file.wrapperPath)
300
+ ? "shim present"
301
+ : "present but not an opencodex shim"
302
+ : "missing";
303
+ const backup = existsSync(file.backupPath) ? "present" : "missing";
304
+ return `Codex autostart shim: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`;
305
+ }).join("\n");
189
306
  }
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
@@ -92,7 +93,7 @@ export function loadConfig(): OcxConfig {
92
93
  return getDefaultConfig();
93
94
  }
94
95
  try {
95
- const raw = readFileSync(configPath, "utf-8");
96
+ const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
96
97
  const parsed = JSON.parse(raw);
97
98
  const result = configSchema.safeParse(parsed);
98
99
  if (result.success) return result.data as OcxConfig;
@@ -172,7 +173,7 @@ export function writePid(pid: number): void {
172
173
  } else {
173
174
  hardenConfigDir();
174
175
  }
175
- writeFileSync(getPidPath(), String(pid), "utf-8");
176
+ atomicWriteFile(getPidPath(), String(pid));
176
177
  }
177
178
 
178
179
  export function readPid(): number | null {
@@ -180,13 +181,15 @@ export function readPid(): number | null {
180
181
  if (!existsSync(pidPath)) return null;
181
182
  try {
182
183
  const raw = readFileSync(pidPath, "utf-8").trim();
183
- const pid = parseInt(raw, 10);
184
- if (isNaN(pid)) return null;
184
+ const pid = parsePidFile(raw);
185
+ if (pid === null) return null;
185
186
  try {
186
187
  process.kill(pid, 0);
187
- return pid;
188
+ return isLikelyOcxStartProcess(pid) ? pid : null;
188
189
  } catch (e: unknown) {
189
- if ((e as NodeJS.ErrnoException).code === "EPERM") return pid;
190
+ if ((e as NodeJS.ErrnoException).code === "EPERM") {
191
+ return isLikelyOcxStartProcess(pid) ? pid : null;
192
+ }
190
193
  return null;
191
194
  }
192
195
  } catch {
@@ -194,7 +197,8 @@ export function readPid(): number | null {
194
197
  }
195
198
  }
196
199
 
197
- export function removePid(): void {
200
+ export function removePid(expectedPid?: number): void {
201
+ if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return;
198
202
  try {
199
203
  unlinkSync(getPidPath());
200
204
  } catch { /* ignore */ }
@@ -207,6 +211,56 @@ function warnConfigRepaired(configPath: string, error: z.ZodError): void {
207
211
  console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
208
212
  }
209
213
 
214
+ function readPidFileValue(): number | null {
215
+ try {
216
+ return parsePidFile(readFileSync(getPidPath(), "utf-8"));
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ export function parsePidFile(raw: string): number | null {
223
+ const trimmed = raw.trim();
224
+ if (!/^\d+$/.test(trimmed)) return null;
225
+ const pid = Number.parseInt(trimmed, 10);
226
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
227
+ }
228
+
229
+ export function isOcxStartCommandLine(commandLine: string): boolean {
230
+ const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
231
+ const hasOcxEntrypoint = normalized.includes("src/cli.ts")
232
+ || normalized.includes("@bitkyc08/opencodex")
233
+ || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
234
+ return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
235
+ }
236
+
237
+ function isLikelyOcxStartProcess(pid: number): boolean {
238
+ const commandLine = readProcessCommandLine(pid);
239
+ if (commandLine === undefined) return false;
240
+ return isOcxStartCommandLine(commandLine);
241
+ }
242
+
243
+ function readProcessCommandLine(pid: number): string | undefined {
244
+ try {
245
+ if (process.platform === "win32") {
246
+ const output = execFileSync("powershell.exe", [
247
+ "-NoProfile",
248
+ "-Command",
249
+ `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
250
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 });
251
+ return output.trim() || undefined;
252
+ }
253
+ const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
254
+ encoding: "utf-8",
255
+ stdio: ["ignore", "pipe", "ignore"],
256
+ timeout: 1000,
257
+ });
258
+ return output.trim() || undefined;
259
+ } catch {
260
+ return undefined;
261
+ }
262
+ }
263
+
210
264
  function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
211
265
  if (warnedConfigFallbacks.has(configPath)) return;
212
266
  warnedConfigFallbacks.add(configPath);
package/src/open-url.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ function windowsRundll32(): string {
6
+ const windowsRoot = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows";
7
+ const candidate = join(windowsRoot, "System32", "rundll32.exe");
8
+ return existsSync(candidate) ? candidate : "rundll32";
9
+ }
2
10
 
3
11
  export function openUrl(url: string): void {
4
12
  if (!/^https?:\/\//i.test(url)) return;
5
13
  const cmd =
6
14
  process.platform === "darwin" ? "open"
7
- : process.platform === "win32" ? "rundll32"
15
+ : process.platform === "win32" ? windowsRundll32()
8
16
  : "xdg-open";
9
17
  const args = process.platform === "win32"
10
18
  ? ["url.dll,FileProtocolHandler", url]
package/src/server.ts CHANGED
@@ -522,14 +522,58 @@ async function fetchWithHeaderTimeout(
522
522
  }
523
523
  }
524
524
 
525
- const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
525
+ export interface RequestLogEntry {
526
+ requestId: string;
527
+ timestamp: number;
528
+ model: string;
529
+ provider: string;
530
+ status: number;
531
+ durationMs: number;
532
+ errorCode?: string;
533
+ }
534
+
535
+ const requestLog: RequestLogEntry[] = [];
526
536
  const MAX_LOG_SIZE = 200;
537
+ let requestLogSeq = 0;
527
538
 
528
- function addRequestLog(entry: typeof requestLog[number]) {
539
+ function addRequestLog(entry: RequestLogEntry) {
529
540
  requestLog.push(entry);
530
541
  if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
531
542
  }
532
543
 
544
+ export function nextRequestLogId(timestamp = Date.now()): string {
545
+ requestLogSeq = (requestLogSeq % 1_000_000) + 1;
546
+ return `ocx-${timestamp.toString(36)}-${requestLogSeq.toString(36)}`;
547
+ }
548
+
549
+ export function requestLogErrorCode(status: number): string | undefined {
550
+ if (status >= 200 && status < 400) return undefined;
551
+ if (status === 400 || status === 409) return "invalid_request_error";
552
+ if (status === 401 || status === 403) return "invalid_api_key";
553
+ if (status === 429) return "rate_limit_exceeded";
554
+ if (status === 503) return "server_is_overloaded";
555
+ if (status >= 500) return "upstream_server_error";
556
+ return `http_${status}`;
557
+ }
558
+
559
+ export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchParams): RequestLogEntry[] {
560
+ let filtered = logs;
561
+ const provider = params.get("provider")?.trim();
562
+ if (provider) filtered = filtered.filter(entry => entry.provider === provider);
563
+ const status = params.get("status")?.trim().toLowerCase();
564
+ if (status) {
565
+ filtered = /^[1-5]xx$/.test(status)
566
+ ? filtered.filter(entry => Math.floor(entry.status / 100) === Number(status[0]))
567
+ : filtered.filter(entry => String(entry.status) === status);
568
+ }
569
+ const tailRaw = params.get("tail")?.trim();
570
+ if (tailRaw) {
571
+ const tail = Number.parseInt(tailRaw, 10);
572
+ if (Number.isFinite(tail) && tail > 0) filtered = filtered.slice(-Math.min(tail, MAX_LOG_SIZE));
573
+ }
574
+ return filtered;
575
+ }
576
+
533
577
  /**
534
578
  * Relay an upstream body verbatim while wiring client-cancel -> upstream.abort(). A body returned
535
579
  * directly from fetch does NOT propagate the consumer's cancel to a signalled fetch, so a client
@@ -941,7 +985,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
941
985
  }
942
986
 
943
987
  if (url.pathname === "/api/logs" && req.method === "GET") {
944
- return jsonResponse(requestLog);
988
+ return jsonResponse(filterRequestLogs(requestLog, url.searchParams));
945
989
  }
946
990
 
947
991
  if (url.pathname === "/api/providers" && req.method === "GET") {
@@ -1228,14 +1272,18 @@ export function startServer(port?: number) {
1228
1272
  return formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked");
1229
1273
  }
1230
1274
  const start = Date.now();
1275
+ const requestId = nextRequestLogId(start);
1231
1276
  const logCtx = { model: "unknown", provider: "unknown" };
1232
1277
  const response = await handleResponses(req, config, logCtx);
1278
+ const errorCode = requestLogErrorCode(response.status);
1233
1279
  addRequestLog({
1280
+ requestId,
1234
1281
  timestamp: start,
1235
1282
  model: logCtx.model,
1236
1283
  provider: logCtx.provider,
1237
1284
  status: response.status,
1238
1285
  durationMs: Date.now() - start,
1286
+ ...(errorCode ? { errorCode } : {}),
1239
1287
  });
1240
1288
  return response;
1241
1289
  }
package/src/service.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Codex on a service-managed restart (the restarted instance re-injects); explicit stop/uninstall
6
6
  * restore it via the command.
7
7
  */
8
- import { execSync } from "node:child_process";
8
+ import { execFileSync, execSync } from "node:child_process";
9
9
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { join } from "node:path";
@@ -95,6 +95,19 @@ function sh(cmd: string): string {
95
95
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
96
96
  }
97
97
 
98
+ function runFile(file: string, args: string[]): string {
99
+ return execFileSync(file, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
100
+ }
101
+
102
+ function windowsSchtasks(): string {
103
+ const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "schtasks.exe");
104
+ return existsSync(candidate) ? candidate : "schtasks.exe";
105
+ }
106
+
107
+ function schtasks(args: string[]): string {
108
+ return runFile(windowsSchtasks(), args);
109
+ }
110
+
98
111
  function windowsBatchValue(value: string): string {
99
112
  return value.replace(/%/g, "%%").replace(/[\r\n]/g, "");
100
113
  }
@@ -124,6 +137,10 @@ export function buildWindowsServiceScript(): string {
124
137
  return `${lines.join("\r\n")}\r\n`;
125
138
  }
126
139
 
140
+ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath()): string[] {
141
+ return ["/create", "/tn", TASK, "/tr", `"${script}"`, "/sc", "onlogon", "/rl", "highest", "/f"];
142
+ }
143
+
127
144
  // ── macOS (launchd) ──
128
145
  function installLaunchd(): void {
129
146
  const dir = join(homedir(), "Library", "LaunchAgents");
@@ -148,14 +165,14 @@ function installWindows(): void {
148
165
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
149
166
  const script = windowsServiceScriptPath();
150
167
  writeFileSync(script, buildWindowsServiceScript(), "utf8");
151
- sh(`schtasks /create /tn ${TASK} /tr "\\"${script}\\"" /sc onlogon /rl highest /f`);
152
- sh(`schtasks /run /tn ${TASK}`);
168
+ schtasks(buildWindowsSchtasksCreateArgs(script));
169
+ schtasks(["/run", "/tn", TASK]);
153
170
  }
154
- function startWindows(): void { sh(`schtasks /run /tn ${TASK}`); }
155
- function stopWindows(): void { try { sh(`schtasks /end /tn ${TASK}`); } catch { /* not running */ } }
156
- function statusWindows(): string { try { return sh(`schtasks /query /tn ${TASK}`); } catch { return ""; } }
171
+ function startWindows(): void { schtasks(["/run", "/tn", TASK]); }
172
+ function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { /* not running */ } }
173
+ function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
157
174
  function uninstallWindows(): void {
158
- try { sh(`schtasks /delete /tn ${TASK} /f`); } catch { /* absent */ }
175
+ try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
159
176
  if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
160
177
  }
161
178
 
@@ -253,7 +270,7 @@ export function stopServiceIfInstalled(): boolean {
253
270
  }
254
271
  } else if (process.platform === "win32") {
255
272
  try {
256
- const q = sh(`schtasks /query /tn ${TASK} 2>nul`);
273
+ const q = schtasks(["/query", "/tn", TASK]);
257
274
  if (q.includes(TASK)) { stopWindows(); return true; }
258
275
  } catch { /* task not found */ }
259
276
  } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
@@ -274,7 +291,7 @@ export function uninstallServiceIfInstalled(): boolean {
274
291
  }
275
292
  } else if (process.platform === "win32") {
276
293
  try {
277
- const q = sh(`schtasks /query /tn ${TASK} 2>nul`);
294
+ const q = schtasks(["/query", "/tn", TASK]);
278
295
  if (q.includes(TASK)) { uninstallWindows(); return true; }
279
296
  } catch { /* task not found */ }
280
297
  } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
@@ -283,6 +300,26 @@ export function uninstallServiceIfInstalled(): boolean {
283
300
  return false;
284
301
  }
285
302
 
303
+ export function serviceStatusSummary(): string {
304
+ if (process.platform === "darwin") {
305
+ if (!existsSync(plistPath())) return "not installed";
306
+ const status = statusLaunchd();
307
+ return status ? "installed (launchd)" : "installed, not loaded";
308
+ }
309
+ if (process.platform === "win32") {
310
+ const status = statusWindows();
311
+ return status ? "installed (Task Scheduler)" : "not installed";
312
+ }
313
+ if (process.platform === "linux") {
314
+ if (existsSync("/.dockerenv")) return "unsupported in Docker";
315
+ if (!isSystemd()) return "unsupported: systemd not found";
316
+ if (!existsSync(unitPath())) return "not installed";
317
+ const status = statusSystemd();
318
+ return status ? "installed (systemd user)" : "installed, not running";
319
+ }
320
+ return `unsupported on ${process.platform}`;
321
+ }
322
+
286
323
  export function serviceCommand(sub?: string): void {
287
324
  const ops = platformOps();
288
325
  if (!ops) {