diff --git a/README.md b/README.md index 22d6956c6c..380510496d 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Qwen Cloud, SiliconFlow, and more. Full list: `ocx init` or the ocx init # interactive setup (writes config, wires Codex, offers the shim) ocx start [--port 10100] # start the proxy in the foreground ocx stop # stop + restore native Codex -ocx service [install|start|stop|status|uninstall|remove] # background service +ocx service [install|repair|restart|start|stop|status|uninstall|remove] # background service ocx codex-shim install # start the proxy on demand whenever `codex` launches ocx health [--json] # check immediate proxy liveness ocx ready [--json] [--wait [--timeout ]] # check post-sync readiness diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index f677871a28..bacb25b646 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -146,15 +146,16 @@ Invalide le cache local du sélecteur de modèles de Codex afin qu’il soit rec ## Service d’arrière-plan -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` Exécute opencodex comme service d’arrière-plan géré à l’ouverture de session — **launchd** sous macOS, **unité utilisateur systemd** sous Linux et **Task Scheduler** sous Windows — qui démarre automatiquement à la connexion et redémarre après un plantage. Les services définissent `OCX_SERVICE=1` afin qu’un redémarrage ne réécrive pas inutilement la configuration Codex. | Sous-commande | Action | | --- | --- | -| aucune | Crée ou met à jour le service, puis le démarre. | +| aucune | Installe et démarre le service s’il est absent ; sinon, actualise et redémarre le service existant sans le réenregistrer. | | `install` | Crée et démarre le service. L’enregistrement exige une élévation sous Windows. | | `repair` | Actualise sur place un service installé et le redémarre, sans le réenregistrer. | +| `restart` | Alias de `repair`. | | `start` | Démarre un service installé. | | `stop` | Arrête le service et rétablit le fonctionnement natif de Codex. | | `status` | Affiche les diagnostics du service et du proxy, ainsi que les chemins des journaux. | @@ -165,6 +166,7 @@ Exécute opencodex comme service d’arrière-plan géré à l’ouverture de se ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 1f8c593e91..802f5921bd 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -150,15 +150,16 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 ## バックグラウンドサービス -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` opencodex を、ログイン時に自動起動し、クラッシュ時に自動再起動するログイン管理バックグラウンド サービス (macOS **launchd**、Linux **systemd ユーザー ユニット**、Windows **タスク スケジューラ**) として実行します。サービスは `OCX_SERVICE=1` を設定して実行されるため、再起動によって Codex 設定が変更されることはありません。 |サブコマンド |アクション | | --- | --- | -|なし |サービスを作成/更新して開始します。 | +|なし |未インストールなら作成して開始し、既存なら再登録せずに更新して再起動します。 | | `install` |サービスを作成して開始します。 | | `repair` | 既存のサービスを再登録せずに更新して再起動します。 | +| `restart` | `repair` の別名です。 | | `start` |インストールされているサービスを開始します。 | | `stop` |サービスを停止し、ネイティブ Codex を復元します。 | | `status` |サービスとプロキシの診断とログ パスをレポートします。 | @@ -169,6 +170,7 @@ opencodex を、ログイン時に自動起動し、クラッシュ時に自動 ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 14d8db2cf0..f6d7d3fe2f 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -193,7 +193,7 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 ## 백그라운드 서비스 -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` 로그인 관리형 백그라운드 서비스로 opencodex를 실행합니다(macOS **launchd**, Linux **systemd** 사용자 유닛, Windows **Task Scheduler**). 로그인 시 자동 시작하고 충돌 시 자동 재시작합니다. 서비스 실행은 @@ -201,9 +201,10 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 | 하위 명령 | 동작 | | --- | --- | -| 없음 | 서비스를 생성/업데이트하고 시작합니다. | +| 없음 | 서비스가 없으면 설치하고 시작하며, 이미 있으면 재등록하지 않고 새로 고쳐 재시작합니다. | | `install` | 서비스를 생성하고 시작합니다. | | `repair` | 설치된 서비스를 다시 등록하지 않고 제자리에서 새로 고친 뒤 재시작합니다. | +| `restart` | `repair`의 별칭입니다. | | `start` | 설치된 서비스를 시작합니다. | | `stop` | 서비스를 중지하고 기본 Codex를 복원합니다. | | `status` | 서비스와 프록시 진단, 로그 경로를 보고합니다. | @@ -214,6 +215,7 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 486d10321e..6bb459176c 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -198,7 +198,7 @@ same stale-`app-server` warning and optional `--restart-codex` behavior as `ocx ## Background service -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` Run opencodex as a login-managed background service (macOS **launchd**, Linux **systemd user unit**, Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash. Service runs set @@ -211,19 +211,25 @@ run `ocx service repair` to refresh the task with the restored package paths. | Subcommand | Action | | --- | --- | -| none | Create/update and start the service. | +| none | Install and start when absent; otherwise refresh and restart the existing service without re-registering it. | | `install` | Create and start the service. Registers it, which on Windows needs elevation. | | `repair` | Refresh an installed service in place and restart it, without re-registering it. | +| `restart` | Alias of `repair`. | | `start` | Start an installed service. | | `stop` | Stop the service and restore native Codex. | | `status` | Report service and proxy diagnostics plus log paths. | | `uninstall` | Remove the service and restore native Codex. | | `remove` | Alias of `uninstall`. | +On Windows, a bare `ocx service` runs the install path only after both Task Scheduler and WinSW are +proven absent. If either status query is inconclusive, it refuses to register anything and asks you +to run `ocx service status`; use explicit `ocx service install` only after confirming absence. + ```bash ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 2696370cc5..7a5abeedab 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -209,7 +209,7 @@ opencodex. Предупреждение о stale-`app-server` и optional `--res ## Фоновая служба -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` Запустить opencodex как login-managed background service (macOS **launchd**, Linux **systemd user unit**, Windows **Task Scheduler**), которая автоматически стартует при логине и сама @@ -218,9 +218,10 @@ unit**, Windows **Task Scheduler**), которая автоматически | Подкоманда | Действие | | --- | --- | -| none | Создать/обновить и запустить службу. | +| none | Установить и запустить службу, если её нет; иначе обновить и перезапустить существующую службу без повторной регистрации. | | `install` | Создать и запустить службу. | | `repair` | Обновить установленную службу на месте и перезапустить её без повторной регистрации. | +| `restart` | Псевдоним команды `repair`. | | `start` | Запустить уже установленную службу. | | `stop` | Остановить службу и восстановить native Codex. | | `status` | Показать диагностику службы и прокси, а также пути к логам. | @@ -231,6 +232,7 @@ unit**, Windows **Task Scheduler**), которая автоматически ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 134442a2cb..7bb4d0d257 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -232,7 +232,7 @@ ve isteğe bağlı `--restart-codex` davranışı geçerlidir. ## Arka plan servisi -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` opencodex'i oturum açmada otomatik başlayan ve çökmede otomatik yeniden başlayan oturumla yönetilen bir arka plan servisi (macOS **launchd**, Linux **systemd @@ -242,9 +242,10 @@ yapılandırmasını dalgalandırmaz. | Alt komut | Eylem | | --- | --- | -| none | Servisi oluşturun/güncelleyin ve başlatın. | +| none | Servis yoksa kurup başlatın; varsa yeniden kaydetmeden yenileyip yeniden başlatın. | | `install` | Servisi oluşturun ve başlatın. Kaydeder, bu da Windows'ta yükseltme gerektirir. | | `repair` | Kurulu bir servisi yerinde yenileyin ve yeniden kaydetmeden yeniden başlatın. | +| `restart` | `repair` komutunun takma adıdır. | | `start` | Kurulu bir servisi başlatın. | | `stop` | Servisi durdurun ve yerel Codex'i geri yükleyin. | | `status` | Servis ve proxy tanılamalarını artı günlük yollarını bildirin. | @@ -255,6 +256,7 @@ yapılandırmasını dalgalandırmaz. ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` @@ -436,4 +438,3 @@ Yeni sürümler, [Sürüm iş akışı](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) bunları npm'de yayınladığında kullanılabilir hale gelir. - diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 4d103f0f06..02e85389f9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -147,15 +147,16 @@ ocx status --json ## 后台服务 -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` 将 opencodex 作为登录管理的后台服务运行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登录时自动启动,在崩溃时自动重启。服务运行会设置 `OCX_SERVICE=1`,因此重启时不会反复改动 Codex 配置。 | 子命令 | 操作 | | --- | --- | -| none | 创建/更新并启动服务。 | +| none | 服务不存在时安装并启动;已存在时不重新注册,直接刷新并重启。 | | `install` | 创建并启动服务。 | | `repair` | 就地刷新已安装的服务并重启,不重新注册。 | +| `restart` | `repair` 的别名。 | | `start` | 启动已安装的服务。 | | `stop` | 停止服务并恢复原生 Codex。 | | `status` | 报告服务和代理诊断信息及日志路径。 | @@ -166,6 +167,7 @@ ocx status --json ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 02983cff71..3fc344ecc3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -141,15 +141,16 @@ ocx status --json ## 背景服務 -### `ocx service [install|repair|start|stop|status|uninstall|remove]` +### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` 將 opencodex 作為登入管理的背景服務執行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登入時自動啟動並在崩潰時自動重啟。服務執行時設定 `OCX_SERVICE=1`,使重啟不會折騰 Codex 設定。 | 子指令 | 動作 | | --- | --- | -| 無 | 建立/更新並啟動服務。 | +| 無 | 服務不存在時安裝並啟動;已存在時不重新註冊,直接重新整理並重啟。 | | `install` | 建立並啟動服務。註冊它,在 Windows 上需要提高權限。 | | `repair` | 就地重新整理已安裝的服務並重啟它,而不重新註冊。 | +| `restart` | `repair` 的別名。 | | `start` | 啟動已安裝的服務。 | | `stop` | 停止服務並還原原生 Codex。 | | `status` | 回報服務與代理診斷及日誌路徑。 | @@ -160,6 +161,7 @@ ocx status --json ocx service ocx service install ocx service repair +ocx service restart ocx service status ocx service uninstall ``` diff --git a/src/cli/registry.ts b/src/cli/registry.ts index c8c786b54e..be99d804e5 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -61,10 +61,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "service", - usage: "ocx service [install|start|stop|status|uninstall|remove]", + usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]", summary: "Run as a background service.", details: [ - "With no subcommand, installs/updates and starts the background service.", + "With no subcommand, installs when absent or repairs/restarts an existing service.", + "`restart` is an alias of `repair` and does not re-register an installed service.", "Use `ocx service status` to see diagnostics and log paths.", ], }, diff --git a/src/service.ts b/src/service.ts index a00e616ba1..cf61a657fb 100644 --- a/src/service.ts +++ b/src/service.ts @@ -3274,6 +3274,7 @@ export async function serviceStatusReport( } export function normalizeServiceSubcommand(sub?: string): string { + if (sub === "restart") return "repair"; return sub ?? "install"; } @@ -3283,6 +3284,119 @@ export interface ParsedServiceArgs { invalid: string[]; } +export type ServiceInstallationState = "installed" | "absent" | "unknown"; + +export interface ServiceInstallationProbe { + state: ServiceInstallationState; + detail?: string; +} + +export interface ServiceInstallationProbeHooks { + platform?: NodeJS.Platform; + exists?: (path: string) => boolean; + probeWindowsTask?: () => WindowsSchedulerTaskProbe; + nativeStatus?: () => WinswStatus; +} + +/** + * Read only enough registration state to choose between install and repair. + * Windows must keep query failure distinct from proven absence: treating an + * unreadable scheduler/SCM as absent would send a bare command into the + * elevated registration path and recreate the original #2287 failure. + */ +export function probeServiceInstallation( + hooks: ServiceInstallationProbeHooks = {}, +): ServiceInstallationProbe { + const platform = hooks.platform ?? process.platform; + const exists = hooks.exists ?? existsSync; + if (platform === "darwin") { + return { state: exists(plistPath()) ? "installed" : "absent" }; + } + if (platform === "linux") { + return { state: exists(unitPath()) ? "installed" : "absent" }; + } + if (platform !== "win32") return { state: "absent" }; + + let scheduler: WindowsSchedulerTaskProbe; + try { + scheduler = (hooks.probeWindowsTask ?? probeWindowsSchedulerTask)(); + } catch (cause) { + scheduler = { status: "unknown", detail: schtasksErrorDetail(cause) }; + } + let native: WinswStatus; + try { + native = (hooks.nativeStatus ?? statusWinswRaw)(); + } catch { + native = "unknown"; + } + + if (scheduler.status === "present" || native === "started" || native === "stopped") { + return { state: "installed" }; + } + if (scheduler.status === "unknown" || native === "unknown") { + const parts = [ + scheduler.status === "unknown" ? `Task Scheduler: ${scheduler.detail}` : null, + native === "unknown" ? "WinSW status could not be determined" : null, + ].filter((part): part is string => Boolean(part)); + return { state: "unknown", detail: parts.join("; ") }; + } + return { state: "absent" }; +} + +/** + * A bare invocation is an idempotent "make the installed service current" + * operation. First-time setup still installs, but an existing registration must + * use the repair path so Windows does not re-run the elevated `schtasks /create`. + * Backend flags remain an explicit install request because they select which + * registration mechanism to create. + */ +export function selectServiceSubcommand( + parsed: ParsedServiceArgs, + options: { hasExplicitSubcommand: boolean; installed: boolean }, +): string { + if (!options.hasExplicitSubcommand && parsed.backend === null && options.installed) return "repair"; + return parsed.sub; +} + +export type ServiceCommandPlan = + | { ok: true; parsed: ParsedServiceArgs; command: string } + | { ok: false; message: string }; + +export function planServiceCommand( + args: string[], + options: { platform?: NodeJS.Platform; probeInstallation?: () => ServiceInstallationProbe } = {}, +): ServiceCommandPlan { + const parsed = parseServiceArgs(args); + if (parsed.invalid.length > 0) { + return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` }; + } + if (parsed.backend && parsed.sub !== "install") { + return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." }; + } + if (parsed.backend === "native" && (options.platform ?? process.platform) !== "win32") { + return { ok: false, message: "--native (WinSW) is Windows-only." }; + } + + const hasExplicitSubcommand = args.some(arg => !arg.startsWith("--")); + let installed = false; + if (!hasExplicitSubcommand && parsed.backend === null) { + const probe = (options.probeInstallation ?? probeServiceInstallation)(); + if (probe.state === "unknown") { + const suffix = probe.detail ? ` (${probe.detail})` : ""; + return { + ok: false, + message: `Could not safely determine whether the service is installed${suffix}. Run 'ocx service status' and retry; use explicit 'ocx service install' only after confirming it is absent.`, + }; + } + installed = probe.state === "installed"; + } + return { + ok: true, + parsed, + command: selectServiceSubcommand(parsed, { hasExplicitSubcommand, installed }), + }; +} + /** * `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the * subcommand; backend flags are only meaningful for `install` (validated by the caller). @@ -3308,20 +3422,13 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs { } export async function serviceCommand(...args: (string | undefined)[]): Promise { - const parsed = parseServiceArgs(args.filter((a): a is string => Boolean(a))); - const command = parsed.sub; - if (parsed.invalid.length > 0) { - console.error(`Unknown service option: ${parsed.invalid.join(" ")}`); - process.exit(1); - } - if (parsed.backend && command !== "install") { - console.error("--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend."); - process.exit(1); - } - if (parsed.backend === "native" && process.platform !== "win32") { - console.error("--native (WinSW) is Windows-only."); + const filteredArgs = args.filter((a): a is string => Boolean(a)); + const plan = planServiceCommand(filteredArgs); + if (!plan.ok) { + console.error(plan.message); process.exit(1); } + const { parsed, command } = plan; if (command === "repair") { assertServiceEnvironmentMatchesInstall(); assertServiceAuthEnvironment(); @@ -3458,9 +3565,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { test("invalid service and codex-shim usage include remove alias", () => { const cases = [ - { args: ["service", "nope"], expected: "Usage: ocx service [install|repair|start|stop|status|uninstall|remove]" }, + { args: ["service", "nope"], expected: "Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove]" }, { args: ["codex-shim", "nope"], expected: "Usage: ocx codex-shim " }, ]; diff --git a/tests/service.test.ts b/tests/service.test.ts index 8ee4cd243b..69ef8209db 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -5,7 +5,7 @@ import { isAbsolute, join, posix, win32 } from "node:path"; import * as serviceModule from "../src/service"; import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceInstallState, prepareServiceInstall, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; import type { ServiceDiagnostic } from "../src/service"; import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../src/service"; import { buildWinswXml } from "../src/lib/winsw"; @@ -89,19 +89,94 @@ describe("service listen-port bake", () => { }); describe("systemd service unit", () => { - test("bare service command defaults to the install/update/start path", async () => { + test("bare service installs only when absent and otherwise selects no-admin repair", async () => { expect(normalizeServiceSubcommand()).toBe("install"); + expect(normalizeServiceSubcommand("restart")).toBe("repair"); expect(normalizeServiceSubcommand("start")).toBe("start"); expect(normalizeServiceSubcommand("nope")).toBe("nope"); + const bare = parseServiceArgs([]); + expect(selectServiceSubcommand(bare, { hasExplicitSubcommand: false, installed: false })).toBe("install"); + expect(selectServiceSubcommand(bare, { hasExplicitSubcommand: false, installed: true })).toBe("repair"); + expect(selectServiceSubcommand(parseServiceArgs(["install"]), { + hasExplicitSubcommand: true, + installed: true, + })).toBe("install"); + expect(selectServiceSubcommand(parseServiceArgs(["--native"]), { + hasExplicitSubcommand: false, + installed: true, + })).toBe("install"); + + let probes = 0; + const installed = planServiceCommand([], { + probeInstallation: () => { probes += 1; return { state: "installed" }; }, + }); + expect(installed).toMatchObject({ ok: true, command: "repair" }); + expect(probes).toBe(1); + + const absent = planServiceCommand([], { + probeInstallation: () => ({ state: "absent" }), + }); + expect(absent).toMatchObject({ ok: true, command: "install" }); + + const unknown = planServiceCommand([], { + probeInstallation: () => ({ state: "unknown", detail: "query failed" }), + }); + expect(unknown).toMatchObject({ ok: false }); + if (!unknown.ok) expect(unknown.message).toContain("Could not safely determine"); + + probes = 0; + const invalid = planServiceCommand(["--bogus"], { + probeInstallation: () => { probes += 1; return { state: "installed" }; }, + }); + expect(invalid).toMatchObject({ ok: false, message: "Unknown service option: --bogus" }); + expect(probes).toBe(0); + + const explicitInstall = planServiceCommand(["install"], { + probeInstallation: () => { probes += 1; return { state: "unknown" }; }, + }); + expect(explicitInstall).toMatchObject({ ok: true, command: "install" }); + expect(probes).toBe(0); + const service = await readText("src/service.ts"); const serviceCommand = service.slice(service.indexOf("export async function serviceCommand")); - // Args flow through parseServiceArgs (which applies the install default) into the switch. - expect(serviceCommand).toContain("const parsed = parseServiceArgs("); - expect(serviceCommand).toContain("const command = parsed.sub;"); + expect(serviceCommand).toContain("const plan = planServiceCommand(filteredArgs);"); + expect(serviceCommand).toContain("const { parsed, command } = plan;"); expect(serviceCommand).toContain("switch (command)"); }); + test("Windows install presence distinguishes unknown queries from proven absence", () => { + const present = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "present" }), + nativeStatus: () => "unknown", + }); + expect(present.state).toBe("installed"); + + const absent = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "absent" }), + nativeStatus: () => "nonexistent", + }); + expect(absent.state).toBe("absent"); + + const schedulerUnknown = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "unknown", detail: "localized query failure" }), + nativeStatus: () => "nonexistent", + }); + expect(schedulerUnknown).toMatchObject({ state: "unknown" }); + expect(schedulerUnknown.detail).toContain("localized query failure"); + + const nativeUnknown = probeServiceInstallation({ + platform: "win32", + probeWindowsTask: () => ({ status: "absent" }), + nativeStatus: () => "unknown", + }); + expect(nativeUnknown).toMatchObject({ state: "unknown" }); + expect(nativeUnknown.detail).toContain("WinSW status"); + }); + test("uses unquoted append targets for service logs", () => { const unit = buildUnit(); diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index f3f5cce06f..f89a9af2f6 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -234,6 +234,10 @@ describe("service backend CLI parsing", () => { expect(parseServiceArgs([])).toEqual({ sub: "install", backend: null, invalid: [] }); }); + test("restart aliases the existing no-admin repair path", () => { + expect(parseServiceArgs(["restart"])).toEqual({ sub: "repair", backend: null, invalid: [] }); + }); + test("--scheduler and unknown flags are recognized separately", () => { expect(parseServiceArgs(["install", "--scheduler"]).backend).toBe("scheduler"); expect(parseServiceArgs(["install", "--bogus"]).invalid).toEqual(["--bogus"]);