@bitkyc08/opencodex 2.52.0-preview.20260912 → 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-D_t6sCWs.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,363 @@
|
|
|
1
|
+
import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
|
|
4
|
+
import { expandUserPath, getConfigDir } from "../config";
|
|
5
|
+
import { resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home";
|
|
6
|
+
import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime";
|
|
7
|
+
import { WINSW_SHA256, WINSW_VERSION } from "../lib/winsw";
|
|
8
|
+
import { hardenSecretPath } from "../lib/windows-secret-acl";
|
|
9
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
10
|
+
import { isProtectedHomeUnderTest, isTestHomeGuardArmed } from "../lib/test-home-guard";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in
|
|
14
|
+
* for it: `ocx claude` and `ocx opencode` set that on the proxies they spawn to borrow its
|
|
15
|
+
* routing-preservation meaning, so a proxy carrying it is not necessarily the managed job.
|
|
16
|
+
*/
|
|
17
|
+
export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED";
|
|
18
|
+
|
|
19
|
+
export const LABEL = "com.opencodex.proxy";
|
|
20
|
+
export const TASK = "opencodex-proxy";
|
|
21
|
+
|
|
22
|
+
// This module lives one level below the original src/service.ts, so path-relative
|
|
23
|
+
// lookups anchored at that file's directory go through this constant instead.
|
|
24
|
+
export const serviceSourceDir = dirname(import.meta.dir);
|
|
25
|
+
|
|
26
|
+
export type ServiceBackend = "scheduler" | "native";
|
|
27
|
+
|
|
28
|
+
export function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
|
|
29
|
+
// Bake the bundled Bun (manager-owned global package directory, survives `ocx update`) rather than
|
|
30
|
+
// a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
|
|
31
|
+
// standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
|
|
32
|
+
//
|
|
33
|
+
// Path and provenance come from ONE resolution so the marker can never describe a
|
|
34
|
+
// different binary than the one actually baked.
|
|
35
|
+
return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(serviceSourceDir, "cli", "index.ts") };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The stable `ocx` launcher to bake into a systemd unit, or null to fall back to the
|
|
40
|
+
* Bun + CLI pair.
|
|
41
|
+
*
|
|
42
|
+
* `cliEntry()` resolves both of its paths from `import.meta.dir`, so they point INSIDE
|
|
43
|
+
* the installed package tree. Under a version manager that tree is a versioned directory:
|
|
44
|
+
* `~/.local/share/mise/installs/npm-opencodex/2.35.0/...`. An upgrade installs 2.36.0 and
|
|
45
|
+
* deletes 2.35.0, after which the unit's `exec <old-bun> <old-cli>` cannot resolve, and
|
|
46
|
+
* `Restart=on-failure` turns that into a restart loop (#2898). The shim in
|
|
47
|
+
* `~/.local/share/mise/shims/ocx` survives the upgrade and dispatches to whatever version
|
|
48
|
+
* is current, so it is the durable thing to name.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately LEXICAL. Resolving the symlink would write the versioned target back into
|
|
51
|
+
* the unit and reintroduce the bug — the indirection is the entire point.
|
|
52
|
+
*
|
|
53
|
+
* Only an absolute path is accepted. A bare `ocx` would be re-resolved through `PATH` on
|
|
54
|
+
* every restart, which turns a service definition into a PATH-hijacking surface; naming
|
|
55
|
+
* one validated absolute file keeps the target fixed at install time.
|
|
56
|
+
*
|
|
57
|
+
* The RECORDED launcher wins over a fresh PATH walk. `ocx service repair` runs from
|
|
58
|
+
* whatever shell the operator (or `ocx update`, or a tray helper) happened to have, and a
|
|
59
|
+
* context without `ocx` on `PATH` used to resolve null here — rewriting a working
|
|
60
|
+
* launcher-form plist into the version-pinned Bun + CLI pair and then booting the healthy
|
|
61
|
+
* job out to load it (#4236, defect 1g). A launcher that is still an executable file is
|
|
62
|
+
* the thing the installed service already runs, so repair must keep naming it; only a
|
|
63
|
+
* recorded launcher that has disappeared falls through to discovery.
|
|
64
|
+
*
|
|
65
|
+
* That preference is NOT macOS-only: `installSystemd` resolves this same function, so a
|
|
66
|
+
* Linux `ocx service repair` from a PATH-less context keeps the `ExecStart` the unit
|
|
67
|
+
* already has instead of rewriting it to the version-pinned pair — the #2898 shape this
|
|
68
|
+
* function exists to avoid. The failure mode it prevents is milder there (systemd
|
|
69
|
+
* `daemon-reload` + `restart` does not evict-then-maybe-nothing the way launchd did), but
|
|
70
|
+
* the rewrite was the same, so the behavior is deliberately shared rather than branched.
|
|
71
|
+
*/
|
|
72
|
+
export function stableLauncherEntry(deps: {
|
|
73
|
+
env?: NodeJS.ProcessEnv;
|
|
74
|
+
isExecutableFile?: (path: string) => boolean;
|
|
75
|
+
pathDelimiter?: string;
|
|
76
|
+
state?: ServiceInstallState | null;
|
|
77
|
+
} = {}): string | null {
|
|
78
|
+
const env = deps.env ?? process.env;
|
|
79
|
+
const isExecutableFile = deps.isExecutableFile ?? ((path: string): boolean => {
|
|
80
|
+
try {
|
|
81
|
+
if (!statSync(path).isFile()) return false;
|
|
82
|
+
accessSync(path, fsConstants.X_OK);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const recorded = (deps.state === undefined ? readServiceInstallState() : deps.state)?.launcherPath;
|
|
89
|
+
if (recorded && isAbsolute(recorded) && isExecutableFile(recorded)) return recorded;
|
|
90
|
+
const entries = (env.PATH ?? "").split(deps.pathDelimiter ?? delimiter);
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (!entry || !isAbsolute(entry)) continue;
|
|
93
|
+
const candidate = join(entry, "ocx");
|
|
94
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function plistPath(): string {
|
|
100
|
+
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function logPath(): string {
|
|
104
|
+
return join(getConfigDir(), "service.log");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function serviceLogPath(): string {
|
|
108
|
+
return logPath();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function windowsServiceScriptPath(): string {
|
|
112
|
+
return join(getConfigDir(), "opencodex-service.cmd");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function windowsLauncherVbsPath(): string {
|
|
116
|
+
return join(getConfigDir(), "opencodex-service-launcher.vbs");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function windowsTaskXmlPath(): string {
|
|
120
|
+
return join(getConfigDir(), "opencodex-service-task.xml");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function serviceStatePath(): string {
|
|
124
|
+
return join(getConfigDir(), "service-state.json");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function defaultOpenCodexHome(): string {
|
|
128
|
+
return resolve(join(homedir(), ".opencodex"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] {
|
|
132
|
+
const paths = [join(opencodexHome, "service-state.json")];
|
|
133
|
+
const defaultPath = join(defaultOpenCodexHome(), "service-state.json");
|
|
134
|
+
if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath);
|
|
135
|
+
return paths;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function serviceStatePaths(): string[] {
|
|
139
|
+
const paths = serviceStatePathsForOpenCodexHome(currentOpenCodexHome());
|
|
140
|
+
if (!isTestHomeGuardArmed()) return paths;
|
|
141
|
+
/*
|
|
142
|
+
* Under an armed test process the legacy default-home entry IS the developer's real
|
|
143
|
+
* `~/.opencodex/service-state.json`. It is there so an install made before
|
|
144
|
+
* OPENCODEX_HOME was set can still be found, but it means a test whose OPENCODEX_HOME
|
|
145
|
+
* points at a sandbox still writes their live install state — observed while building
|
|
146
|
+
* the launchd repair coverage: one case replaced the real record's codexHome and
|
|
147
|
+
* opencodexHome with temp-directory paths. Drop it rather than deny the write, so the
|
|
148
|
+
* sandbox path keeps working and the real one is simply not in the list.
|
|
149
|
+
*
|
|
150
|
+
* The predicate is the guard's own, not a local `resolve()` compare: the guard
|
|
151
|
+
* canonicalizes through `realpath`, and on macOS a sandbox under `/var/folders/...`
|
|
152
|
+
* resolves to `/private/var/folders/...`, so two spellings of one directory must not
|
|
153
|
+
* decide this.
|
|
154
|
+
*/
|
|
155
|
+
return paths.filter(path => !isProtectedHomeUnderTest(dirname(path)));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The state paths a WRITE may use. Same list, but an empty one is an error instead of a
|
|
160
|
+
* silent no-op.
|
|
161
|
+
*
|
|
162
|
+
* With OPENCODEX_HOME unset under an armed test process, `currentOpenCodexHome()` falls
|
|
163
|
+
* back to the real `~/.opencodex` (`os.homedir()` ignores `$HOME`), the filter above then
|
|
164
|
+
* removes every candidate, and `writeServiceInstallState` wrote NOTHING while reporting
|
|
165
|
+
* success — a test asserting on install state would read the previous run's record, or
|
|
166
|
+
* none. Fail the way `assertNotRealHomeUnderTest` does, naming the fix.
|
|
167
|
+
*/
|
|
168
|
+
function serviceStateWritePaths(): string[] {
|
|
169
|
+
const paths = serviceStatePaths();
|
|
170
|
+
if (paths.length > 0) return paths;
|
|
171
|
+
throw new Error(
|
|
172
|
+
"refusing to write service install state with no writable state path: every candidate "
|
|
173
|
+
+ "resolved to the real OpenCodex home and was filtered out. Point OPENCODEX_HOME at a "
|
|
174
|
+
+ "temp directory for this test (the preload does it for every invocation; something "
|
|
175
|
+
+ "deleted the variable without restoring it).",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function currentCodexHome(deps: CodexHomeDeps = {}): string {
|
|
180
|
+
// Service ownership must identify the same home as the runtime. In WSL an
|
|
181
|
+
// unset CODEX_HOME can resolve to the single Windows Desktop home rather than
|
|
182
|
+
// Linux ~/.codex; recording the fallback here creates a false foreign owner.
|
|
183
|
+
return resolveCodexHomeDir(deps);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function currentCodexSqliteHomeAbsolute(target: "native" | "windows" = "native"): string | undefined {
|
|
187
|
+
const raw = process.env.CODEX_SQLITE_HOME?.trim();
|
|
188
|
+
if (!raw) return undefined;
|
|
189
|
+
const expanded = expandUserPath(raw);
|
|
190
|
+
// Service artifacts can be rendered by cross-platform tests and repair tooling, so an
|
|
191
|
+
// already-absolute path for the TARGET platform is preserved rather than re-anchored
|
|
192
|
+
// against the writing host. `resolve()` is host-relative in both directions: on a POSIX
|
|
193
|
+
// host it turns `C:\data` into `<cwd>/C:\data`, and on a Windows host it turns `/tmp/x`
|
|
194
|
+
// into `D:\tmp\x` — neither is a path the target can use. A relative value still resolves,
|
|
195
|
+
// because a service unit has no meaningful working directory.
|
|
196
|
+
//
|
|
197
|
+
// CODEX_HOME and OPENCODEX_HOME are carried through literally, so without this the same
|
|
198
|
+
// generated file disagreed with itself about two variables holding the same kind of value.
|
|
199
|
+
if (target === "windows") {
|
|
200
|
+
return win32.isAbsolute(expanded) ? win32.normalize(expanded) : resolve(expanded);
|
|
201
|
+
}
|
|
202
|
+
return posix.isAbsolute(expanded) ? posix.normalize(expanded) : resolve(expanded);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function currentOpenCodexHome(): string {
|
|
206
|
+
// getConfigDir() already resolves OPENCODEX_HOME with ~ expansion; keep the
|
|
207
|
+
// install-state comparison on the same normalization or `~/...` values falsely
|
|
208
|
+
// fail the environment-match check depending on cwd.
|
|
209
|
+
return getConfigDir();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function normalizePathForCompare(path: string): string {
|
|
213
|
+
const resolved = resolve(path);
|
|
214
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface ServiceInstallState {
|
|
218
|
+
version: 1 | 2;
|
|
219
|
+
codexHome: string;
|
|
220
|
+
opencodexHome: string;
|
|
221
|
+
/** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */
|
|
222
|
+
bunPath?: string;
|
|
223
|
+
cliPath?: string;
|
|
224
|
+
/**
|
|
225
|
+
* launchd and systemd. The stable `ocx` launcher the service definition actually invokes,
|
|
226
|
+
* when one was found. Present means `bunPath`/`cliPath` are provenance for the install,
|
|
227
|
+
* NOT what the service runs — so staleness must be judged against THIS path instead. A version-manager
|
|
228
|
+
* upgrade replaces the directory those two point into while the launcher survives, and
|
|
229
|
+
* checking the old pair would report a stale service that is in fact healthy.
|
|
230
|
+
*/
|
|
231
|
+
launcherPath?: string;
|
|
232
|
+
/** v2: which Windows backend was chosen at install; absent (v1/legacy) means scheduler. */
|
|
233
|
+
backend?: ServiceBackend;
|
|
234
|
+
winswVersion?: string;
|
|
235
|
+
winswSha256?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function parseServiceInstallState(value: unknown): ServiceInstallState | null {
|
|
239
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
240
|
+
const state = value as Record<string, unknown>;
|
|
241
|
+
if (state.version !== 1 && state.version !== 2) return null;
|
|
242
|
+
if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null;
|
|
243
|
+
if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null;
|
|
244
|
+
for (const key of ["bunPath", "cliPath", "launcherPath", "winswVersion", "winswSha256"] as const) {
|
|
245
|
+
if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null;
|
|
246
|
+
}
|
|
247
|
+
if (state.version === 1) {
|
|
248
|
+
if (state.backend !== undefined) return null;
|
|
249
|
+
} else if (state.backend !== "scheduler" && state.backend !== "native") {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return state as unknown as ServiceInstallState;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void {
|
|
256
|
+
const { bun, cli } = cliEntry();
|
|
257
|
+
const state: ServiceInstallState = {
|
|
258
|
+
version: 2,
|
|
259
|
+
codexHome: currentCodexHome(),
|
|
260
|
+
opencodexHome: currentOpenCodexHome(),
|
|
261
|
+
bunPath: bun,
|
|
262
|
+
cliPath: cli,
|
|
263
|
+
...(launcherPath ? { launcherPath } : {}),
|
|
264
|
+
backend,
|
|
265
|
+
...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}),
|
|
266
|
+
};
|
|
267
|
+
for (const path of serviceStateWritePaths()) {
|
|
268
|
+
const dir = dirname(path);
|
|
269
|
+
recordOwnedConfigPath(getConfigDir(), path);
|
|
270
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
271
|
+
writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
272
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
273
|
+
if (process.platform === "win32") hardenSecretPath(path, { required: true });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function readServiceInstallState(): ServiceInstallState | null {
|
|
278
|
+
for (const path of serviceStatePaths()) {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = parseServiceInstallState(JSON.parse(readFileSync(path, "utf8")));
|
|
281
|
+
if (parsed) return parsed;
|
|
282
|
+
} catch {
|
|
283
|
+
/* try the next known state path */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** What ONE state path said. Absent, unreadable and invalid are different answers. */
|
|
290
|
+
export type ServiceStateEvidence =
|
|
291
|
+
| { readonly path: string; readonly kind: "absent" }
|
|
292
|
+
| { readonly path: string; readonly kind: "unreadable"; readonly reason: string }
|
|
293
|
+
| { readonly path: string; readonly kind: "invalid" }
|
|
294
|
+
| { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallState };
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Every state path, with what each one said.
|
|
298
|
+
*
|
|
299
|
+
* `readServiceInstallState` returns the FIRST path that parsed and discards the
|
|
300
|
+
* rest, so a valid mirror beside a corrupt one reads as clean. That is the right
|
|
301
|
+
* behavior for callers that just need the install state; it is the wrong input
|
|
302
|
+
* for deciding ownership, where a disagreement between mirrors is exactly the
|
|
303
|
+
* evidence that matters.
|
|
304
|
+
*/
|
|
305
|
+
export function inspectServiceStateEvidence(
|
|
306
|
+
paths: readonly string[] = serviceStatePaths(),
|
|
307
|
+
): readonly ServiceStateEvidence[] {
|
|
308
|
+
return paths.map((path): ServiceStateEvidence => {
|
|
309
|
+
let raw: string;
|
|
310
|
+
try {
|
|
311
|
+
raw = readFileSync(path, "utf8");
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const code = error && typeof error === "object" && "code" in error
|
|
314
|
+
? String((error as { code?: unknown }).code)
|
|
315
|
+
: "";
|
|
316
|
+
// ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask,
|
|
317
|
+
// and collapsing them into absence is how a locked-down state file would
|
|
318
|
+
// become permission to write.
|
|
319
|
+
if (code === "ENOENT") return { path, kind: "absent" };
|
|
320
|
+
return { path, kind: "unreadable", reason: code || String(error) };
|
|
321
|
+
}
|
|
322
|
+
let parsed: ServiceInstallState | null;
|
|
323
|
+
try {
|
|
324
|
+
parsed = parseServiceInstallState(JSON.parse(raw));
|
|
325
|
+
} catch {
|
|
326
|
+
return { path, kind: "invalid" };
|
|
327
|
+
}
|
|
328
|
+
return parsed ? { path, kind: "valid", state: parsed } : { path, kind: "invalid" };
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The homes this process is actually using, for comparison against a claim. */
|
|
333
|
+
export function currentServiceHomes(deps: CodexHomeDeps = {}): { codexHome: string; opencodexHome: string } {
|
|
334
|
+
return { codexHome: currentCodexHome(deps), opencodexHome: currentOpenCodexHome() };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function serviceHomeMatches(a: string, b: string): boolean {
|
|
338
|
+
return normalizePathForCompare(a) === normalizePathForCompare(b);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Single accessor for backend-sensitive service code — v1/legacy state maps to scheduler. */
|
|
342
|
+
export function readServiceBackend(): ServiceBackend {
|
|
343
|
+
return readServiceInstallState()?.backend === "native" ? "native" : "scheduler";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The `ocx` argv that refreshes an already-installed service after an update.
|
|
348
|
+
*
|
|
349
|
+
* `repair` discovers the installed backend itself. A healthy Windows scheduler task only
|
|
350
|
+
* gets refreshed assets plus a restart; a stale live definition is re-registered and may
|
|
351
|
+
* require elevation. `install` always reaches `/create`, so using repair here avoids an
|
|
352
|
+
* unnecessary admin prompt for the common healthy update path.
|
|
353
|
+
*
|
|
354
|
+
* The historical export name is kept for callers outside this module.
|
|
355
|
+
*/
|
|
356
|
+
export function serviceReinstallArgs(): string[] {
|
|
357
|
+
return ["service", "repair"];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The `ocx` argv that registers a service from scratch, preserving the chosen backend. */
|
|
361
|
+
export function serviceInstallArgs(): string[] {
|
|
362
|
+
return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
|
|
363
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getConfigDir } from "../config";
|
|
7
|
+
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime";
|
|
8
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
9
|
+
import { systemdProperty } from "../service-manager-probe";
|
|
10
|
+
import { writeServiceApiTokenFile, sh } from "./guards";
|
|
11
|
+
import { shellQuote, buildServiceShellCommand, buildServiceLauncherShellCommand, resolvedProxyEnv } from "./health";
|
|
12
|
+
import type { ServiceInstallCleanupOps } from "./orchestration";
|
|
13
|
+
import { SERVICE_MANAGED_ENV, TASK, cliEntry, stableLauncherEntry, logPath, serviceStatePath, currentCodexSqliteHomeAbsolute, writeServiceInstallState } from "./state";
|
|
14
|
+
import { writeServiceDefinitionFile } from "./windows-ops";
|
|
15
|
+
|
|
16
|
+
/** The `--port <n>` baked into the installed systemd user unit. Linux only. */
|
|
17
|
+
export function systemdListenPort(deps: { readUnit?: () => string } = {}): number | null {
|
|
18
|
+
try {
|
|
19
|
+
const text = (deps.readUnit ?? (() => readFileSync(unitPath(), "utf8")))();
|
|
20
|
+
const last = [...text.matchAll(/start --port (\d{1,5})(?:\s|"|$)/gm)].at(-1);
|
|
21
|
+
if (!last) return null;
|
|
22
|
+
const n = Number(last[1]);
|
|
23
|
+
return n > 0 && n <= 65535 ? n : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function systemdQuote(value: string): string {
|
|
30
|
+
return `"${value
|
|
31
|
+
.replace(/\\/g, "\\\\")
|
|
32
|
+
.replace(/"/g, "\\\"")
|
|
33
|
+
.replace(/%/g, "%%")
|
|
34
|
+
.replace(/\n/g, "\\n")}"`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function systemdEnvironmentAssignment(name: string, value: string | undefined): string | null {
|
|
38
|
+
if (!value) return null;
|
|
39
|
+
return `Environment=${systemdQuote(`${name}=${value}`)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Linux (systemd user unit) ──
|
|
43
|
+
function unitDir(): string {
|
|
44
|
+
return join(homedir(), ".config", "systemd", "user");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function unitPath(): string {
|
|
48
|
+
return join(unitDir(), `${TASK}.service`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildUnit(
|
|
52
|
+
proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
|
|
53
|
+
deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {},
|
|
54
|
+
): string {
|
|
55
|
+
const runtime = deps.runtime ?? durableBunRuntime();
|
|
56
|
+
const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
|
|
57
|
+
// Discovery belongs to installSystemd(), which resolves once and passes the same value to
|
|
58
|
+
// both the unit and install state. Keeping this builder explicit makes tests and diagnostics
|
|
59
|
+
// independent of the host PATH.
|
|
60
|
+
const launcher = deps.launcher ?? null;
|
|
61
|
+
const log = logPath();
|
|
62
|
+
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
63
|
+
const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
|
|
64
|
+
const codexSqliteHome = systemdEnvironmentAssignment("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute());
|
|
65
|
+
const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
|
|
66
|
+
const envLines = [
|
|
67
|
+
systemdEnvironmentAssignment("OCX_SERVICE", "1"),
|
|
68
|
+
systemdEnvironmentAssignment(SERVICE_MANAGED_ENV, "1"),
|
|
69
|
+
...(launcher ? [] : [
|
|
70
|
+
systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
|
|
71
|
+
systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
|
|
72
|
+
]),
|
|
73
|
+
// A launcher normally resolves the current package's bundled Bun after every upgrade.
|
|
74
|
+
// Preserve only a proof-bound shell override; otherwise writing a package-local path here
|
|
75
|
+
// would recreate the version-manager pin that the launcher mode exists to remove.
|
|
76
|
+
launcher && runtime.source === "override"
|
|
77
|
+
? systemdEnvironmentAssignment(runtime.overrideEnv, runtime.path)
|
|
78
|
+
: null,
|
|
79
|
+
systemdEnvironmentAssignment("PATH", path),
|
|
80
|
+
codexHome,
|
|
81
|
+
codexSqliteHome,
|
|
82
|
+
opencodexHome,
|
|
83
|
+
...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
|
|
84
|
+
].filter((line): line is string => Boolean(line)).join("\n");
|
|
85
|
+
const command = `${launcher ? buildServiceLauncherShellCommand(launcher) : buildServiceShellCommand(bun, cli)} >> ${shellQuote(log)} 2>&1`;
|
|
86
|
+
return `[Unit]
|
|
87
|
+
Description=OpenCodex Proxy Server
|
|
88
|
+
After=network-online.target
|
|
89
|
+
Wants=network-online.target
|
|
90
|
+
|
|
91
|
+
[Service]
|
|
92
|
+
Type=simple
|
|
93
|
+
ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(command)}
|
|
94
|
+
Restart=on-failure
|
|
95
|
+
RestartSec=5
|
|
96
|
+
${envLines}
|
|
97
|
+
|
|
98
|
+
[Install]
|
|
99
|
+
WantedBy=default.target
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The per-user runtime dir systemd creates (holds the user-bus socket), or null. */
|
|
104
|
+
function userRuntimeDir(): string | null {
|
|
105
|
+
const fromEnv = process.env.XDG_RUNTIME_DIR;
|
|
106
|
+
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
|
107
|
+
if (typeof process.getuid === "function") {
|
|
108
|
+
const candidate = `/run/user/${process.getuid()}`;
|
|
109
|
+
if (existsSync(candidate)) return candidate;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* SSH sessions frequently start without `XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS`, so
|
|
116
|
+
* `systemctl --user` can't find the user bus even when systemd is running. Point `XDG_RUNTIME_DIR`
|
|
117
|
+
* at the per-user runtime dir when it exists so the `--user` probe and install commands reach the
|
|
118
|
+
* bus. No-op when already set or when no runtime dir exists (e.g. genuinely non-systemd hosts).
|
|
119
|
+
*/
|
|
120
|
+
function ensureUserBusEnv(): void {
|
|
121
|
+
if (process.env.XDG_RUNTIME_DIR) return;
|
|
122
|
+
const dir = userRuntimeDir();
|
|
123
|
+
if (dir) process.env.XDG_RUNTIME_DIR = dir;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isSystemd(): boolean {
|
|
127
|
+
try { execSync("systemctl --version", { stdio: "pipe" }); } catch { return false; }
|
|
128
|
+
ensureUserBusEnv();
|
|
129
|
+
// Prefer the user-bus probe; but an SSH session without a user D-Bus fails it even when systemd
|
|
130
|
+
// is present (F9). Fall back to the per-user runtime dir existing — a strong signal the user
|
|
131
|
+
// systemd instance is available — so a first-time `ocx service install` isn't wrongly refused.
|
|
132
|
+
try { execSync("systemctl --user show-environment", { stdio: "pipe" }); return true; } catch { /* no user bus in this session */ }
|
|
133
|
+
return userRuntimeDir() !== null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function installSystemd(): void {
|
|
137
|
+
ensureUserBusEnv(); // reach the user bus over a bare SSH session (F9)
|
|
138
|
+
const dir = unitDir();
|
|
139
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
140
|
+
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
141
|
+
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
142
|
+
writeServiceApiTokenFile();
|
|
143
|
+
// Resolve ONCE and reuse: the unit and the install state must agree about what is
|
|
144
|
+
// launched, or the staleness check would validate a path the unit does not run.
|
|
145
|
+
const launcher = stableLauncherEntry();
|
|
146
|
+
writeServiceDefinitionFile(unitPath(), buildUnit(resolvedProxyEnv(), { launcher }), "utf8");
|
|
147
|
+
sh("systemctl --user daemon-reload");
|
|
148
|
+
sh(`systemctl --user enable ${TASK}`);
|
|
149
|
+
sh(`systemctl --user restart ${TASK}`);
|
|
150
|
+
writeServiceInstallState("scheduler", launcher);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether systemd's in-memory unit differs from the file on disk.
|
|
155
|
+
*
|
|
156
|
+
* The systemd analogue of launchd's stale-plist case: writing
|
|
157
|
+
* `~/.config/systemd/user/<unit>` does not change the definition systemd has loaded
|
|
158
|
+
* until `daemon-reload`, so a plain `systemctl start` would run the PREVIOUS
|
|
159
|
+
* ExecStart. `NeedDaemonReload` is a per-unit property emitted as a bare
|
|
160
|
+
* `NeedDaemonReload=yes|no` line; pass the unit name or `show` reports the manager's
|
|
161
|
+
* own property instead, which answers a different question.
|
|
162
|
+
*
|
|
163
|
+
* Fail-open: if the query cannot run (no user bus, unit absent) we must not block a
|
|
164
|
+
* start that would otherwise work.
|
|
165
|
+
*/
|
|
166
|
+
export function systemdNeedsDaemonReload(deps: { show?: () => string } = {}): boolean {
|
|
167
|
+
try {
|
|
168
|
+
const out = (deps.show ?? (() => sh(`systemctl --user show -p NeedDaemonReload ${TASK}`)))();
|
|
169
|
+
return /NeedDaemonReload\s*=\s*yes/i.test(out);
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function startSystemd(): void {
|
|
176
|
+
ensureUserBusEnv();
|
|
177
|
+
if (!existsSync(unitPath())) {
|
|
178
|
+
console.error(`opencodex service is not installed: ${unitPath()}`);
|
|
179
|
+
console.error("Run `ocx service install` first to create and enable the systemd user unit.");
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
// The unit on disk may be newer than what systemd loaded; starting now would run
|
|
183
|
+
// the previous definition.
|
|
184
|
+
//
|
|
185
|
+
// `start` alone is not enough after a reload: it is a no-op on an already-active
|
|
186
|
+
// unit, so the stale process would keep running the old ExecStart. NeedDaemonReload
|
|
187
|
+
// compares disk against loaded, never loaded against running, so the only way to
|
|
188
|
+
// make the running process match the file is to restart it.
|
|
189
|
+
if (systemdNeedsDaemonReload()) {
|
|
190
|
+
console.log("ℹ️ unit file changed on disk; reloading systemd and restarting the service.");
|
|
191
|
+
sh("systemctl --user daemon-reload");
|
|
192
|
+
sh(`systemctl --user restart ${TASK}`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
sh(`systemctl --user start ${TASK}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function stopSystemd(): void { try { sh(`systemctl --user stop ${TASK}`); } catch { /* not running */ } }
|
|
199
|
+
|
|
200
|
+
export function statusSystemd(): string { try { return sh(`systemctl --user status ${TASK}`); } catch { return ""; } }
|
|
201
|
+
|
|
202
|
+
export function uninstallSystemd(deps: {
|
|
203
|
+
run?: (command: string) => string;
|
|
204
|
+
unitExists?: () => boolean;
|
|
205
|
+
removeUnit?: () => void;
|
|
206
|
+
} = {}): void {
|
|
207
|
+
const run = deps.run ?? sh;
|
|
208
|
+
try { run(`systemctl --user stop ${TASK}`); } catch { /* not running */ }
|
|
209
|
+
try { run(`systemctl --user disable ${TASK}`); } catch { /* absent */ }
|
|
210
|
+
if ((deps.unitExists ?? (() => existsSync(unitPath())))()) {
|
|
211
|
+
(deps.removeUnit ?? (() => unlinkSync(unitPath())))();
|
|
212
|
+
}
|
|
213
|
+
try { run("systemctl --user daemon-reload"); } catch { /* best-effort */ }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function systemdServiceInstallCleanupOps(deps: {
|
|
217
|
+
run?: (command: string) => string;
|
|
218
|
+
} = {}): ServiceInstallCleanupOps {
|
|
219
|
+
const run = deps.run ?? sh;
|
|
220
|
+
return {
|
|
221
|
+
status: () => {
|
|
222
|
+
const output = run(`systemctl --user show -p LoadState ${TASK}`);
|
|
223
|
+
const loadState = systemdProperty(output, "LoadState")?.toLowerCase();
|
|
224
|
+
if (!loadState) throw new Error("systemd service status could not be verified.");
|
|
225
|
+
return loadState === "not-found" ? null : loadState;
|
|
226
|
+
},
|
|
227
|
+
stop: () => { run(`systemctl --user stop ${TASK}`); },
|
|
228
|
+
};
|
|
229
|
+
}
|