@bitkyc08/opencodex 2.7.26 → 2.7.27
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 +2 -2
- package/bin/ocx.mjs +23 -2
- package/gui/dist/assets/{index-BvQ5spEX.js → index-Vcr0pzdO.js} +3 -3
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/claude.ts +9 -0
- package/src/cli/index.ts +13 -1
- package/src/lib/service-secrets.ts +19 -0
- package/src/lib/winsw.ts +343 -0
- package/src/server/management-api.ts +19 -2
- package/src/server/system-env.ts +11 -0
- package/src/service.ts +203 -25
- package/src/update/index.ts +42 -3
- package/src/update/job.ts +12 -3
package/src/service.ts
CHANGED
|
@@ -16,12 +16,15 @@ import { isWslRuntime } from "./codex/home";
|
|
|
16
16
|
import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
|
|
17
17
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
18
18
|
import { serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
19
|
+
import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw";
|
|
19
20
|
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
|
|
20
21
|
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
|
|
21
22
|
|
|
22
23
|
const LABEL = "com.opencodex.proxy";
|
|
23
24
|
const TASK = "opencodex-proxy";
|
|
24
25
|
|
|
26
|
+
export type ServiceBackend = "scheduler" | "native";
|
|
27
|
+
|
|
25
28
|
function cliEntry(): { bun: string; cli: string } {
|
|
26
29
|
// Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
|
|
27
30
|
// a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
|
|
@@ -45,6 +48,10 @@ function windowsServiceScriptPath(): string {
|
|
|
45
48
|
return join(getConfigDir(), "opencodex-service.cmd");
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
function windowsLauncherVbsPath(): string {
|
|
52
|
+
return join(getConfigDir(), "opencodex-service-launcher.vbs");
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
function windowsTaskXmlPath(): string {
|
|
49
56
|
return join(getConfigDir(), "opencodex-service-task.xml");
|
|
50
57
|
}
|
|
@@ -82,22 +89,28 @@ function normalizePathForCompare(path: string): string {
|
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
interface ServiceInstallState {
|
|
85
|
-
version: 1;
|
|
92
|
+
version: 1 | 2;
|
|
86
93
|
codexHome: string;
|
|
87
94
|
opencodexHome: string;
|
|
88
95
|
/** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */
|
|
89
96
|
bunPath?: string;
|
|
90
97
|
cliPath?: string;
|
|
98
|
+
/** v2: which Windows backend was chosen at install; absent (v1/legacy) means scheduler. */
|
|
99
|
+
backend?: ServiceBackend;
|
|
100
|
+
winswVersion?: string;
|
|
101
|
+
winswSha256?: string;
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
function writeServiceInstallState(): void {
|
|
104
|
+
function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void {
|
|
94
105
|
const { bun, cli } = cliEntry();
|
|
95
106
|
const state: ServiceInstallState = {
|
|
96
|
-
version:
|
|
107
|
+
version: 2,
|
|
97
108
|
codexHome: currentCodexHome(),
|
|
98
109
|
opencodexHome: currentOpenCodexHome(),
|
|
99
110
|
bunPath: bun,
|
|
100
111
|
cliPath: cli,
|
|
112
|
+
backend,
|
|
113
|
+
...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}),
|
|
101
114
|
};
|
|
102
115
|
for (const path of serviceStatePaths()) {
|
|
103
116
|
const dir = dirname(path);
|
|
@@ -112,7 +125,7 @@ function readServiceInstallState(): ServiceInstallState | null {
|
|
|
112
125
|
for (const path of serviceStatePaths()) {
|
|
113
126
|
try {
|
|
114
127
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as ServiceInstallState;
|
|
115
|
-
if (parsed.version === 1) return parsed;
|
|
128
|
+
if (parsed.version === 1 || parsed.version === 2) return parsed;
|
|
116
129
|
} catch {
|
|
117
130
|
/* try the next known state path */
|
|
118
131
|
}
|
|
@@ -120,6 +133,16 @@ function readServiceInstallState(): ServiceInstallState | null {
|
|
|
120
133
|
return null;
|
|
121
134
|
}
|
|
122
135
|
|
|
136
|
+
/** Single accessor for update/reinstall code — v1/legacy state maps to scheduler. */
|
|
137
|
+
export function readServiceBackend(): ServiceBackend {
|
|
138
|
+
return readServiceInstallState()?.backend === "native" ? "native" : "scheduler";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The `ocx` argv that reinstalls the currently-chosen service backend (update paths). */
|
|
142
|
+
export function serviceReinstallArgs(): string[] {
|
|
143
|
+
return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
|
|
144
|
+
}
|
|
145
|
+
|
|
123
146
|
export function assertServiceEnvironmentMatchesInstall(): void {
|
|
124
147
|
const state = readServiceInstallState();
|
|
125
148
|
if (!state) return;
|
|
@@ -256,6 +279,11 @@ function windowsSchtasks(): string {
|
|
|
256
279
|
return existsSync(candidate) ? candidate : "schtasks.exe";
|
|
257
280
|
}
|
|
258
281
|
|
|
282
|
+
function windowsWscript(): string {
|
|
283
|
+
const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
|
|
284
|
+
return existsSync(candidate) ? candidate : "wscript.exe";
|
|
285
|
+
}
|
|
286
|
+
|
|
259
287
|
function schtasks(args: string[]): string {
|
|
260
288
|
return runFile(windowsSchtasks(), args);
|
|
261
289
|
}
|
|
@@ -295,8 +323,8 @@ export function buildWindowsServiceScript(entry = cliEntry()): string {
|
|
|
295
323
|
const lines = [
|
|
296
324
|
"@echo off",
|
|
297
325
|
"setlocal",
|
|
298
|
-
// The wrapper
|
|
299
|
-
// safe (no leak into user shells) and lets cmd parse
|
|
326
|
+
// The wrapper console is hidden by the wscript launcher (window style 0), so switching
|
|
327
|
+
// it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
|
|
300
328
|
"chcp 65001 >nul",
|
|
301
329
|
windowsBatchSet("OCX_SERVICE", "1"),
|
|
302
330
|
windowsBatchSet("PATH", path, "pathList"),
|
|
@@ -335,8 +363,31 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath
|
|
|
335
363
|
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
|
|
336
364
|
}
|
|
337
365
|
|
|
338
|
-
|
|
339
|
-
|
|
366
|
+
/**
|
|
367
|
+
* VBS launcher that starts the batch wrapper with a hidden window (style 0).
|
|
368
|
+
* bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
|
|
369
|
+
* scheduled task stays "running": MultipleInstancesPolicy=IgnoreNew keeps preventing
|
|
370
|
+
* duplicates and `schtasks /end` still has a live task instance to stop. Without the
|
|
371
|
+
* launcher, the console batch action shows a closable cmd window in the interactive
|
|
372
|
+
* session (issue #165). VBS string literals escape `"` as `""`.
|
|
373
|
+
*/
|
|
374
|
+
export function buildWindowsLauncherVbs(script = windowsServiceScriptPath()): string {
|
|
375
|
+
const escaped = script.replace(/"/g, '""');
|
|
376
|
+
const lines = [
|
|
377
|
+
"' OpenCodex service launcher — runs the batch wrapper with a hidden window.",
|
|
378
|
+
"' Generated by `ocx service install`; do not edit.",
|
|
379
|
+
'Set shell = CreateObject("WScript.Shell")',
|
|
380
|
+
// WshShell.Run(command, windowStyle 0 = hidden, bWaitOnReturn True = stay resident).
|
|
381
|
+
`shell.Run """${escaped}""", 0, True`,
|
|
382
|
+
];
|
|
383
|
+
return `${lines.join("\r\n")}\r\n`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function buildWindowsTaskXml(script = windowsServiceScriptPath(), launcher = windowsLauncherVbsPath()): string {
|
|
387
|
+
const escapedWscript = taskXmlString(windowsWscript());
|
|
388
|
+
// Escape the launcher path independently for the <Arguments> element; quoting it
|
|
389
|
+
// keeps spaces intact, and /b (batch mode) suppresses script error popups.
|
|
390
|
+
const escapedLauncherArgs = taskXmlString(`/b /nologo "${launcher}"`);
|
|
340
391
|
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
341
392
|
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
342
393
|
<RegistrationInfo>
|
|
@@ -372,7 +423,8 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string
|
|
|
372
423
|
</Settings>
|
|
373
424
|
<Actions Context="Author">
|
|
374
425
|
<Exec>
|
|
375
|
-
<Command>${
|
|
426
|
+
<Command>${escapedWscript}</Command>
|
|
427
|
+
<Arguments>${escapedLauncherArgs}</Arguments>
|
|
376
428
|
</Exec>
|
|
377
429
|
</Actions>
|
|
378
430
|
</Task>
|
|
@@ -421,15 +473,70 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut
|
|
|
421
473
|
function installWindows(): void {
|
|
422
474
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
423
475
|
writeServiceApiTokenFile();
|
|
476
|
+
// Transactional backend switch: installing the scheduler backend removes a native
|
|
477
|
+
// service first — two live managers would both respawn the proxy (conflict).
|
|
478
|
+
if (statusWinswRaw() !== "nonexistent") {
|
|
479
|
+
console.log("🔁 Removing the native (WinSW) service before installing the Task Scheduler backend...");
|
|
480
|
+
try {
|
|
481
|
+
uninstallWinswService();
|
|
482
|
+
} catch (err) {
|
|
483
|
+
throw new Error(`Cannot remove the native service before switching to Task Scheduler: ${err instanceof Error ? err.message : String(err)}. Remove it manually with 'sc delete ${WINSW_SERVICE_ID}' or retry.`);
|
|
484
|
+
}
|
|
485
|
+
if (statusWinswRaw() !== "nonexistent") {
|
|
486
|
+
throw new Error("Native service still present after removal attempt — aborting switch. Remove it manually with 'sc delete opencodex-proxy-native'.");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
424
489
|
// End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
|
|
425
490
|
// script mid-rewrite runs a torn batch file, and its open handle can fail the write.
|
|
426
491
|
try { stopWindows(); } catch { /* not running */ }
|
|
427
492
|
const script = windowsServiceScriptPath();
|
|
428
493
|
writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
|
|
494
|
+
// UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
|
|
495
|
+
// paths on some WSH/codepage combinations — same contract as the task XML below.
|
|
496
|
+
writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
|
|
429
497
|
writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
|
|
430
498
|
schtasks(buildWindowsSchtasksCreateArgs(script));
|
|
431
499
|
schtasks(["/run", "/tn", TASK]);
|
|
432
|
-
writeServiceInstallState();
|
|
500
|
+
writeServiceInstallState("scheduler");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Opt-in native backend (`ocx service install --native`). Transactional: removes the
|
|
505
|
+
* scheduler backend first; on failure the machine is left with NO service (explicitly
|
|
506
|
+
* reported) — never a silent fallback to the scheduler.
|
|
507
|
+
*/
|
|
508
|
+
async function installWindowsNative(): Promise<void> {
|
|
509
|
+
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
510
|
+
writeServiceApiTokenFile();
|
|
511
|
+
let hadScheduler = false;
|
|
512
|
+
try {
|
|
513
|
+
hadScheduler = schtasks(["/query", "/tn", TASK]).includes(TASK);
|
|
514
|
+
} catch { /* task absent */ }
|
|
515
|
+
if (hadScheduler) {
|
|
516
|
+
console.log("🔁 Removing the Task Scheduler backend before installing the native (WinSW) service...");
|
|
517
|
+
try { stopWindows(); } catch { /* not running */ }
|
|
518
|
+
try {
|
|
519
|
+
uninstallWindows();
|
|
520
|
+
} catch (err) {
|
|
521
|
+
throw new Error(`Cannot remove the Task Scheduler backend before switching to native: ${err instanceof Error ? err.message : String(err)}`);
|
|
522
|
+
}
|
|
523
|
+
// Verify removal — schtasks /delete can silently fail if UAC or policy blocks it.
|
|
524
|
+
try {
|
|
525
|
+
if (schtasks(["/query", "/tn", TASK]).includes(TASK)) {
|
|
526
|
+
throw new Error("Task Scheduler backend still present after removal — aborting switch.");
|
|
527
|
+
}
|
|
528
|
+
} catch (e) {
|
|
529
|
+
if (e instanceof Error && e.message.includes("still present")) throw e;
|
|
530
|
+
/* query failure = task absent, which is what we want */
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
await installWinswService(defaultWinswEntry(import.meta.dir));
|
|
535
|
+
} catch (err) {
|
|
536
|
+
if (hadScheduler) console.error("⚠️ Native install failed AFTER removing the Task Scheduler backend — no service is installed now. Run `ocx service install` to restore the scheduler backend, or retry `--native`.");
|
|
537
|
+
throw err;
|
|
538
|
+
}
|
|
539
|
+
writeServiceInstallState("native");
|
|
433
540
|
}
|
|
434
541
|
function startWindows(): void { schtasks(["/run", "/tn", TASK]); }
|
|
435
542
|
function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { /* not running */ } }
|
|
@@ -437,6 +544,7 @@ function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]
|
|
|
437
544
|
function uninstallWindows(): void {
|
|
438
545
|
try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
|
|
439
546
|
if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
|
|
547
|
+
if (existsSync(windowsLauncherVbsPath())) unlinkSync(windowsLauncherVbsPath());
|
|
440
548
|
if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
|
|
441
549
|
}
|
|
442
550
|
|
|
@@ -561,15 +669,18 @@ function uninstallSystemd(): void {
|
|
|
561
669
|
}
|
|
562
670
|
|
|
563
671
|
type ServiceOps = {
|
|
564
|
-
install: () => void
|
|
672
|
+
install: () => void | Promise<void>; start: () => void; stop: () => void;
|
|
565
673
|
status: () => string; uninstall: () => void;
|
|
566
674
|
};
|
|
567
675
|
|
|
568
|
-
function platformOps(): ServiceOps | null {
|
|
676
|
+
function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
|
|
569
677
|
if (process.platform === "darwin")
|
|
570
678
|
return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
|
|
571
|
-
if (process.platform === "win32")
|
|
679
|
+
if (process.platform === "win32") {
|
|
680
|
+
if (backend === "native")
|
|
681
|
+
return { install: installWindowsNative, start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService };
|
|
572
682
|
return { install: installWindows, start: startWindows, stop: stopWindows, status: statusWindows, uninstall: uninstallWindows };
|
|
683
|
+
}
|
|
573
684
|
if (process.platform === "linux") {
|
|
574
685
|
if (existsSync("/.dockerenv")) {
|
|
575
686
|
console.error("Docker detected. Run 'ocx start' directly instead of using the service manager.");
|
|
@@ -623,10 +734,15 @@ export function stopServiceIfInstalled(): boolean {
|
|
|
623
734
|
try { stopLaunchd(); return true; } catch { return false; }
|
|
624
735
|
}
|
|
625
736
|
} else if (process.platform === "win32") {
|
|
737
|
+
// Query BOTH backends regardless of state: a failed switch or stale state can leave
|
|
738
|
+
// two managers installed, and either one would respawn the proxy after `ocx stop`.
|
|
739
|
+
let stopped = false;
|
|
626
740
|
try {
|
|
627
741
|
const q = schtasks(["/query", "/tn", TASK]);
|
|
628
|
-
if (q.includes(TASK)) { stopWindows();
|
|
742
|
+
if (q.includes(TASK)) { stopWindows(); stopped = true; }
|
|
629
743
|
} catch { /* task not found */ }
|
|
744
|
+
if (statusWinswRaw() !== "nonexistent") { stopWinswService(); stopped = true; }
|
|
745
|
+
if (stopped) return true;
|
|
630
746
|
} else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
|
|
631
747
|
try { stopSystemd(); return true; } catch { return false; }
|
|
632
748
|
}
|
|
@@ -652,10 +768,13 @@ export function uninstallServiceIfInstalled(): boolean {
|
|
|
652
768
|
try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
|
|
653
769
|
}
|
|
654
770
|
} else if (process.platform === "win32") {
|
|
771
|
+
let removed = false;
|
|
655
772
|
try {
|
|
656
773
|
const q = schtasks(["/query", "/tn", TASK]);
|
|
657
|
-
if (q.includes(TASK)) { uninstallWindows();
|
|
774
|
+
if (q.includes(TASK)) { uninstallWindows(); removed = true; }
|
|
658
775
|
} catch { /* task not found */ }
|
|
776
|
+
if (statusWinswRaw() !== "nonexistent") { uninstallWinswService(); removed = true; }
|
|
777
|
+
if (removed) { removeServiceInstallState(); return true; }
|
|
659
778
|
} else if (process.platform === "linux" && existsSync(unitPath())) {
|
|
660
779
|
try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
|
|
661
780
|
try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
|
|
@@ -677,8 +796,11 @@ export function serviceStatusSummary(): string {
|
|
|
677
796
|
return status ? `installed (launchd; ${diagnostics})` : `installed, not loaded (${diagnostics})`;
|
|
678
797
|
}
|
|
679
798
|
if (process.platform === "win32") {
|
|
680
|
-
const
|
|
681
|
-
|
|
799
|
+
const scheduler = statusWindows();
|
|
800
|
+
const native = winswStatusSummary();
|
|
801
|
+
if (scheduler && native) return `installed (CONFLICT: Task Scheduler AND native WinSW both present — run 'ocx service uninstall' then reinstall one; ${diagnostics})`;
|
|
802
|
+
if (native) return `installed (${native}; ${diagnostics})`;
|
|
803
|
+
return scheduler ? `installed (Task Scheduler; ${diagnostics})` : `not installed (${diagnostics})`;
|
|
682
804
|
}
|
|
683
805
|
if (process.platform === "linux") {
|
|
684
806
|
if (existsSync("/.dockerenv")) return "unsupported in Docker";
|
|
@@ -694,19 +816,66 @@ export function normalizeServiceSubcommand(sub?: string): string {
|
|
|
694
816
|
return sub ?? "install";
|
|
695
817
|
}
|
|
696
818
|
|
|
697
|
-
export
|
|
698
|
-
|
|
819
|
+
export interface ParsedServiceArgs {
|
|
820
|
+
sub: string;
|
|
821
|
+
backend: ServiceBackend | null;
|
|
822
|
+
invalid: string[];
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the
|
|
827
|
+
* subcommand; backend flags are only meaningful for `install` (validated by the caller).
|
|
828
|
+
*/
|
|
829
|
+
export function parseServiceArgs(args: string[]): ParsedServiceArgs {
|
|
830
|
+
let sub: string | undefined;
|
|
831
|
+
let backend: ServiceBackend | null = null;
|
|
832
|
+
const invalid: string[] = [];
|
|
833
|
+
for (const arg of args) {
|
|
834
|
+
if (arg === "--native") {
|
|
835
|
+
if (backend === "scheduler") { invalid.push("--native (conflicts with --scheduler)"); continue; }
|
|
836
|
+
backend = "native";
|
|
837
|
+
}
|
|
838
|
+
else if (arg === "--scheduler") {
|
|
839
|
+
if (backend === "native") { invalid.push("--scheduler (conflicts with --native)"); continue; }
|
|
840
|
+
backend = "scheduler";
|
|
841
|
+
}
|
|
842
|
+
else if (arg.startsWith("--")) invalid.push(arg);
|
|
843
|
+
else if (sub === undefined) sub = arg;
|
|
844
|
+
else invalid.push(arg);
|
|
845
|
+
}
|
|
846
|
+
return { sub: normalizeServiceSubcommand(sub), backend, invalid };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
|
|
850
|
+
const parsed = parseServiceArgs(args.filter((a): a is string => Boolean(a)));
|
|
851
|
+
const command = parsed.sub;
|
|
852
|
+
if (parsed.invalid.length > 0) {
|
|
853
|
+
console.error(`Unknown service option: ${parsed.invalid.join(" ")}`);
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
if (parsed.backend && command !== "install") {
|
|
857
|
+
console.error("--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend.");
|
|
858
|
+
process.exit(1);
|
|
859
|
+
}
|
|
860
|
+
if (parsed.backend === "native" && process.platform !== "win32") {
|
|
861
|
+
console.error("--native (WinSW) is Windows-only.");
|
|
862
|
+
process.exit(1);
|
|
863
|
+
}
|
|
864
|
+
// Non-install subcommands follow the backend recorded at install time (state v2).
|
|
865
|
+
const backend: ServiceBackend = parsed.backend ?? (process.platform === "win32" ? readServiceBackend() : "scheduler");
|
|
866
|
+
const ops = platformOps(backend);
|
|
699
867
|
if (!ops) {
|
|
700
868
|
console.error("ocx service supports macOS (launchd), Windows (Task Scheduler), and Linux (systemd).");
|
|
701
869
|
process.exit(1);
|
|
702
870
|
}
|
|
703
|
-
const command = normalizeServiceSubcommand(sub);
|
|
704
871
|
switch (command) {
|
|
705
872
|
case "install":
|
|
706
873
|
assertServiceEnvironmentMatchesInstall();
|
|
707
874
|
assertServiceAuthEnvironment();
|
|
708
|
-
ops.install();
|
|
709
|
-
console.log(
|
|
875
|
+
await ops.install();
|
|
876
|
+
console.log(backend === "native"
|
|
877
|
+
? "✅ opencodex native service installed + started (windowless, starts at boot, auto-restarts on crash)."
|
|
878
|
+
: "✅ opencodex service installed + started (auto-starts on login, auto-restarts on crash).");
|
|
710
879
|
if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
|
|
711
880
|
break;
|
|
712
881
|
case "start":
|
|
@@ -732,9 +901,17 @@ export async function serviceCommand(sub?: string): Promise<void> {
|
|
|
732
901
|
case "uninstall":
|
|
733
902
|
case "remove":
|
|
734
903
|
assertServiceEnvironmentMatchesInstall();
|
|
735
|
-
ops.stop();
|
|
904
|
+
try { ops.stop(); } catch (err) {
|
|
905
|
+
console.warn(`⚠️ Service stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
906
|
+
}
|
|
736
907
|
await stopTrackedProxyForServiceCommand();
|
|
737
|
-
|
|
908
|
+
try {
|
|
909
|
+
ops.uninstall();
|
|
910
|
+
} catch (err) {
|
|
911
|
+
console.error(`❌ Service uninstall failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
912
|
+
console.error("The service may still be installed. Check with 'ocx service status' or remove manually.");
|
|
913
|
+
process.exit(1);
|
|
914
|
+
}
|
|
738
915
|
{
|
|
739
916
|
const restore = restoreNativeCodex();
|
|
740
917
|
if (!restore.success) {
|
|
@@ -746,8 +923,9 @@ export async function serviceCommand(sub?: string): Promise<void> {
|
|
|
746
923
|
console.log("✅ service uninstalled.");
|
|
747
924
|
break;
|
|
748
925
|
default:
|
|
749
|
-
console.error("Usage: ocx service [install|start|stop|status|uninstall|remove]");
|
|
926
|
+
console.error("Usage: ocx service [install|start|stop|status|uninstall|remove] [--native|--scheduler]");
|
|
750
927
|
console.error(" With no subcommand, installs/updates and starts the background service.");
|
|
928
|
+
console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
|
|
751
929
|
process.exit(1);
|
|
752
930
|
}
|
|
753
931
|
}
|
package/src/update/index.ts
CHANGED
|
@@ -58,6 +58,24 @@ function npmSpawnTarget(bin: string): { bin: string; shell: boolean } {
|
|
|
58
58
|
return { bin: "npm.cmd", shell: true };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The GUI update worker sets OCX_SERVICE=1 and has stdio ignored — inheriting that for
|
|
63
|
+
* `npm.cmd` (shell:true) opens stacked visible consoles on Windows. Pipe instead and
|
|
64
|
+
* relay bounded output after the child exits. (Ported from PR #167.)
|
|
65
|
+
*/
|
|
66
|
+
function updateChildStdio(): "inherit" | "pipe" {
|
|
67
|
+
if (process.env.OCX_SERVICE === "1") return "pipe";
|
|
68
|
+
if (typeof process.stdout.isTTY === "boolean" && !process.stdout.isTTY) return "pipe";
|
|
69
|
+
return "inherit";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null; stderr?: string | Buffer | null }): void {
|
|
73
|
+
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
74
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
75
|
+
if (stdout) console.log(stdout.length > 4000 ? `${label}${stdout.slice(-4000)}` : stdout);
|
|
76
|
+
if (stderr) console.error(stderr.length > 4000 ? `${label}${stderr.slice(-4000)}` : stderr);
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
/** Latest published version from the registry (best-effort; null if npm isn't available). */
|
|
62
80
|
export function latestVersion(tag: string): string | null {
|
|
63
81
|
const npm = npmSpawnTarget("npm");
|
|
@@ -160,7 +178,13 @@ export async function runUpdate(): Promise<void> {
|
|
|
160
178
|
// Full `ocx stop` semantics (drain, service stop, restore).
|
|
161
179
|
if (serviceWasInstalled || readPid() || readRuntimePort()) {
|
|
162
180
|
console.log("⏹ Stopping the running proxy before updating...");
|
|
163
|
-
const
|
|
181
|
+
const stopStdio = updateChildStdio();
|
|
182
|
+
const stop = spawnSync(process.execPath, [process.argv[1], "stop"], {
|
|
183
|
+
stdio: stopStdio,
|
|
184
|
+
encoding: stopStdio === "pipe" ? "utf8" : undefined,
|
|
185
|
+
windowsHide: true,
|
|
186
|
+
});
|
|
187
|
+
if (stopStdio === "pipe") logSpawnOutput("", stop);
|
|
164
188
|
if (stop.status !== 0 || readPid() || readRuntimePort()) {
|
|
165
189
|
console.error("⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
|
|
166
190
|
process.exit(1);
|
|
@@ -178,7 +202,15 @@ export async function runUpdate(): Promise<void> {
|
|
|
178
202
|
console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
|
|
179
203
|
|
|
180
204
|
const target = npmSpawnTarget(bin);
|
|
181
|
-
const
|
|
205
|
+
const installStdio = updateChildStdio();
|
|
206
|
+
const r = spawnSync(target.bin, cmdArgs, {
|
|
207
|
+
stdio: installStdio,
|
|
208
|
+
encoding: installStdio === "pipe" ? "utf8" : undefined,
|
|
209
|
+
timeout: 180000,
|
|
210
|
+
windowsHide: true,
|
|
211
|
+
shell: target.shell,
|
|
212
|
+
});
|
|
213
|
+
if (installStdio === "pipe") logSpawnOutput("", r);
|
|
182
214
|
if (r.status === 0) {
|
|
183
215
|
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
|
|
184
216
|
// Re-bake the bundled Bun path into the Codex autostart shim on every
|
|
@@ -197,7 +229,14 @@ export async function runUpdate(): Promise<void> {
|
|
|
197
229
|
// launchd/schtasks/systemd user isn't left with the background proxy down.
|
|
198
230
|
if (serviceWasInstalled) {
|
|
199
231
|
console.log("🔁 Reinstalling the background service with the updated files...");
|
|
200
|
-
const
|
|
232
|
+
const { serviceReinstallArgs } = await import("../service");
|
|
233
|
+
const svcStdio = updateChildStdio();
|
|
234
|
+
const svc = spawnSync(process.execPath, [process.argv[1], ...serviceReinstallArgs()], {
|
|
235
|
+
stdio: svcStdio,
|
|
236
|
+
encoding: svcStdio === "pipe" ? "utf8" : undefined,
|
|
237
|
+
windowsHide: true,
|
|
238
|
+
});
|
|
239
|
+
if (svcStdio === "pipe") logSpawnOutput("", svc);
|
|
201
240
|
if (svc.status !== 0) console.warn("⚠️ Service refresh failed — run 'ocx service install' manually.");
|
|
202
241
|
} else {
|
|
203
242
|
console.log("Restart the proxy: ocx start");
|
package/src/update/job.ts
CHANGED
|
@@ -160,22 +160,24 @@ export function restartCommand(
|
|
|
160
160
|
installer: Installer,
|
|
161
161
|
launcher = packageLauncherPath(),
|
|
162
162
|
port?: number,
|
|
163
|
+
serviceArgs?: string[],
|
|
163
164
|
): { mode: "service" | "proxy"; bin: string; args: string[]; display: string } {
|
|
164
165
|
const mode = serviceInstalled ? "service" : "proxy";
|
|
165
166
|
const pinPort = !serviceInstalled && typeof port === "number" && Number.isFinite(port) && port > 0;
|
|
166
167
|
const startArgs = pinPort
|
|
167
168
|
? [launcher, "start", "--port", String(Math.trunc(port))]
|
|
168
169
|
: [launcher, "start"];
|
|
170
|
+
const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "install"])] : startArgs;
|
|
169
171
|
if (installer === "npm") {
|
|
170
172
|
const bin = nodeBin();
|
|
171
|
-
const args =
|
|
173
|
+
const args = svcArgs;
|
|
172
174
|
return { mode, bin, args, display: formatCommand(bin, args) };
|
|
173
175
|
}
|
|
174
176
|
// bun/source installs: restart via the current runtime executable + package launcher (both real
|
|
175
177
|
// .exe files), NOT the `ocx.cmd` shim. Spawning a `.cmd` shell-less throws EINVAL on Windows
|
|
176
178
|
// Node/Bun ≥18.20/20.12 (CVE-2024-27980 hardening) — the same class the npm path (nodeBin) avoids.
|
|
177
179
|
const bin = process.execPath;
|
|
178
|
-
const args =
|
|
180
|
+
const args = svcArgs;
|
|
179
181
|
return { mode, bin, args, display: formatCommand(bin, args) };
|
|
180
182
|
}
|
|
181
183
|
|
|
@@ -303,7 +305,14 @@ async function restartAfterUpdate(
|
|
|
303
305
|
// port to wait on; config is only the cold-start fallback.
|
|
304
306
|
const port = captured?.port ?? config.port ?? 10100;
|
|
305
307
|
const hostname = captured?.hostname ?? config.hostname ?? "127.0.0.1";
|
|
306
|
-
|
|
308
|
+
let svcArgs: string[] | undefined;
|
|
309
|
+
if (serviceInstalled) {
|
|
310
|
+
try {
|
|
311
|
+
const { serviceReinstallArgs } = await import("../service");
|
|
312
|
+
svcArgs = serviceReinstallArgs();
|
|
313
|
+
} catch { /* fallback to default service install */ }
|
|
314
|
+
}
|
|
315
|
+
const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs);
|
|
307
316
|
if (serviceInstalled) {
|
|
308
317
|
const result = runLoggedCommand(job, cmd.bin, cmd.args, RESTART_TIMEOUT_MS);
|
|
309
318
|
if (result.status !== 0) {
|