@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/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: 1,
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 runs in its own hidden console, so switching that console to UTF-8 is
299
- // safe (no leak into user shells) and lets cmd parse any UTF-8 remnants correctly.
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
- export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string {
339
- const escapedScript = taskXmlString(script);
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>${escapedScript}</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; start: () => void; stop: () => 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(); return true; }
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(); removeServiceInstallState(); return true; }
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 status = statusWindows();
681
- return status ? `installed (Task Scheduler; ${diagnostics})` : `not installed (${diagnostics})`;
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 async function serviceCommand(sub?: string): Promise<void> {
698
- const ops = platformOps();
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("✅ opencodex service installed + started (auto-starts on login, auto-restarts on crash).");
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
- ops.uninstall();
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
  }
@@ -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 stop = spawnSync(process.execPath, [process.argv[1], "stop"], { stdio: "inherit", windowsHide: true });
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 r = spawnSync(target.bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true, shell: target.shell });
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 svc = spawnSync(process.execPath, [process.argv[1], "service", "install"], { stdio: "inherit", windowsHide: true });
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 = serviceInstalled ? [launcher, "service", "install"] : startArgs;
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 = serviceInstalled ? [launcher, "service", "install"] : startArgs;
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
- const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port);
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) {