@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913
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/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { assertNotRealLaunchAgentsUnderTest } from "../lib/test-home-guard";
|
|
4
|
+
import { sh } from "./guards";
|
|
5
|
+
import { plistPath } from "./state";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { chmodSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { getConfigDir } from "../config";
|
|
10
|
+
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime";
|
|
11
|
+
import { serviceApiTokenFilePath } from "../lib/service-secrets";
|
|
12
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
13
|
+
import { writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./guards";
|
|
14
|
+
import { resolveServiceListenPort, buildServiceShellCommand, buildServiceLauncherShellCommand, installedServiceListenPort, resolvedProxyEnv } from "./health";
|
|
15
|
+
import { SERVICE_MANAGED_ENV, LABEL, cliEntry, stableLauncherEntry, logPath, serviceStatePath, currentCodexSqliteHomeAbsolute, type ServiceInstallState, writeServiceInstallState, readServiceInstallState } from "./state";
|
|
16
|
+
import { writeServiceDefinitionFile } from "./windows-ops";
|
|
17
|
+
import { readTextOrNull } from "./windows-taskxml";
|
|
18
|
+
|
|
19
|
+
function plistString(value: string): string {
|
|
20
|
+
return value
|
|
21
|
+
.replace(/&/g, "&")
|
|
22
|
+
.replace(/</g, "<")
|
|
23
|
+
.replace(/>/g, ">")
|
|
24
|
+
.replace(/"/g, """)
|
|
25
|
+
.replace(/'/g, "'");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Render the launchd plist. Mirrors `buildUnit`: when `deps.launcher` names a stable `ocx`
|
|
30
|
+
* executable, the job execs that launcher instead of the package-local Bun + CLI pair, so a
|
|
31
|
+
* version-manager upgrade (mise, asdf, nvm) that replaces the package directory is picked up
|
|
32
|
+
* on the next launchd start instead of leaving the old build serving (#3464 — the macOS
|
|
33
|
+
* counterpart of #2898). Discovery belongs to `installLaunchd()`; the default here is the
|
|
34
|
+
* legacy pair so callers and tests stay hermetic.
|
|
35
|
+
*/
|
|
36
|
+
export function buildPlist(
|
|
37
|
+
proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
|
|
38
|
+
deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {},
|
|
39
|
+
): string {
|
|
40
|
+
const runtime = deps.runtime ?? durableBunRuntime();
|
|
41
|
+
const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
|
|
42
|
+
const launcher = deps.launcher ?? null;
|
|
43
|
+
const log = logPath();
|
|
44
|
+
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
45
|
+
const codexHome = process.env.CODEX_HOME?.trim();
|
|
46
|
+
const codexSqliteHome = currentCodexSqliteHomeAbsolute();
|
|
47
|
+
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
|
|
48
|
+
const envLines = [
|
|
49
|
+
` <key>OCX_SERVICE</key><string>1</string>`,
|
|
50
|
+
// OCX_SERVICE alone cannot identify the managed job: `ocx claude` and `ocx opencode`
|
|
51
|
+
// also set it on the proxies they spawn, to borrow its routing-preservation meaning
|
|
52
|
+
// (src/cli/index.ts preserveRouting). Only the wrapper writes this second marker, so
|
|
53
|
+
// the dashboard-stop refusal below can tell a real launchd job from an ordinary child.
|
|
54
|
+
` <key>${SERVICE_MANAGED_ENV}</key><string>1</string>`,
|
|
55
|
+
...(launcher ? [] : [
|
|
56
|
+
` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
|
|
57
|
+
` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
|
|
58
|
+
]),
|
|
59
|
+
// A launcher resolves the current package's bundled Bun after every upgrade. Preserve
|
|
60
|
+
// only a proof-bound shell override; baking a package-local path here would recreate
|
|
61
|
+
// the version-manager pin that launcher mode exists to remove (same rule as buildUnit).
|
|
62
|
+
launcher && runtime.source === "override"
|
|
63
|
+
? ` <key>${runtime.overrideEnv}</key><string>${plistString(runtime.path)}</string>`
|
|
64
|
+
: null,
|
|
65
|
+
` <key>PATH</key><string>${plistString(path)}</string>`,
|
|
66
|
+
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
|
|
67
|
+
codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
|
|
68
|
+
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
|
|
69
|
+
...proxyEnv.map(({ name, value }) =>
|
|
70
|
+
` <key>${name}</key><string>${plistString(value)}</string>`),
|
|
71
|
+
].filter((line): line is string => Boolean(line)).join("\n");
|
|
72
|
+
const command = launchdServiceCommand(launcher, runtime);
|
|
73
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
74
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
75
|
+
<plist version="1.0">
|
|
76
|
+
<dict>
|
|
77
|
+
<key>Label</key><string>${LABEL}</string>
|
|
78
|
+
<key>ProgramArguments</key>
|
|
79
|
+
<array>
|
|
80
|
+
<string>/bin/sh</string>
|
|
81
|
+
<string>-lc</string>
|
|
82
|
+
<string>${plistString(command)}</string>
|
|
83
|
+
</array>
|
|
84
|
+
<key>RunAtLoad</key><true/>
|
|
85
|
+
<key>KeepAlive</key><true/>
|
|
86
|
+
<key>EnvironmentVariables</key>
|
|
87
|
+
<dict>
|
|
88
|
+
${envLines}
|
|
89
|
+
</dict>
|
|
90
|
+
<key>StandardOutPath</key><string>${plistString(log)}</string>
|
|
91
|
+
<key>StandardErrorPath</key><string>${plistString(log)}</string>
|
|
92
|
+
</dict>
|
|
93
|
+
</plist>
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The single `EnvironmentVariables` line {@link buildPlist} fills from the env of whatever
|
|
99
|
+
* process happens to be repairing.
|
|
100
|
+
*/
|
|
101
|
+
const PLIST_PATH_ENTRY = /^(\s*<key>PATH<\/key><string>)([^\n]*)(<\/string>)$/m;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The rendered plist with the PREVIOUS definition's `PATH` put back — or null when `PATH`
|
|
105
|
+
* is not the only difference.
|
|
106
|
+
*
|
|
107
|
+
* `buildPlist` bakes `process.env.PATH`, and `ocx service repair` is run by whatever has a
|
|
108
|
+
* shell: a tray helper, `ocx update`'s child, an ssh session, a cron job. Each of those
|
|
109
|
+
* carries a DIFFERENT PATH from the login shell that installed the service, so comparing
|
|
110
|
+
* whole-file bytes made the "nothing to repair" pre-check miss almost every time it
|
|
111
|
+
* mattered: a healthy hub was evicted, and its PATH rewritten to the narrower one, purely
|
|
112
|
+
* because of who asked (#4236, review finding 2).
|
|
113
|
+
*
|
|
114
|
+
* Reuse rather than ignore. A plist that differs only in PATH is not "equal" — dropping the
|
|
115
|
+
* difference silently would let a repair report a no-op while launchd keeps a PATH the
|
|
116
|
+
* operator has changed on purpose. Putting the previous value back makes the two files
|
|
117
|
+
* genuinely identical, so the caller's ordinary byte comparison decides, and the PATH the
|
|
118
|
+
* service already runs with is the one that survives.
|
|
119
|
+
*
|
|
120
|
+
* The caller applies this only when the live job is loaded from exactly the exec line this
|
|
121
|
+
* install baked: that is the evidence that the running definition is the one on disk, which
|
|
122
|
+
* is what makes keeping its PATH correct rather than a guess. Whenever anything ELSE about
|
|
123
|
+
* the definition changed the plist is rewritten in full, PATH included, so a real
|
|
124
|
+
* re-install still updates it.
|
|
125
|
+
*/
|
|
126
|
+
export function reusePreviousPlistPathVariable(previous: string, rendered: string): string | null {
|
|
127
|
+
const prev = PLIST_PATH_ENTRY.exec(previous);
|
|
128
|
+
const next = PLIST_PATH_ENTRY.exec(rendered);
|
|
129
|
+
if (!prev || !next || prev[2] === next[2]) return null;
|
|
130
|
+
// A function replacer, not a `$1` template: a PATH entry containing `$&` or `$1` would
|
|
131
|
+
// otherwise be re-expanded into the file.
|
|
132
|
+
const adopted = rendered.replace(PLIST_PATH_ENTRY, (_match, open: string, _value: string, close: string) =>
|
|
133
|
+
`${open}${prev[2] ?? ""}${close}`);
|
|
134
|
+
return adopted === previous ? adopted : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The exec line {@link buildPlist} bakes, for the launcher and runtime a single install
|
|
139
|
+
* already resolved. Shared so `installLaunchd` can verify the live job against the exact
|
|
140
|
+
* string it just wrote instead of re-deriving it from install state that has not been
|
|
141
|
+
* written yet (a fresh install has no state, so `expectedLaunchdCommand` would hand back
|
|
142
|
+
* the Bun + CLI pair and call a correctly loaded launcher job stale).
|
|
143
|
+
*/
|
|
144
|
+
function launchdServiceCommand(
|
|
145
|
+
launcher: string | null,
|
|
146
|
+
runtime: DurableBunRuntime = durableBunRuntime(),
|
|
147
|
+
port: number = resolveServiceListenPort(),
|
|
148
|
+
): string {
|
|
149
|
+
if (launcher) return buildServiceLauncherShellCommand(launcher, port);
|
|
150
|
+
const { bun, cli } = cliEntry(runtime);
|
|
151
|
+
return buildServiceShellCommand(bun, cli, port);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The exec line the installed launchd plist is expected to carry, derived from the recorded
|
|
156
|
+
* install state rather than rediscovered: a launcher install runs the launcher, a legacy or
|
|
157
|
+
* stateless install runs the Bun + CLI pair. `start` and `status` compare the live job
|
|
158
|
+
* against this, so both must follow the launcher or a healthy launcher-backed job reads as
|
|
159
|
+
* "an OLDER plist" (#3464). PATH is deliberately NOT re-walked here.
|
|
160
|
+
*/
|
|
161
|
+
export function expectedLaunchdCommand(
|
|
162
|
+
port: number,
|
|
163
|
+
deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string } } = {},
|
|
164
|
+
): string {
|
|
165
|
+
const state = deps.state === undefined ? readServiceInstallState() : deps.state;
|
|
166
|
+
if (state?.launcherPath) return buildServiceLauncherShellCommand(state.launcherPath, port);
|
|
167
|
+
const entry = deps.entry ?? cliEntry();
|
|
168
|
+
return buildServiceShellCommand(entry.bun, entry.cli, port);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The `--port <n>` actually baked into the installed launchd plist, or null when it
|
|
173
|
+
* cannot be read. macOS only — named for launchd rather than "service" so no caller
|
|
174
|
+
* assumes it covers systemd or the Windows wrapper.
|
|
175
|
+
*
|
|
176
|
+
* `start` needs this because it does NOT rewrite the plist: an install made under
|
|
177
|
+
* OCX_BAKE_PORT, or any later config.port edit, would otherwise leave launchd serving
|
|
178
|
+
* one port while the confirmation probes another, failing a healthy service.
|
|
179
|
+
*
|
|
180
|
+
* Anchored on the closing tag and matched LAST: the command also carries the Bun and
|
|
181
|
+
* CLI paths, and a path containing the literal `start --port 9999` must not shadow
|
|
182
|
+
* the real argument. buildPlist emits the command as the final ProgramArguments
|
|
183
|
+
* string, and buildServiceShellCommand puts the port at the very end of it.
|
|
184
|
+
*/
|
|
185
|
+
export function launchdListenPort(deps: { readPlist?: () => string } = {}): number | null {
|
|
186
|
+
try {
|
|
187
|
+
const text = (deps.readPlist ?? (() => readFileSync(plistPath(), "utf8")))();
|
|
188
|
+
const last = [...text.matchAll(/start --port (\d{1,5})\s*<\/string>/g)].at(-1);
|
|
189
|
+
if (!last) return null;
|
|
190
|
+
const n = Number(last[1]);
|
|
191
|
+
return n > 0 && n <= 65535 ? n : null;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Run `launchctl` and report BOTH streams regardless of exit status.
|
|
199
|
+
*
|
|
200
|
+
* `launchctl load` writes "Load failed: <n>: <reason>" to stderr and exits 0 for
|
|
201
|
+
* every already-bootstrapped job. `sh()` above is execSync, which throws only on a
|
|
202
|
+
* non-zero exit, so install and start both reported success for a load that did
|
|
203
|
+
* nothing — leaving launchd running the PREVIOUS plist while a freshly written one
|
|
204
|
+
* sat unused on disk. That is the 2026-08-02 report: `ocx service` prints a
|
|
205
|
+
* checkmark, `launchctl list` shows the job, and the port answers nothing.
|
|
206
|
+
*
|
|
207
|
+
* spawnSync, NOT execFileSync: execFileSync discards stderr when the child exits 0,
|
|
208
|
+
* which is precisely this case — a runner built on it returns an empty stderr and
|
|
209
|
+
* the guard below can never fire. Measured on macOS 27.0.
|
|
210
|
+
*/
|
|
211
|
+
export function runLaunchctl(
|
|
212
|
+
args: string[],
|
|
213
|
+
deps: { run?: typeof spawnSync } = {},
|
|
214
|
+
): { ok: boolean; stdout: string; stderr: string; status: number | null } {
|
|
215
|
+
const run = deps.run ?? spawnSync;
|
|
216
|
+
// Only the real runner is guarded. Tests that inject a spawnSync stand-in are
|
|
217
|
+
// exercising the parsing, not reaching launchd, and must keep working.
|
|
218
|
+
if (run === spawnSync) assertLiveServiceManagerAllowed(`launchctl ${args.join(" ")}`);
|
|
219
|
+
const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
|
|
220
|
+
// `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
|
|
221
|
+
// null for a signalled child; neither may be reported as success.
|
|
222
|
+
if (result.error) {
|
|
223
|
+
return { ok: false, stdout: "", stderr: String(result.error.message ?? ""), status: null };
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
ok: result.status === 0,
|
|
227
|
+
stdout: String(result.stdout ?? "").trim(),
|
|
228
|
+
stderr: String(result.stderr ?? "").trim(),
|
|
229
|
+
/*
|
|
230
|
+
* The NUMBER, not just its zero-ness.
|
|
231
|
+
*
|
|
232
|
+
* `launchctl print` distinguishes "that domain does not exist" (112) from
|
|
233
|
+
* "the domain answered and has no such service" (113), and an ownership
|
|
234
|
+
* probe needs that difference: the second proves absence, the first only
|
|
235
|
+
* proves we could not look. Collapsing both into `ok: false` forced callers
|
|
236
|
+
* to parse stderr, which Apple does not treat as a stable interface.
|
|
237
|
+
*/
|
|
238
|
+
status: result.status ?? null,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Whether launchctl output indicates the operation did not take. Needed because
|
|
244
|
+
* `ok` alone is insufficient for the legacy `load`/`unload` subcommands, which
|
|
245
|
+
* report failure on stderr while exiting 0. `bootstrap` exits 5, so for that path
|
|
246
|
+
* this is belt-and-braces rather than the only signal.
|
|
247
|
+
*/
|
|
248
|
+
export function launchctlLoadFailed(stderr: string): boolean {
|
|
249
|
+
return /\b(?:Load|Bootstrap) failed\b/i.test(stderr);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** launchd domain target for the current user's GUI session. */
|
|
253
|
+
export function launchdGuiDomain(): string {
|
|
254
|
+
return `gui/${process.getuid?.() ?? 0}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Whether launchd is running the job from the CURRENT plist. `launchctl list` only
|
|
259
|
+
* proves domain membership — a job bootstrapped from an older plist stays listed
|
|
260
|
+
* forever. `launchctl print` exposes the live `arguments`, which is the only way to
|
|
261
|
+
* catch a load that silently no-op'd.
|
|
262
|
+
*/
|
|
263
|
+
export function launchdJobMatchesPlist(
|
|
264
|
+
expectedCommand: string,
|
|
265
|
+
deps: { run?: typeof runLaunchctl } = {},
|
|
266
|
+
): { loaded: boolean; matchesPlist: boolean } {
|
|
267
|
+
const run = deps.run ?? runLaunchctl;
|
|
268
|
+
const printed = run(["print", `${launchdGuiDomain()}/${LABEL}`]);
|
|
269
|
+
if (!printed.ok) return { loaded: false, matchesPlist: false };
|
|
270
|
+
// `print` writes the arguments block to stdout for a live job. Search both streams
|
|
271
|
+
// anyway so a future launchctl that moves diagnostics between them cannot turn this
|
|
272
|
+
// into a false negative — a false "stale" verdict would send users to `bootout` for
|
|
273
|
+
// nothing.
|
|
274
|
+
const printedText = `${printed.stdout}\n${printed.stderr}`;
|
|
275
|
+
return { loaded: true, matchesPlist: printedText.includes(expectedCommand) };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** `launchctl print`: the domain answered and holds no such service. */
|
|
279
|
+
const LAUNCHCTL_NO_SUCH_SERVICE = 113;
|
|
280
|
+
|
|
281
|
+
/** `launchctl print`: that domain does not exist at all (label-independent). */
|
|
282
|
+
const LAUNCHCTL_NO_SUCH_DOMAIN = 112;
|
|
283
|
+
|
|
284
|
+
/** `launchctl bootstrap`: something is already bootstrapped under that label. */
|
|
285
|
+
const LAUNCHCTL_BOOTSTRAP_BUSY = 5;
|
|
286
|
+
|
|
287
|
+
/** `launchctl bootout`: nothing was loaded under that label, i.e. already stopped. */
|
|
288
|
+
const LAUNCHCTL_BOOTOUT_NO_SUCH_PROCESS = 3;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Every domain target an eviction has to cover.
|
|
292
|
+
*
|
|
293
|
+
* {@link probeLaunchdLoadState} asks `gui/<uid>` AND `user/<uid>` because the two domains
|
|
294
|
+
* are independent and hold separate service sets, while every MUTATING verb in this file
|
|
295
|
+
* addressed `gui/<uid>` alone. So a `user/`-domain registration of our Label used to:
|
|
296
|
+
* survive `ocx service stop` (`bootout gui/<uid>/<label>` exits 3, "No such process", which
|
|
297
|
+
* the stop path correctly reads as "nothing was loaded" — in the wrong domain); survive the
|
|
298
|
+
* install cleanup whose whole job is evicting a live manager before new assets land, which
|
|
299
|
+
* then installed over a serving job; and stay registered while `installLaunchd` bootstrapped
|
|
300
|
+
* a SECOND registration of the same Label into `gui/`, leaving two KeepAlive jobs fighting
|
|
301
|
+
* for one port.
|
|
302
|
+
*
|
|
303
|
+
* Both domains unconditionally rather than the one a probe reports: `bootout` against a
|
|
304
|
+
* label a domain does not hold exits 3 and changes nothing, so enumerating first would buy
|
|
305
|
+
* an extra round trip to learn what the verb itself already reports.
|
|
306
|
+
*/
|
|
307
|
+
export function launchdEvictionTargets(uid: number = process.getuid?.() ?? 0): string[] {
|
|
308
|
+
return [`gui/${uid}/${LABEL}`, `user/${uid}/${LABEL}`];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Whether a `bootout` exit status means "nothing of ours was loaded there" rather than a
|
|
313
|
+
* failure. 3 is "No such process"; 113/112 answer for the service and the domain, and a
|
|
314
|
+
* domain that does not exist cannot be holding a job of ours (a headless Mac has no `gui/`).
|
|
315
|
+
*/
|
|
316
|
+
export function launchctlBootoutBenign(status: number | null): boolean {
|
|
317
|
+
return status === 0
|
|
318
|
+
|| status === LAUNCHCTL_BOOTOUT_NO_SUCH_PROCESS
|
|
319
|
+
|| status === LAUNCHCTL_NO_SUCH_SERVICE
|
|
320
|
+
|| status === LAUNCHCTL_NO_SUCH_DOMAIN;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Four states, because three of them used to collapse into one bit.
|
|
325
|
+
*
|
|
326
|
+
* - `loaded-current` — a domain answers 0 and runs the command we expect.
|
|
327
|
+
* - `loaded-stale` — a domain answers 0 but runs a different command (an older plist).
|
|
328
|
+
* - `not-loaded` — every domain answered 112/113, which is proof of absence.
|
|
329
|
+
* - `unknown` — launchctl could not be asked, or answered something undocumented. NOT
|
|
330
|
+
* evidence of a problem, and deliberately not a reason to recommend `ocx service
|
|
331
|
+
* repair`: that command evicts the job, so recommending it on a failed probe is how
|
|
332
|
+
* #4236 turned a healthy hub into an outage.
|
|
333
|
+
*/
|
|
334
|
+
export type LaunchdLoadState = "loaded-current" | "loaded-stale" | "not-loaded" | "unknown";
|
|
335
|
+
|
|
336
|
+
export interface LaunchdLoadProbe {
|
|
337
|
+
state: LaunchdLoadState;
|
|
338
|
+
/** The domain that answered, when one did. */
|
|
339
|
+
domain?: string;
|
|
340
|
+
/** Why the probe is `unknown`. Never carries plist contents or credentials. */
|
|
341
|
+
detail?: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Whether launchd is running our job, and from which plist — asked with `launchctl print`
|
|
346
|
+
* in BOTH user domains.
|
|
347
|
+
*
|
|
348
|
+
* Replaces `launchctl list | grep <label>`, which enumerated the CALLER's bootstrap domain
|
|
349
|
+
* (so a healthy `gui/$uid` job was invisible from ssh/cron), swallowed every exit code
|
|
350
|
+
* through `|| true`, and matched the label unanchored anywhere on a line (so
|
|
351
|
+
* `com.opencodex.proxy.helper` read as ours). `gui/` and `user/` are independent and hold
|
|
352
|
+
* separate service sets — measured on macOS 27.0: the shipped agent answers 0 under
|
|
353
|
+
* `gui/<uid>` and 113 under `user/<uid>` — so asking one leaves the other free to hold a
|
|
354
|
+
* job this probe would then call absent (same reasoning as `inspectLaunchd`).
|
|
355
|
+
*/
|
|
356
|
+
export function probeLaunchdLoadState(deps: {
|
|
357
|
+
launchctl?: typeof runLaunchctl;
|
|
358
|
+
expectedCommand?: () => string;
|
|
359
|
+
uid?: number;
|
|
360
|
+
} = {}): LaunchdLoadProbe {
|
|
361
|
+
const run = deps.launchctl ?? runLaunchctl;
|
|
362
|
+
const uid = deps.uid ?? process.getuid?.() ?? 0;
|
|
363
|
+
for (const domain of [`gui/${uid}`, `user/${uid}`]) {
|
|
364
|
+
const printed = run(["print", `${domain}/${LABEL}`]);
|
|
365
|
+
if (printed.status === 0) {
|
|
366
|
+
const expected = (deps.expectedCommand
|
|
367
|
+
?? (() => expectedLaunchdCommand(installedServiceListenPort())))();
|
|
368
|
+
const printedText = `${printed.stdout}\n${printed.stderr}`;
|
|
369
|
+
return {
|
|
370
|
+
state: printedText.includes(expected) ? "loaded-current" : "loaded-stale",
|
|
371
|
+
domain,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (printed.status === LAUNCHCTL_NO_SUCH_SERVICE) continue;
|
|
375
|
+
// 112 is an answer ABOUT THE DOMAIN and is label-independent, so an unreachable
|
|
376
|
+
// domain cannot be hiding a job of ours. A headless Mac has no GUI domain and no
|
|
377
|
+
// installation either; calling that `unknown` would refuse every verdict on it.
|
|
378
|
+
if (printed.status === LAUNCHCTL_NO_SUCH_DOMAIN) continue;
|
|
379
|
+
return {
|
|
380
|
+
state: "unknown",
|
|
381
|
+
detail: printed.status === null
|
|
382
|
+
? `launchctl could not be run: ${printed.stderr || "spawn failed"}`
|
|
383
|
+
: `launchctl print ${domain}/${LABEL} exited ${String(printed.status)}`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return { state: "not-loaded" };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Up to ~5 × 200 ms, the launchd twin of the Windows scheduler settle delays. */
|
|
390
|
+
const LAUNCHD_SETTLE_ATTEMPTS = 5;
|
|
391
|
+
|
|
392
|
+
const LAUNCHD_SETTLE_DELAY_MS = 200;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Wait for a `bootout` to finish.
|
|
396
|
+
*
|
|
397
|
+
* `bootout` is asynchronous: it returns before the job has exited, so an immediate
|
|
398
|
+
* re-registration races it and gets "Bootstrap failed: 5: Input/output error" — which is
|
|
399
|
+
* exactly what made the old back-to-back retry useless (#4236, defect 1d). Bounded on
|
|
400
|
+
* purpose: a genuinely wedged domain must reach the diagnosable throw rather than hang.
|
|
401
|
+
*
|
|
402
|
+
* Synchronous because `installLaunchd` is (`ServiceOps.install` / `repairLaunchd` are
|
|
403
|
+
* `() => void`), so this uses `Bun.sleepSync` and exposes the seam for tests.
|
|
404
|
+
*/
|
|
405
|
+
function settleLaunchdEviction(
|
|
406
|
+
run: typeof runLaunchctl,
|
|
407
|
+
target: string,
|
|
408
|
+
sleepSync: (ms: number) => void,
|
|
409
|
+
): void {
|
|
410
|
+
for (let attempt = 0; attempt < LAUNCHD_SETTLE_ATTEMPTS; attempt += 1) {
|
|
411
|
+
if (!run(["print", target]).ok) return;
|
|
412
|
+
sleepSync(LAUNCHD_SETTLE_DELAY_MS);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* What an install or repair actually DID to launchd.
|
|
418
|
+
*
|
|
419
|
+
* `reloaded: false` means the no-op path was taken — the plist on disk was already the
|
|
420
|
+
* rendered one, the data token was unchanged, and the probe answered `loaded-current` — so
|
|
421
|
+
* launchd was never asked for anything and the job is still the same process it was. That
|
|
422
|
+
* is the right answer for `repair` (a repair of a healthy service must not be an outage)
|
|
423
|
+
* and the WRONG one for `restart`, which is the verb an operator reaches for precisely when
|
|
424
|
+
* they want a new process. Only `restart` acts on it; see {@link restartLaunchdJob}.
|
|
425
|
+
*/
|
|
426
|
+
export interface LaunchdInstallOutcome {
|
|
427
|
+
reloaded: boolean;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Deps follow {@link startLaunchd}: `launchctl` replaces the LAYER, returning a
|
|
432
|
+
* {@link runLaunchctl} result, not a spawnSync result. Every one is optional so this stays
|
|
433
|
+
* assignable to `ServiceOps.install` and `RepairServiceDeps.repairLaunchd`
|
|
434
|
+
* (`() => void`), and so `platformOps` wires the same function the tests exercise.
|
|
435
|
+
*
|
|
436
|
+
* The seam is what makes the eviction below testable at all. The live-service-manager
|
|
437
|
+
* guard refuses every mutating verb from an armed test process and `bootout` is not on
|
|
438
|
+
* its read-only list, so a test reaching the real runner would fail closed on the guard
|
|
439
|
+
* instead of exercising the sequence.
|
|
440
|
+
*
|
|
441
|
+
* `probe` is the TRI-STATE {@link probeLaunchdLoadState}, used twice and for opposite
|
|
442
|
+
* reasons: once before touching launchd, to prove a repair has nothing to do, and once
|
|
443
|
+
* after, because stderr cannot prove a load took. It is deliberately not the two-state
|
|
444
|
+
* `launchdJobMatchesPlist`, which reports `loaded: false` for every non-zero
|
|
445
|
+
* `launchctl print` — EPERM from a non-Aqua ssh/cron context, an unspawnable launchctl, an
|
|
446
|
+
* undocumented status. With that one, a healthy serving hub read as "not loaded" in the
|
|
447
|
+
* pre-check (so repair evicted it) and again in the verification (so the rollback evicted
|
|
448
|
+
* it a second time and the error claimed "IS NOT RUNNING" about a job that was up). An
|
|
449
|
+
* `unknown` probe is not evidence, so it refuses to evict instead.
|
|
450
|
+
*
|
|
451
|
+
* Protocol (#4236, defect 1). `ocx service repair` on darwin IS this function, and it
|
|
452
|
+
* evicts the running job — a public proxy, a management ingress and a loopback listener on
|
|
453
|
+
* a hub. So:
|
|
454
|
+
*
|
|
455
|
+
* 1. Ask launchd what it is running BEFORE writing anything. `unknown` throws without
|
|
456
|
+
* touching a file or a job; `loaded-current` plus an identical plist and an unchanged
|
|
457
|
+
* token file means there is nothing to repair, and a repair of a healthy service must
|
|
458
|
+
* never cause an outage.
|
|
459
|
+
* 2. Keep the previous plist bytes (in memory and at `<plist>.prev`) before overwriting.
|
|
460
|
+
* 3. `bootout` BOTH user domains, settle, then `bootstrap gui/$uid <plist>` — the verb
|
|
461
|
+
* that PAIRS with the bootout target. Legacy `load -w` is domain-implicit: it acts on
|
|
462
|
+
* the caller's own bootstrap domain, so from ssh/cron it deleted the gui-domain job and
|
|
463
|
+
* registered nothing (defect 1a).
|
|
464
|
+
* 4. Success is `launchctl print` agreeing, never a stderr regex: measured on macOS 27.0,
|
|
465
|
+
* `load -w` over a bootstrapped job exits 0 with "Load failed: 5" and does nothing,
|
|
466
|
+
* and `bootstrap` exits 5 with "Bootstrap failed: 5" for the same condition.
|
|
467
|
+
* 5. On terminal failure restore the previous plist, try to bootstrap it back, and throw
|
|
468
|
+
* an error that says what the probe actually found — down, or up on a different
|
|
469
|
+
* command — and names the manual remedy.
|
|
470
|
+
*
|
|
471
|
+
* Returns {@link LaunchdInstallOutcome} so the one caller that needs a RESTART rather than a
|
|
472
|
+
* repair can tell the no-op path from a reload. See {@link restartLaunchdJob}.
|
|
473
|
+
*/
|
|
474
|
+
export function installLaunchd(deps: {
|
|
475
|
+
launchctl?: typeof runLaunchctl;
|
|
476
|
+
probe?: typeof probeLaunchdLoadState;
|
|
477
|
+
sleepSync?: (ms: number) => void;
|
|
478
|
+
/**
|
|
479
|
+
* Where to write the plist. Only tests pass it: `os.homedir()` reads the password
|
|
480
|
+
* database rather than `$HOME`, so the suite's HOME sandbox does NOT move
|
|
481
|
+
* `plistPath()`, and a case without this seam rewrites the developer's live
|
|
482
|
+
* `com.opencodex.proxy.plist`. `assertNotRealLaunchAgentsUnderTest` below makes that
|
|
483
|
+
* refusal mechanical rather than a convention.
|
|
484
|
+
*/
|
|
485
|
+
plistPath?: string;
|
|
486
|
+
} = {}): LaunchdInstallOutcome {
|
|
487
|
+
const run = deps.launchctl ?? runLaunchctl;
|
|
488
|
+
const probeLoadState = deps.probe ?? probeLaunchdLoadState;
|
|
489
|
+
const sleepSync = deps.sleepSync ?? ((ms: number) => { Bun.sleepSync(ms); });
|
|
490
|
+
const p = deps.plistPath ?? plistPath();
|
|
491
|
+
const dir = dirname(p);
|
|
492
|
+
assertNotRealLaunchAgentsUnderTest(dir);
|
|
493
|
+
// Capture this BEFORE writing: the write below makes the plist exist unconditionally,
|
|
494
|
+
// so a post-write existsSync would call every fresh install an "installed" service.
|
|
495
|
+
const wasInstalled = existsSync(p);
|
|
496
|
+
// The previous definition, kept for rollback. An eviction whose bootstrap fails used to
|
|
497
|
+
// end with the new plist on disk, nothing in launchd, and nothing listening.
|
|
498
|
+
const previousPlist = wasInstalled ? readTextOrNull(p) : null;
|
|
499
|
+
// Resolve the launcher ONCE and hand the same value to the plist and to install state,
|
|
500
|
+
// so the staleness diagnostic judges exactly what launchd runs.
|
|
501
|
+
const launcher = stableLauncherEntry();
|
|
502
|
+
// The command THIS install bakes, not the one install state remembers: on a fresh
|
|
503
|
+
// install there is no state yet, and after a lost state file `expectedLaunchdCommand`
|
|
504
|
+
// falls back to the Bun + CLI pair and would call a correct launcher job stale (#3464).
|
|
505
|
+
const expectedCommand = launchdServiceCommand(launcher);
|
|
506
|
+
const uid = process.getuid?.() ?? 0;
|
|
507
|
+
const guiDomain = launchdGuiDomain();
|
|
508
|
+
const guiTarget = `${guiDomain}/${LABEL}`;
|
|
509
|
+
const evictionTargets = launchdEvictionTargets(uid);
|
|
510
|
+
const probeLive = (): LaunchdLoadProbe => probeLoadState({ expectedCommand: () => expectedCommand });
|
|
511
|
+
|
|
512
|
+
// Nothing has been written yet, deliberately: a probe that cannot answer must leave the
|
|
513
|
+
// host exactly as it found it.
|
|
514
|
+
let verdict = probeLive();
|
|
515
|
+
if (verdict.state === "unknown") {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`refusing to ${wasInstalled ? "repair" : "install"} ${LABEL}: launchd state could not be verified `
|
|
518
|
+
+ `— ${verdict.detail ?? "launchctl could not be asked"}.\n`
|
|
519
|
+
+ "The job may be RUNNING, and this command evicts it, so nothing was changed.\n"
|
|
520
|
+
+ `Check it with:\n launchctl print ${guiTarget}\n launchctl print user/${uid}/${LABEL}\n`
|
|
521
|
+
+ "A non-Aqua context (ssh, cron, a launchd daemon) cannot always reach gui/<uid>; re-run "
|
|
522
|
+
+ `'${wasInstalled ? "ocx service repair" : "ocx service install"}' from a GUI login session.`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
let rendered = buildPlist(resolvedProxyEnv(), { launcher });
|
|
527
|
+
if (previousPlist !== null && previousPlist !== rendered && verdict.state === "loaded-current") {
|
|
528
|
+
// The live job runs exactly the exec line this install baked, so the definition on disk
|
|
529
|
+
// IS the one launchd is running: keep the PATH it already carries instead of replacing
|
|
530
|
+
// it with the repairing process's. See `reusePreviousPlistPathVariable`.
|
|
531
|
+
const adopted = reusePreviousPlistPathVariable(previousPlist, rendered);
|
|
532
|
+
if (adopted !== null) rendered = adopted;
|
|
533
|
+
}
|
|
534
|
+
// Whether launchd has to be handed NEW BYTES, which is what decides below whether a
|
|
535
|
+
// `kickstart` can be trusted. `previousPlist === null` (a fresh install) counts: whatever
|
|
536
|
+
// the label may hold did not come from a definition we can see.
|
|
537
|
+
const renderedDiffers = previousPlist !== rendered;
|
|
538
|
+
|
|
539
|
+
// ── Writes start here. ──
|
|
540
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
541
|
+
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
542
|
+
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
543
|
+
const tokenFile = serviceApiTokenFilePath();
|
|
544
|
+
const previousToken = readTextOrNull(tokenFile);
|
|
545
|
+
writeServiceApiTokenFile();
|
|
546
|
+
// A rotated data token only reaches the job through a restart, so it is part of "is this
|
|
547
|
+
// repair a no-op?" — the plist `cat`s this file at launch.
|
|
548
|
+
const tokenUnchanged = readTextOrNull(tokenFile) === previousToken;
|
|
549
|
+
|
|
550
|
+
if (!renderedDiffers && tokenUnchanged && verdict.state === "loaded-current") {
|
|
551
|
+
// The plist is not rewritten and launchd is not touched; only the owner-only mode
|
|
552
|
+
// is re-asserted, for a definition an older version may have left at 0644.
|
|
553
|
+
try { chmodSync(p, 0o600); } catch { /* best-effort */ }
|
|
554
|
+
// Install state is refreshed because it is what `expectedLaunchdCommand` reads, and
|
|
555
|
+
// a repair that leaves it stale re-creates the false "OLDER plist" report.
|
|
556
|
+
writeServiceInstallState("scheduler", launcher);
|
|
557
|
+
console.log("ℹ️ service is already loaded from the current plist; nothing to do.");
|
|
558
|
+
// The ONLY `reloaded: false` exit: the process launchd was running when this command
|
|
559
|
+
// started is still running, same pid. `ocx service restart` turns that into a kickstart.
|
|
560
|
+
return { reloaded: false };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (previousPlist !== null) {
|
|
564
|
+
// Best-effort: a backup we could not write must not stop the repair, but it is the
|
|
565
|
+
// only thing that makes the rollback below able to restore bytes rather than guesses.
|
|
566
|
+
//
|
|
567
|
+
// `.plist.prev`, not `.prev.plist`: launchd globs `~/Library/LaunchAgents/*.plist` at
|
|
568
|
+
// login, so a backup ending in `.plist` would be a second registration of the same
|
|
569
|
+
// Label fighting the real one for the port.
|
|
570
|
+
try { writeServiceDefinitionFile(`${p}.prev`, previousPlist, "utf8"); } catch { /* best-effort */ }
|
|
571
|
+
}
|
|
572
|
+
writeServiceDefinitionFile(p, rendered, "utf8");
|
|
573
|
+
|
|
574
|
+
// This EVICTS the running job, and from here until the verification below nothing is
|
|
575
|
+
// listening. `unload` is the legacy verb and does not evict a job bootstrapped into the
|
|
576
|
+
// GUI domain — precisely the state that could not repair itself (#4141).
|
|
577
|
+
//
|
|
578
|
+
// Absence is fine: booting out a job that is not there exits 3 ("No such process"), and
|
|
579
|
+
// a real failure is reported by the verification below with a better message than a raw
|
|
580
|
+
// eviction error would carry.
|
|
581
|
+
const evictEveryDomain = (): void => {
|
|
582
|
+
for (const target of evictionTargets) {
|
|
583
|
+
// Settle only where something was actually evicted. `bootout` is asynchronous, so a
|
|
584
|
+
// job it DID remove needs waiting for; one it never held (exit 3) has nothing to
|
|
585
|
+
// wait on, and probing it would only add a round trip per install.
|
|
586
|
+
if (run(["bootout", target]).status === 0) settleLaunchdEviction(run, target, sleepSync);
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
const evictThenBootstrap = (): { ok: boolean; stdout: string; stderr: string; status: number | null } => {
|
|
590
|
+
evictEveryDomain();
|
|
591
|
+
return run(["bootstrap", guiDomain, p]);
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
let loaded = evictThenBootstrap();
|
|
595
|
+
verdict = probeLive();
|
|
596
|
+
// `unknown` is excluded on purpose: a retry means another eviction, and a probe that
|
|
597
|
+
// could not answer is not a reason to take the job down again.
|
|
598
|
+
if (verdict.state === "not-loaded" || verdict.state === "loaded-stale") {
|
|
599
|
+
// ONE retry. A bounded retry recovers the race; a loop would turn a wedged domain
|
|
600
|
+
// into a hang instead of the diagnosable throw below.
|
|
601
|
+
if (loaded.status === LAUNCHCTL_BOOTSTRAP_BUSY || launchctlLoadFailed(loaded.stderr)) {
|
|
602
|
+
// "Bootstrap failed: 5: Input/output error" has TWO causes, measured on macOS 27.0
|
|
603
|
+
// with a throwaway label, and they need opposite remedies:
|
|
604
|
+
//
|
|
605
|
+
// - something is still bootstrapped under our label (the job re-registered, or it
|
|
606
|
+
// had not finished exiting). `kickstart -k` restarts what the domain holds without
|
|
607
|
+
// opening a second eviction window — but it restarts launchd's CACHED definition
|
|
608
|
+
// and does NOT re-read the plist, so it can only settle this when the rendered
|
|
609
|
+
// bytes are the ones already on disk. With new bytes it would restart the OLD
|
|
610
|
+
// definition, and since the exec line is unchanged whenever only
|
|
611
|
+
// `EnvironmentVariables` moved, the verification below would agree and install
|
|
612
|
+
// state would be written while launchd kept the stale environment. So: new bytes
|
|
613
|
+
// skip kickstart and go to the eviction, which is the only way to publish them.
|
|
614
|
+
// - the label is in the domain's DISABLED list, so `bootstrap` refuses it while
|
|
615
|
+
// `launchctl print` reports 113. This is the one thing the legacy `load -w` did
|
|
616
|
+
// that plain `bootstrap` does not: `-w` cleared that flag. `enable` is the modern
|
|
617
|
+
// spelling of it, and it is idempotent on a job that was never disabled — but it
|
|
618
|
+
// runs only here, so an ordinary repair does not quietly undo a deliberate
|
|
619
|
+
// `launchctl disable`.
|
|
620
|
+
const kicked = renderedDiffers ? null : run(["kickstart", "-k", guiTarget]);
|
|
621
|
+
if (kicked?.ok) verdict = probeLive();
|
|
622
|
+
if (verdict.state !== "loaded-current") {
|
|
623
|
+
run(["enable", guiTarget]);
|
|
624
|
+
loaded = evictThenBootstrap();
|
|
625
|
+
verdict = probeLive();
|
|
626
|
+
}
|
|
627
|
+
} else if (loaded.ok) {
|
|
628
|
+
// Exit 0 while `print` disagrees: the load silently no-op'd. That IS worth evicting
|
|
629
|
+
// again. A load that failed for any OTHER reason — a malformed plist, EPERM — is not
|
|
630
|
+
// fixed by evicting a job, so it falls straight through to the throw and the
|
|
631
|
+
// operator sees the real stderr instead of a delayed copy of it.
|
|
632
|
+
loaded = evictThenBootstrap();
|
|
633
|
+
verdict = probeLive();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (verdict.state === "unknown") {
|
|
638
|
+
// The probe stopped answering between the pre-check and here. Do NOT evict again, do
|
|
639
|
+
// NOT roll back (a rollback is another eviction) and do NOT claim the job is down: the
|
|
640
|
+
// bytes we asked launchd to load are the ones on disk either way.
|
|
641
|
+
if (!loaded.ok) {
|
|
642
|
+
throw new Error(
|
|
643
|
+
`launchctl could not bootstrap ${p}: ${loaded.stderr || "bootstrap reported failure"}\n`
|
|
644
|
+
+ `and the state of ${LABEL} could not be verified afterwards — ${verdict.detail ?? "launchctl could not be asked"}.\n`
|
|
645
|
+
+ "The job was NOT evicted again and the plist was left in place, so this says nothing about "
|
|
646
|
+
+ "whether it is running.\n"
|
|
647
|
+
+ `Check it with:\n launchctl print ${guiTarget}\n launchctl print user/${uid}/${LABEL}\n`
|
|
648
|
+
+ `and load it if it is absent:\n launchctl bootstrap ${guiDomain} ${p}`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
console.warn(
|
|
652
|
+
`⚠️ launchctl accepted the bootstrap but the job state could not be verified — ${
|
|
653
|
+
verdict.detail ?? "launchctl could not be asked"}. Check: launchctl print ${guiTarget}`,
|
|
654
|
+
);
|
|
655
|
+
writeServiceInstallState("scheduler", launcher);
|
|
656
|
+
return { reloaded: true };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (verdict.state !== "loaded-current") {
|
|
660
|
+
// Do NOT write install state for a load that did not take: state describing an unused
|
|
661
|
+
// plist is what made this failure invisible.
|
|
662
|
+
let rolledBack: "restored" | "on-disk-only" | "none" = "none";
|
|
663
|
+
if (previousPlist !== null) {
|
|
664
|
+
try {
|
|
665
|
+
writeServiceDefinitionFile(p, previousPlist, "utf8");
|
|
666
|
+
evictEveryDomain();
|
|
667
|
+
run(["bootstrap", guiDomain, p]);
|
|
668
|
+
rolledBack = run(["print", guiTarget]).ok ? "restored" : "on-disk-only";
|
|
669
|
+
} catch {
|
|
670
|
+
rolledBack = "on-disk-only";
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// What the probe actually found, rather than one sentence for both outcomes: a
|
|
674
|
+
// `loaded-stale` job IS running, and telling its operator "nothing is listening" sends
|
|
675
|
+
// them to fix the wrong thing.
|
|
676
|
+
const state = verdict.state === "loaded-stale"
|
|
677
|
+
? `is still loaded in ${verdict.domain ?? guiDomain} from a DIFFERENT command than the plist just written`
|
|
678
|
+
: rolledBack === "restored"
|
|
679
|
+
? `was evicted from ${guiDomain}; the PREVIOUS plist was restored and re-bootstrapped`
|
|
680
|
+
: `was evicted from ${guiDomain} and IS NOT RUNNING — nothing is listening`;
|
|
681
|
+
throw new Error(
|
|
682
|
+
`launchctl could not bootstrap ${p}: ${loaded.stderr || "bootstrap reported failure"}\n`
|
|
683
|
+
+ `The ${LABEL} job ${state}.\n`
|
|
684
|
+
+ (rolledBack === "on-disk-only"
|
|
685
|
+
? "The previous plist was restored on disk but could not be bootstrapped either.\n"
|
|
686
|
+
: "")
|
|
687
|
+
+ `Recover manually with:\n launchctl bootstrap ${guiDomain} ${p}\n`
|
|
688
|
+
+ `Inspect it with:\n launchctl print ${guiTarget}\n launchctl print-disabled ${guiDomain}\n`
|
|
689
|
+
// macOS `service repair` delegates straight to installLaunchd, so this fires for
|
|
690
|
+
// an already-installed service too; repair reloads it without re-registering.
|
|
691
|
+
+ `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
writeServiceInstallState("scheduler", launcher);
|
|
695
|
+
// The rollback copy has done its job: the new definition is verified loaded. Leaving it
|
|
696
|
+
// behind makes the NEXT repair's backup ambiguous (which failure did it come from?) and
|
|
697
|
+
// `uninstall` the only thing that ever cleaned it up.
|
|
698
|
+
if (existsSync(`${p}.prev`)) { try { unlinkSync(`${p}.prev`); } catch { /* best-effort */ } }
|
|
699
|
+
return { reloaded: true };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Restart the loaded job IN PLACE — the `restart` half of `ocx service restart`.
|
|
704
|
+
*
|
|
705
|
+
* Only reached when {@link installLaunchd} reported `reloaded: false`, i.e. the plist is
|
|
706
|
+
* already the current one and the probe proved the job is loaded from it. Nothing has to be
|
|
707
|
+
* published, so this must NOT evict: `kickstart -k` restarts what the domain already holds
|
|
708
|
+
* without opening an eviction window, which is the whole reason `restart` can be honest
|
|
709
|
+
* about a healthy service while `repair` stays a no-op on it. `kickstart` restarts the
|
|
710
|
+
* definition launchd has CACHED and does not re-read the plist — harmless here, and exactly
|
|
711
|
+
* why the retry path inside `installLaunchd` may only use it for bytes already on disk.
|
|
712
|
+
*
|
|
713
|
+
* `launchctl print` answers about REGISTRATION, not liveness, so the verification asks the
|
|
714
|
+
* same tri-state probe `installLaunchd` does: `loaded-current` is the restart confirmed,
|
|
715
|
+
* `unknown` is not evidence of anything and only warns, and absence after a kick means the
|
|
716
|
+
* job went away and KeepAlive did not bring it back — which throws, so the repair branch
|
|
717
|
+
* reports it and still runs its serving check.
|
|
718
|
+
*
|
|
719
|
+
* Both deps are test seams, and the default runner is also refused by
|
|
720
|
+
* `assertLiveServiceManagerAllowed`: `kickstart` is not a read-only verb, so an armed test
|
|
721
|
+
* process that reached the real runner would fail closed rather than bounce the developer's
|
|
722
|
+
* own hub.
|
|
723
|
+
*/
|
|
724
|
+
export function restartLaunchdJob(deps: {
|
|
725
|
+
launchctl?: typeof runLaunchctl;
|
|
726
|
+
probe?: typeof probeLaunchdLoadState;
|
|
727
|
+
/** The exec line the live job must carry; defaults to the one an install would bake. */
|
|
728
|
+
expectedCommand?: () => string;
|
|
729
|
+
} = {}): void {
|
|
730
|
+
const run = deps.launchctl ?? runLaunchctl;
|
|
731
|
+
const target = `${launchdGuiDomain()}/${LABEL}`;
|
|
732
|
+
const expectedCommand = deps.expectedCommand
|
|
733
|
+
?? (() => launchdServiceCommand(stableLauncherEntry()));
|
|
734
|
+
const kicked = run(["kickstart", "-k", target]);
|
|
735
|
+
const verdict = (deps.probe ?? probeLaunchdLoadState)({ expectedCommand });
|
|
736
|
+
if (!kicked.ok || verdict.state === "not-loaded" || verdict.state === "loaded-stale") {
|
|
737
|
+
// Three different things to say, because they send the operator to three different
|
|
738
|
+
// places: the job is gone, the job is up on an older definition, or the job is up and
|
|
739
|
+
// `kickstart` refused — in which case the proxy is fine and only the restart failed.
|
|
740
|
+
const state = verdict.state === "not-loaded"
|
|
741
|
+
? `is NOT loaded in ${launchdGuiDomain()} — nothing is listening`
|
|
742
|
+
: verdict.state === "loaded-stale"
|
|
743
|
+
? "is loaded from a DIFFERENT command than the plist on disk"
|
|
744
|
+
: "is still loaded, so it may be serving the process this restart failed to replace";
|
|
745
|
+
throw new Error(
|
|
746
|
+
`launchctl could not restart ${LABEL}: ${kicked.stderr || "kickstart reported failure"}\n`
|
|
747
|
+
+ `The ${LABEL} job ${state}.\n`
|
|
748
|
+
+ `Restart it manually with:\n launchctl kickstart -k ${target}\n`
|
|
749
|
+
+ `Inspect it with:\n launchctl print ${target}\n`
|
|
750
|
+
+ "and run 'ocx service repair' if it is absent.",
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
if (verdict.state === "unknown") {
|
|
754
|
+
console.warn(
|
|
755
|
+
`⚠️ launchctl accepted the restart but the job state could not be verified — ${
|
|
756
|
+
verdict.detail ?? "launchctl could not be asked"}. Check: launchctl print ${target}`,
|
|
757
|
+
);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
console.log(`ℹ️ service restarted (launchctl kickstart -k ${target}).`);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Deps are named for the layer they replace, not for the process API: `launchctl`
|
|
765
|
+
* returns a {@link runLaunchctl} result and `matches` a {@link launchdJobMatchesPlist}
|
|
766
|
+
* result. Only `runLaunchctl` itself takes a spawnSync mock.
|
|
767
|
+
*
|
|
768
|
+
* Exported for the branch tests. Every parameter is optional, so this stays
|
|
769
|
+
* assignable to `ServiceOps.start` (`() => void`) and `platformOps` wires the same
|
|
770
|
+
* function the tests exercise.
|
|
771
|
+
*/
|
|
772
|
+
export function startLaunchd(deps: {
|
|
773
|
+
launchctl?: typeof runLaunchctl;
|
|
774
|
+
matches?: typeof launchdJobMatchesPlist;
|
|
775
|
+
} = {}): void {
|
|
776
|
+
const run = deps.launchctl ?? runLaunchctl;
|
|
777
|
+
const p = plistPath();
|
|
778
|
+
const loaded = run(["load", "-w", p]);
|
|
779
|
+
if (loaded.ok && !launchctlLoadFailed(loaded.stderr)) return;
|
|
780
|
+
// `Load failed` on start is AMBIGUOUS in a way it is not on install: the job may
|
|
781
|
+
// already be bootstrapped from THIS plist, which is a no-op rather than an error.
|
|
782
|
+
// `install` can assume a stale job (it just rewrote the plist); `start` cannot, and
|
|
783
|
+
// throwing here would break `ocx service start` on every healthy service.
|
|
784
|
+
const live = (deps.matches ?? launchdJobMatchesPlist)(
|
|
785
|
+
expectedLaunchdCommand(installedServiceListenPort()),
|
|
786
|
+
);
|
|
787
|
+
if (live.loaded && live.matchesPlist) {
|
|
788
|
+
console.log("ℹ️ service was already loaded from the current plist; nothing to do.");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
throw new Error(
|
|
792
|
+
`launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
|
|
793
|
+
+ (live.loaded
|
|
794
|
+
? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service repair`
|
|
795
|
+
: "The job is not loaded. Run 'ocx service repair' to reload it."),
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Evict the job with the modern, domain-explicit verb, in EVERY domain that can hold it;
|
|
801
|
+
* fall back to legacy `unload` only when `bootout` could not be run at all.
|
|
802
|
+
*
|
|
803
|
+
* `unload` cannot evict a job bootstrapped into the GUI domain (the same reason
|
|
804
|
+
* `installLaunchd` stopped using it), so a stop built on it reported success while the
|
|
805
|
+
* proxy kept serving. Exit 3 ("Boot-out failed: 3: No such process") is the not-loaded
|
|
806
|
+
* case and is not a failure here.
|
|
807
|
+
*
|
|
808
|
+
* `gui/<uid>` alone was the remaining half of that bug: `probeLaunchdLoadState` reports a
|
|
809
|
+
* `user/<uid>` job too, and against one of those this function exited 3 in the wrong domain
|
|
810
|
+
* and returned as if it had stopped something. See {@link launchdEvictionTargets}.
|
|
811
|
+
*/
|
|
812
|
+
export function stopLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
|
|
813
|
+
const run = deps.launchctl ?? runLaunchctl;
|
|
814
|
+
let spawnable = true;
|
|
815
|
+
try {
|
|
816
|
+
for (const target of launchdEvictionTargets()) {
|
|
817
|
+
// Any real exit status is final — including 3, which only means the job was not
|
|
818
|
+
// loaded THERE. `status: null` is "launchctl could not be spawned at all", and only
|
|
819
|
+
// then is the legacy verb worth one attempt.
|
|
820
|
+
if (run(["bootout", target]).status === null) spawnable = false;
|
|
821
|
+
}
|
|
822
|
+
} catch {
|
|
823
|
+
// The armed-test guard refuses mutating verbs; retrying through `sh` would only hit it
|
|
824
|
+
// again.
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (spawnable) return;
|
|
828
|
+
try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Registration for `ocx service stop`'s "is anything installed?" guard, as a human string.
|
|
833
|
+
* Empty means "no job of ours is loaded"; the tri-state lives in
|
|
834
|
+
* {@link probeLaunchdLoadState}, which `diagnoseService` uses instead of this.
|
|
835
|
+
*/
|
|
836
|
+
export function statusLaunchd(deps: { probe?: typeof probeLaunchdLoadState } = {}): string {
|
|
837
|
+
const probe = (deps.probe ?? probeLaunchdLoadState)();
|
|
838
|
+
if (probe.state === "loaded-current") return `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()}`;
|
|
839
|
+
if (probe.state === "loaded-stale") return `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()} from an OLDER plist`;
|
|
840
|
+
if (probe.state === "unknown") return `${LABEL} state unknown: ${probe.detail ?? "launchctl could not be asked"}`;
|
|
841
|
+
return "";
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export function uninstallLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
|
|
845
|
+
const p = plistPath();
|
|
846
|
+
// Same reason as `installLaunchd`: HOME isolation does not move this path, so without
|
|
847
|
+
// the guard an armed test process deletes the developer's live plist.
|
|
848
|
+
assertNotRealLaunchAgentsUnderTest(dirname(p));
|
|
849
|
+
stopLaunchd(deps);
|
|
850
|
+
if (existsSync(p)) unlinkSync(p);
|
|
851
|
+
// The rollback copy is part of the installation, not a user file.
|
|
852
|
+
if (existsSync(`${p}.prev`)) { try { unlinkSync(`${p}.prev`); } catch { /* best-effort */ } }
|
|
853
|
+
}
|