@bridge_gpt/mcp-server 0.2.34 → 0.2.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +490 -366
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-review-workflow.js +264 -0
- package/build/claude-user-config-doctor.js +317 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +393 -4
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +37 -15
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +473 -81
- package/build/executor/cli.js +83 -6
- package/build/executor/credentials.js +39 -11
- package/build/executor/deps.js +32 -1
- package/build/executor/env.js +71 -28
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/index.js +4 -0
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +530 -20
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +114 -8
- package/build/executor/preflight.js +127 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +23 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +75 -3
- package/build/executor/types.js +4 -18
- package/build/executor/worker-command.js +73 -12
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +1039 -237
- package/build/init.js +503 -111
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +1188 -247
- package/build/install-reexec.js +233 -0
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +52 -13
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +32 -13
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +259 -0
- package/build/setup-epic.js +772 -28
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +412 -110
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +132 -6
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +19 -4
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor persistent-service packaging (BAPI-688).
|
|
3
|
+
*
|
|
4
|
+
* Renders long-running *user-domain* executor services — a macOS launchd
|
|
5
|
+
* LaunchAgent plist or a Linux systemd `--user` unit — for ONE stable executor
|
|
6
|
+
* id, and hosts the `executor install-service` CLI that previews or writes
|
|
7
|
+
* exactly one of them.
|
|
8
|
+
*
|
|
9
|
+
* Three invariants shape this module:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Never embeds credentials.** A generated unit carries only the validated
|
|
12
|
+
* base URL, the generating shell's `PATH`, the repository list, the executor
|
|
13
|
+
* id, and the packaged invocation. `BAPI_API_KEY` (and every
|
|
14
|
+
* credential-store value) resolves at *launch* time through the executor's
|
|
15
|
+
* existing credential seam, so an artifact left on disk is not a secret.
|
|
16
|
+
* `PATH` is baked deliberately (the scheduler backends set the precedent —
|
|
17
|
+
* see `BAKED_ENV_VAR_NAMES` in `../scheduler-backends/escaping.js`,
|
|
18
|
+
* BAPI-327): under launchd / `systemd --user` the environment PATH is the
|
|
19
|
+
* minimal system default, and the executor spawns its worker as the bare
|
|
20
|
+
* command `claude`, which on a typical workstation lives in `~/.local/bin`,
|
|
21
|
+
* `~/.claude/local`, or `/opt/homebrew/bin` — without the generating
|
|
22
|
+
* shell's PATH a service-launched executor crash-loops at worker preflight.
|
|
23
|
+
* 2. **Filesystem effects only through injected dependencies.** Every `stat` /
|
|
24
|
+
* `mkdir` / `writeFile` / prompt goes through {@link ExecutorServiceInstallDeps},
|
|
25
|
+
* so the whole CLI is unit-testable without touching a real home directory.
|
|
26
|
+
* 3. **Never manages service lifecycle.** The installer writes a file. It does
|
|
27
|
+
* not run `launchctl`, `systemctl`, `loginctl`, or `schtasks` — starting,
|
|
28
|
+
* stopping, and enabling the unit stay operator-owned.
|
|
29
|
+
*
|
|
30
|
+
* The artifact type is deliberately executor-local rather than the scheduler's
|
|
31
|
+
* {@link ../scheduler-backends/types.js#GeneratedArtifact}: that shape carries a
|
|
32
|
+
* loose `kind: string`, whereas the executor wants a *discriminating* literal
|
|
33
|
+
* kind so a plist can never be handed to the systemd inspector. The two remain
|
|
34
|
+
* structurally compatible (`path` / `content` / `kind`).
|
|
35
|
+
*/
|
|
36
|
+
import { mkdir as fsMkdir, stat as fsStat, writeFile as fsWriteFile } from "node:fs/promises";
|
|
37
|
+
import os from "node:os";
|
|
38
|
+
import { existsSync } from "node:fs";
|
|
39
|
+
import readline from "node:readline";
|
|
40
|
+
import { xmlEscape, systemdQuote, posixShellQuote, windowsCmdQuote, } from "../scheduler-backends/escaping.js";
|
|
41
|
+
import { pathApiForPlatform } from "../scheduler-backends/types.js";
|
|
42
|
+
import { resolveMcpShimInvocationForRuntime, MCP_SERVER_PACKAGE_NAME, } from "../mcp-server-invocation.js";
|
|
43
|
+
import { isTransientNpxEntryPath } from "../schedule-run.js";
|
|
44
|
+
import { parseExecutorArgs } from "./cli.js";
|
|
45
|
+
import { resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE } from "./credentials.js";
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Fixed naming conventions (one service unit == one executor id)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
/** launchd label prefix. The full label is `<prefix><executor-id>`. */
|
|
50
|
+
export const EXECUTOR_LAUNCHD_LABEL_PREFIX = "com.bridge-gpt.executor.";
|
|
51
|
+
/** launchd plist filename suffix. */
|
|
52
|
+
export const EXECUTOR_LAUNCHD_FILENAME_SUFFIX = ".plist";
|
|
53
|
+
/** systemd user-unit filename prefix. */
|
|
54
|
+
export const EXECUTOR_SYSTEMD_UNIT_PREFIX = "bridge-gpt-executor-";
|
|
55
|
+
/** systemd user-unit filename suffix. */
|
|
56
|
+
export const EXECUTOR_SYSTEMD_UNIT_SUFFIX = ".service";
|
|
57
|
+
/** The ONLY Bridge environment value baked into a generated unit. */
|
|
58
|
+
export const EXECUTOR_SERVICE_BASE_URL_ENV = "BAPI_BASE_URL";
|
|
59
|
+
/**
|
|
60
|
+
* The one NON-Bridge environment value baked into a generated unit: the
|
|
61
|
+
* generating shell's `PATH`, captured at render time so a service-launched
|
|
62
|
+
* executor can resolve the bare `claude` worker command (module docstring,
|
|
63
|
+
* invariant 1). Baked only when the captured value is non-empty — baking an
|
|
64
|
+
* empty PATH would be strictly worse than the service manager's default.
|
|
65
|
+
*/
|
|
66
|
+
export const EXECUTOR_SERVICE_PATH_ENV = "PATH";
|
|
67
|
+
/** Home-relative directory segments holding Darwin LaunchAgents. */
|
|
68
|
+
const LAUNCHD_DIR_SEGMENTS = ["Library", "LaunchAgents"];
|
|
69
|
+
/** Home-relative directory segments holding Linux systemd user units. */
|
|
70
|
+
const SYSTEMD_DIR_SEGMENTS = [".config", "systemd", "user"];
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Path + label helpers (pure)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
/** The launchd label for an executor id: `com.bridge-gpt.executor.<id>`. */
|
|
75
|
+
export function executorLaunchdLabelForId(executorId) {
|
|
76
|
+
return `${EXECUTOR_LAUNCHD_LABEL_PREFIX}${executorId}`;
|
|
77
|
+
}
|
|
78
|
+
/** Absolute LaunchAgents directory for a home dir (Darwin path semantics). */
|
|
79
|
+
export function executorLaunchdDirForHome(homeDir) {
|
|
80
|
+
return pathApiForPlatform("darwin").join(homeDir, ...LAUNCHD_DIR_SEGMENTS);
|
|
81
|
+
}
|
|
82
|
+
/** `~/Library/LaunchAgents/com.bridge-gpt.executor.<id>.plist`. */
|
|
83
|
+
export function executorLaunchdPlistPathForId(executorId, homeDir) {
|
|
84
|
+
const filename = `${executorLaunchdLabelForId(executorId)}${EXECUTOR_LAUNCHD_FILENAME_SUFFIX}`;
|
|
85
|
+
return pathApiForPlatform("darwin").join(executorLaunchdDirForHome(homeDir), filename);
|
|
86
|
+
}
|
|
87
|
+
/** The systemd unit name: `bridge-gpt-executor-<id>.service`. */
|
|
88
|
+
export function executorSystemdUnitNameForId(executorId) {
|
|
89
|
+
return `${EXECUTOR_SYSTEMD_UNIT_PREFIX}${executorId}${EXECUTOR_SYSTEMD_UNIT_SUFFIX}`;
|
|
90
|
+
}
|
|
91
|
+
/** Absolute systemd user-unit directory for a home dir (POSIX semantics). */
|
|
92
|
+
export function executorSystemdDirForHome(homeDir) {
|
|
93
|
+
return pathApiForPlatform("linux").join(homeDir, ...SYSTEMD_DIR_SEGMENTS);
|
|
94
|
+
}
|
|
95
|
+
/** `~/.config/systemd/user/bridge-gpt-executor-<id>.service`. */
|
|
96
|
+
export function executorSystemdUnitPathForId(executorId, homeDir) {
|
|
97
|
+
return pathApiForPlatform("linux").join(executorSystemdDirForHome(homeDir), executorSystemdUnitNameForId(executorId));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Recover the executor id from a LaunchAgents filename, or `null` when the name
|
|
101
|
+
* does not follow the fixed convention. Doctor uses this so enumeration and
|
|
102
|
+
* generation share one naming contract.
|
|
103
|
+
*/
|
|
104
|
+
export function executorIdFromLaunchdFilename(filename) {
|
|
105
|
+
if (!filename.startsWith(EXECUTOR_LAUNCHD_LABEL_PREFIX) ||
|
|
106
|
+
!filename.endsWith(EXECUTOR_LAUNCHD_FILENAME_SUFFIX)) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const id = filename.slice(EXECUTOR_LAUNCHD_LABEL_PREFIX.length, filename.length - EXECUTOR_LAUNCHD_FILENAME_SUFFIX.length);
|
|
110
|
+
return id.length > 0 ? id : null;
|
|
111
|
+
}
|
|
112
|
+
/** Recover the executor id from a systemd unit filename, or `null`. */
|
|
113
|
+
export function executorIdFromSystemdFilename(filename) {
|
|
114
|
+
if (!filename.startsWith(EXECUTOR_SYSTEMD_UNIT_PREFIX) ||
|
|
115
|
+
!filename.endsWith(EXECUTOR_SYSTEMD_UNIT_SUFFIX)) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const id = filename.slice(EXECUTOR_SYSTEMD_UNIT_PREFIX.length, filename.length - EXECUTOR_SYSTEMD_UNIT_SUFFIX.length);
|
|
119
|
+
return id.length > 0 ? id : null;
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Validation (pure) — everything that becomes a filename or a unit directive
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/** Thrown by the pure renderers/builder when an input cannot be safely rendered. */
|
|
125
|
+
export class ExecutorServiceValidationError extends Error {
|
|
126
|
+
constructor(message) {
|
|
127
|
+
super(message);
|
|
128
|
+
this.name = "ExecutorServiceValidationError";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Control characters (including NUL/CR/LF) are never legal in a rendered value. */
|
|
132
|
+
// eslint-disable-next-line no-control-regex
|
|
133
|
+
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
|
|
134
|
+
/**
|
|
135
|
+
* systemd unit-name boundary. systemd accepts `[A-Za-z0-9:_.-]` (plus escape
|
|
136
|
+
* sequences) in a unit name; we additionally require an alphanumeric first
|
|
137
|
+
* character so a unit can never be mistaken for a flag or a dotfile. Invalid
|
|
138
|
+
* ids are REJECTED, never silently rewritten — rewriting would break the fixed
|
|
139
|
+
* one-unit-per-executor-id naming contract the operator and doctor both rely on.
|
|
140
|
+
*/
|
|
141
|
+
const SYSTEMD_UNIT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.-]*$/;
|
|
142
|
+
/**
|
|
143
|
+
* Reject a value that cannot be interpolated into a unit directive at all.
|
|
144
|
+
*
|
|
145
|
+
* Every failure surfaces as an {@link ExecutorServiceValidationError} so the CLI
|
|
146
|
+
* has one error type to catch and sanitize. `CONTROL_CHAR_RE` covers NUL (which
|
|
147
|
+
* can truncate a written unit mid-file) alongside CR/LF (which could open a new
|
|
148
|
+
* directive) and the rest of the C0 range. The escaping helpers below re-check
|
|
149
|
+
* NUL independently, so this is the first of two barriers, not the only one.
|
|
150
|
+
*/
|
|
151
|
+
function assertRenderableValue(value, label) {
|
|
152
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
153
|
+
throw new ExecutorServiceValidationError(`${label} is required`);
|
|
154
|
+
}
|
|
155
|
+
if (CONTROL_CHAR_RE.test(value)) {
|
|
156
|
+
throw new ExecutorServiceValidationError(`${label} contains a control character (NUL, CR, LF, or similar), which could truncate the ` +
|
|
157
|
+
"unit or inject a directive");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Validate an executor id for the target platform. Darwin keeps XML
|
|
162
|
+
* metacharacters (`& < > " '` are legal filename characters there and are XML
|
|
163
|
+
* escaped at render time); Linux is held to the literal systemd unit-name
|
|
164
|
+
* boundary. Both reject path separators, control characters, and relative-path
|
|
165
|
+
* names so the id can never redirect the write.
|
|
166
|
+
*/
|
|
167
|
+
export function validateExecutorId(executorId, platform) {
|
|
168
|
+
assertRenderableValue(executorId, "executor id");
|
|
169
|
+
if (executorId !== executorId.trim()) {
|
|
170
|
+
throw new ExecutorServiceValidationError("executor id must not have leading or trailing whitespace");
|
|
171
|
+
}
|
|
172
|
+
if (executorId.includes("/") || executorId.includes("\\")) {
|
|
173
|
+
throw new ExecutorServiceValidationError("executor id must not contain a path separator ('/' or '\\')");
|
|
174
|
+
}
|
|
175
|
+
if (executorId === "." || executorId === "..") {
|
|
176
|
+
throw new ExecutorServiceValidationError("executor id must not be a relative path segment");
|
|
177
|
+
}
|
|
178
|
+
if (executorId.length > 128) {
|
|
179
|
+
throw new ExecutorServiceValidationError("executor id must be 128 characters or fewer");
|
|
180
|
+
}
|
|
181
|
+
if (platform === "linux" && !SYSTEMD_UNIT_ID_RE.test(executorId)) {
|
|
182
|
+
throw new ExecutorServiceValidationError(`executor id '${executorId}' is not a valid systemd unit-name fragment: ` +
|
|
183
|
+
"use only letters, digits, ':', '_', '.', or '-', starting with a letter or digit " +
|
|
184
|
+
"(the id is never rewritten, because one executor id maps to exactly one unit name)");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Validate the full render input without producing an artifact. */
|
|
188
|
+
export function validateExecutorServiceRenderInput(input, platform) {
|
|
189
|
+
try {
|
|
190
|
+
validateExecutorId(input.executorId, platform);
|
|
191
|
+
assertRenderableValue(input.homeDir, "home directory");
|
|
192
|
+
assertRenderableValue(input.baseUrl, "base URL");
|
|
193
|
+
// Whitespace is never legitimate inside a URL, and a base URL is the one
|
|
194
|
+
// rendered value that has a strict grammar. Rejecting it here means callers
|
|
195
|
+
// other than the CLI (which additionally does a full `new URL(...)` check)
|
|
196
|
+
// still cannot bake a malformed endpoint into a durable unit.
|
|
197
|
+
if (/\s/.test(input.baseUrl)) {
|
|
198
|
+
throw new ExecutorServiceValidationError("base URL must not contain whitespace");
|
|
199
|
+
}
|
|
200
|
+
assertRenderableValue(input.invocation.executable, "service executable");
|
|
201
|
+
// PATH is optional (empty means "omit the directive"), but a present value
|
|
202
|
+
// is held to the same control-character barrier as every rendered value: a
|
|
203
|
+
// CR/LF or NUL in PATH could open a new directive or truncate the unit.
|
|
204
|
+
if (input.envPath !== undefined && input.envPath.length > 0) {
|
|
205
|
+
assertRenderableValue(input.envPath, "PATH value");
|
|
206
|
+
}
|
|
207
|
+
if (input.repos.length === 0) {
|
|
208
|
+
throw new ExecutorServiceValidationError("at least one repository is required");
|
|
209
|
+
}
|
|
210
|
+
for (const repo of input.repos)
|
|
211
|
+
assertRenderableValue(repo, "repository name");
|
|
212
|
+
for (const arg of input.invocation.prefixArgs)
|
|
213
|
+
assertRenderableValue(arg, "service argument");
|
|
214
|
+
return { ok: true };
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function assertValidRenderInput(input, platform) {
|
|
221
|
+
const result = validateExecutorServiceRenderInput(input, platform);
|
|
222
|
+
if (!result.ok)
|
|
223
|
+
throw new ExecutorServiceValidationError(result.error);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Build the executor daemon argument vector: the packaged prefix, then one
|
|
227
|
+
* `--repo <name>` pair per repository, then `--executor-id <id>`. An argument
|
|
228
|
+
* ARRAY, never a concatenated shell command — and repository *names*, never
|
|
229
|
+
* repository paths, because the executor's identity is the id, not a checkout.
|
|
230
|
+
*/
|
|
231
|
+
export function buildExecutorServiceArguments(input) {
|
|
232
|
+
const args = [...input.invocation.prefixArgs];
|
|
233
|
+
for (const repo of input.repos)
|
|
234
|
+
args.push("--repo", repo);
|
|
235
|
+
args.push("--executor-id", input.executorId);
|
|
236
|
+
return args;
|
|
237
|
+
}
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Renderers (pure)
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
/**
|
|
242
|
+
* Render the launchd LaunchAgent plist for one executor id. `RunAtLoad` and
|
|
243
|
+
* `KeepAlive` make it a long-running daemon that comes back after a crash or a
|
|
244
|
+
* login. Every interpolated value is XML escaped. When the render input carries
|
|
245
|
+
* a non-empty `envPath`, the generating shell's `PATH` is baked alongside the
|
|
246
|
+
* base URL (module docstring, invariant 1) so the executor's worker spawn can
|
|
247
|
+
* resolve `claude` under launchd's minimal default environment.
|
|
248
|
+
*/
|
|
249
|
+
export function renderExecutorLaunchdPlist(input) {
|
|
250
|
+
assertValidRenderInput(input, "darwin");
|
|
251
|
+
const label = executorLaunchdLabelForId(input.executorId);
|
|
252
|
+
const programArgs = [input.invocation.executable, ...buildExecutorServiceArguments(input)]
|
|
253
|
+
.map((arg) => ` <string>${xmlEscape(arg)}</string>`)
|
|
254
|
+
.join("\n");
|
|
255
|
+
const envEntries = [
|
|
256
|
+
` <key>${EXECUTOR_SERVICE_BASE_URL_ENV}</key>`,
|
|
257
|
+
` <string>${xmlEscape(input.baseUrl)}</string>`,
|
|
258
|
+
];
|
|
259
|
+
if (input.envPath !== undefined && input.envPath.length > 0) {
|
|
260
|
+
envEntries.push(` <key>${EXECUTOR_SERVICE_PATH_ENV}</key>`, ` <string>${xmlEscape(input.envPath)}</string>`);
|
|
261
|
+
}
|
|
262
|
+
return [
|
|
263
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
264
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
265
|
+
'<plist version="1.0">',
|
|
266
|
+
"<dict>",
|
|
267
|
+
" <key>Label</key>",
|
|
268
|
+
` <string>${xmlEscape(label)}</string>`,
|
|
269
|
+
" <key>EnvironmentVariables</key>",
|
|
270
|
+
" <dict>",
|
|
271
|
+
...envEntries,
|
|
272
|
+
" </dict>",
|
|
273
|
+
" <key>ProgramArguments</key>",
|
|
274
|
+
" <array>",
|
|
275
|
+
programArgs,
|
|
276
|
+
" </array>",
|
|
277
|
+
" <key>RunAtLoad</key>",
|
|
278
|
+
" <true/>",
|
|
279
|
+
" <key>KeepAlive</key>",
|
|
280
|
+
" <true/>",
|
|
281
|
+
"</dict>",
|
|
282
|
+
"</plist>",
|
|
283
|
+
"",
|
|
284
|
+
].join("\n");
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Escape a value for a systemd directive. {@link systemdQuote} handles
|
|
288
|
+
* backslashes and double quotes; `%` is additionally doubled because systemd
|
|
289
|
+
* expands `%`-specifiers (`%h`, `%i`, …) inside `ExecStart` and `Environment`.
|
|
290
|
+
*/
|
|
291
|
+
export function systemdDirectiveValue(value) {
|
|
292
|
+
return systemdQuote(value.replace(/%/g, "%%"));
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Render the systemd `--user` unit for one executor id. `Type=simple` +
|
|
296
|
+
* `Restart=always` make it a supervised long-running service; `[Install]` /
|
|
297
|
+
* `WantedBy=default.target` lets the operator `systemctl --user enable` it — the
|
|
298
|
+
* installer never runs that command itself. When the render input carries a
|
|
299
|
+
* non-empty `envPath`, the generating shell's `PATH` is baked alongside the base
|
|
300
|
+
* URL (module docstring, invariant 1) so the executor's worker spawn can resolve
|
|
301
|
+
* `claude` under the `--user` manager's minimal default environment. Like every
|
|
302
|
+
* directive value it goes through {@link systemdDirectiveValue}, so `%` is
|
|
303
|
+
* doubled against specifier expansion.
|
|
304
|
+
*/
|
|
305
|
+
export function renderExecutorSystemdService(input) {
|
|
306
|
+
assertValidRenderInput(input, "linux");
|
|
307
|
+
const execStart = [input.invocation.executable, ...buildExecutorServiceArguments(input)]
|
|
308
|
+
.map(systemdDirectiveValue)
|
|
309
|
+
.join(" ");
|
|
310
|
+
const environmentLines = [
|
|
311
|
+
`Environment=${EXECUTOR_SERVICE_BASE_URL_ENV}=${systemdDirectiveValue(input.baseUrl)}`,
|
|
312
|
+
];
|
|
313
|
+
if (input.envPath !== undefined && input.envPath.length > 0) {
|
|
314
|
+
environmentLines.push(`Environment=${EXECUTOR_SERVICE_PATH_ENV}=${systemdDirectiveValue(input.envPath)}`);
|
|
315
|
+
}
|
|
316
|
+
return [
|
|
317
|
+
"[Unit]",
|
|
318
|
+
`Description=${systemdDirectiveValue(`Bridge GPT epic-conductor executor (${input.executorId})`)}`,
|
|
319
|
+
"After=network-online.target",
|
|
320
|
+
"",
|
|
321
|
+
"[Service]",
|
|
322
|
+
"Type=simple",
|
|
323
|
+
...environmentLines,
|
|
324
|
+
`ExecStart=${execStart}`,
|
|
325
|
+
"Restart=always",
|
|
326
|
+
"RestartSec=10",
|
|
327
|
+
"",
|
|
328
|
+
"[Install]",
|
|
329
|
+
"WantedBy=default.target",
|
|
330
|
+
"",
|
|
331
|
+
].join("\n");
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Build the single artifact for a supported persistent-service platform. Pure:
|
|
335
|
+
* it derives the deterministic path and renders the content, and rejects any
|
|
336
|
+
* platform that has no generated-unit format rather than guessing one.
|
|
337
|
+
*/
|
|
338
|
+
export function buildExecutorServiceArtifact(input, platform) {
|
|
339
|
+
if (platform === "darwin") {
|
|
340
|
+
return {
|
|
341
|
+
path: executorLaunchdPlistPathForId(input.executorId, input.homeDir),
|
|
342
|
+
content: renderExecutorLaunchdPlist(input),
|
|
343
|
+
kind: "launchd-plist",
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
if (platform === "linux") {
|
|
347
|
+
return {
|
|
348
|
+
path: executorSystemdUnitPathForId(input.executorId, input.homeDir),
|
|
349
|
+
content: renderExecutorSystemdService(input),
|
|
350
|
+
kind: "systemd-service",
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
throw new ExecutorServiceValidationError(`platform '${platform}' has no generated executor service format ` +
|
|
354
|
+
"(persistent units are generated for darwin and linux only)");
|
|
355
|
+
}
|
|
356
|
+
/** Reverse {@link xmlEscape} for the five entities it emits. */
|
|
357
|
+
function xmlUnescape(value) {
|
|
358
|
+
return value
|
|
359
|
+
.replace(/</g, "<")
|
|
360
|
+
.replace(/>/g, ">")
|
|
361
|
+
.replace(/"/g, '"')
|
|
362
|
+
.replace(/'/g, "'")
|
|
363
|
+
.replace(/&/g, "&");
|
|
364
|
+
}
|
|
365
|
+
/** Pull `--repo`/`--executor-id` values out of a recovered argument vector. */
|
|
366
|
+
function readArgumentContract(args) {
|
|
367
|
+
const repos = [];
|
|
368
|
+
let executorId = null;
|
|
369
|
+
for (let i = 0; i < args.length; i++) {
|
|
370
|
+
if (args[i] === "--repo" && typeof args[i + 1] === "string") {
|
|
371
|
+
repos.push(args[++i]);
|
|
372
|
+
}
|
|
373
|
+
else if (args[i] === "--executor-id" && typeof args[i + 1] === "string") {
|
|
374
|
+
executorId = args[++i];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { repos, executorId };
|
|
378
|
+
}
|
|
379
|
+
/** Inspect a plist produced by {@link renderExecutorLaunchdPlist}. */
|
|
380
|
+
function inspectLaunchdPlist(content) {
|
|
381
|
+
const problems = [];
|
|
382
|
+
const envMatch = content.match(new RegExp(`<key>${EXECUTOR_SERVICE_BASE_URL_ENV}</key>\\s*<string>([^<]*)</string>`));
|
|
383
|
+
const baseUrl = envMatch ? xmlUnescape(envMatch[1]) : null;
|
|
384
|
+
if (baseUrl === null) {
|
|
385
|
+
problems.push(`no ${EXECUTOR_SERVICE_BASE_URL_ENV} entry found in the plist`);
|
|
386
|
+
}
|
|
387
|
+
const arrayMatch = content.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
388
|
+
let args = [];
|
|
389
|
+
if (arrayMatch) {
|
|
390
|
+
args = Array.from(arrayMatch[1].matchAll(/<string>([^<]*)<\/string>/g)).map((m) => xmlUnescape(m[1]));
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
problems.push("no ProgramArguments array found in the plist");
|
|
394
|
+
}
|
|
395
|
+
const { repos, executorId } = readArgumentContract(args);
|
|
396
|
+
return { ok: problems.length === 0, baseUrl, repos, executorId, problems };
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Split a systemd directive value into tokens, honouring the double-quoted form
|
|
400
|
+
* {@link systemdDirectiveValue} emits (backslash escapes inside quotes, `%%`
|
|
401
|
+
* collapsing to a literal `%`).
|
|
402
|
+
*/
|
|
403
|
+
function splitSystemdTokens(value) {
|
|
404
|
+
const tokens = [];
|
|
405
|
+
let current = "";
|
|
406
|
+
let inQuotes = false;
|
|
407
|
+
let started = false;
|
|
408
|
+
for (let i = 0; i < value.length; i++) {
|
|
409
|
+
const ch = value[i];
|
|
410
|
+
if (ch === "\\" && i + 1 < value.length) {
|
|
411
|
+
current += value[++i];
|
|
412
|
+
started = true;
|
|
413
|
+
}
|
|
414
|
+
else if (ch === "%" && value[i + 1] === "%") {
|
|
415
|
+
current += "%";
|
|
416
|
+
i++;
|
|
417
|
+
started = true;
|
|
418
|
+
}
|
|
419
|
+
else if (ch === '"') {
|
|
420
|
+
inQuotes = !inQuotes;
|
|
421
|
+
started = true;
|
|
422
|
+
}
|
|
423
|
+
else if (!inQuotes && /\s/.test(ch)) {
|
|
424
|
+
if (started)
|
|
425
|
+
tokens.push(current);
|
|
426
|
+
current = "";
|
|
427
|
+
started = false;
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
current += ch;
|
|
431
|
+
started = true;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (started)
|
|
435
|
+
tokens.push(current);
|
|
436
|
+
return tokens;
|
|
437
|
+
}
|
|
438
|
+
/** Inspect a unit produced by {@link renderExecutorSystemdService}. */
|
|
439
|
+
function inspectSystemdService(content) {
|
|
440
|
+
const problems = [];
|
|
441
|
+
const lines = content.split(/\r?\n/);
|
|
442
|
+
const envLine = lines.find((l) => l.startsWith(`Environment=${EXECUTOR_SERVICE_BASE_URL_ENV}=`));
|
|
443
|
+
let baseUrl = null;
|
|
444
|
+
if (envLine) {
|
|
445
|
+
const raw = envLine.slice(`Environment=${EXECUTOR_SERVICE_BASE_URL_ENV}=`.length);
|
|
446
|
+
baseUrl = splitSystemdTokens(raw)[0] ?? null;
|
|
447
|
+
}
|
|
448
|
+
if (baseUrl === null || baseUrl.length === 0) {
|
|
449
|
+
baseUrl = null;
|
|
450
|
+
problems.push(`no ${EXECUTOR_SERVICE_BASE_URL_ENV} directive found in the unit`);
|
|
451
|
+
}
|
|
452
|
+
const execLine = lines.find((l) => l.startsWith("ExecStart="));
|
|
453
|
+
let args = [];
|
|
454
|
+
if (execLine) {
|
|
455
|
+
args = splitSystemdTokens(execLine.slice("ExecStart=".length));
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
problems.push("no ExecStart directive found in the unit");
|
|
459
|
+
}
|
|
460
|
+
const { repos, executorId } = readArgumentContract(args);
|
|
461
|
+
return { ok: problems.length === 0, baseUrl, repos, executorId, problems };
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Recover the persisted contract (base URL, repositories, executor id) from an
|
|
465
|
+
* artifact this module generated. Doctor calls this instead of maintaining a
|
|
466
|
+
* second parser, so a renderer change can never silently desync the diagnostic.
|
|
467
|
+
* Malformed content yields a sanitized `problems` list — it never throws and
|
|
468
|
+
* never invents values.
|
|
469
|
+
*/
|
|
470
|
+
export function inspectExecutorServiceArtifact(kind, content) {
|
|
471
|
+
try {
|
|
472
|
+
return kind === "launchd-plist" ? inspectLaunchdPlist(content) : inspectSystemdService(content);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
baseUrl: null,
|
|
478
|
+
repos: [],
|
|
479
|
+
executorId: null,
|
|
480
|
+
problems: ["unit content could not be parsed"],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Resolve the packaged launcher a persistent unit should bake in: this process's
|
|
486
|
+
* own absolute Node executable plus the absolute compiled `index.js`, followed by
|
|
487
|
+
* the `executor` subcommand token.
|
|
488
|
+
*
|
|
489
|
+
* A persistent unit must survive a reboot with no login shell, so the two unsafe
|
|
490
|
+
* forms are refused rather than written: the npm-channel (`npx`) fallback, which
|
|
491
|
+
* is a PATH-relative command that also re-resolves the package on every launch,
|
|
492
|
+
* and an entry path inside a prunable `_npx` cache directory.
|
|
493
|
+
*/
|
|
494
|
+
export function resolvePackagedExecutorInvocation(deps) {
|
|
495
|
+
const resolved = resolveMcpShimInvocationForRuntime({
|
|
496
|
+
moduleUrl: deps?.moduleUrl ?? import.meta.url,
|
|
497
|
+
nodeExecutable: deps?.execPath ?? process.execPath,
|
|
498
|
+
argv1: deps?.argv1 ?? process.argv[1],
|
|
499
|
+
fileExists: deps?.fileExists ?? ((p) => existsSync(p)),
|
|
500
|
+
});
|
|
501
|
+
if (resolved.form !== "absolute-build-path") {
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
error: "could not resolve an absolute packaged CLI path for a persistent service. " +
|
|
505
|
+
`A service unit must not launch through 'npx ${MCP_SERVER_PACKAGE_NAME}', which depends on ` +
|
|
506
|
+
"PATH and a prunable cache. Install the CLI persistently " +
|
|
507
|
+
`(e.g. 'npm i -g ${MCP_SERVER_PACKAGE_NAME}') and re-run install-service from that binary.`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (isTransientNpxEntryPath(resolved.serverEntryPath)) {
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
error: `the running CLI lives in an npx cache directory ('${resolved.serverEntryPath}'), which can be ` +
|
|
514
|
+
"pruned at any time — a service unit baked with that path would fail with ENOENT after a reboot. " +
|
|
515
|
+
`Install the CLI persistently (e.g. 'npm i -g ${MCP_SERVER_PACKAGE_NAME}') and re-run install-service.`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
ok: true,
|
|
520
|
+
invocation: {
|
|
521
|
+
executable: resolved.nodeExecutable,
|
|
522
|
+
prefixArgs: [resolved.serverEntryPath, "executor"],
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// `executor install-service` CLI
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
/** User-facing usage for `executor install-service`. */
|
|
530
|
+
export function getExecutorInstallServiceUsage() {
|
|
531
|
+
return [
|
|
532
|
+
"Usage: mcp-server executor install-service --executor-id <id> --repo <name> [--repo <name> ...] [options]",
|
|
533
|
+
"",
|
|
534
|
+
"Generates ONE long-running user-domain service unit for ONE stable executor id:",
|
|
535
|
+
" macOS ~/Library/LaunchAgents/com.bridge-gpt.executor.<id>.plist",
|
|
536
|
+
" Linux ~/.config/systemd/user/bridge-gpt-executor-<id>.service",
|
|
537
|
+
" Windows manual Task Scheduler guidance is printed; nothing is written.",
|
|
538
|
+
"",
|
|
539
|
+
"Required:",
|
|
540
|
+
" --executor-id <id> Stable, explicit executor id. A persistent unit",
|
|
541
|
+
" needs a durable identity, so the foreground",
|
|
542
|
+
" <hostname>-<pid> default is never used here.",
|
|
543
|
+
" --repo <name> Repo to serve (repeatable; --repos=a,b also works).",
|
|
544
|
+
" --base-url <url> Bridge API endpoint, or set BAPI_BASE_URL.",
|
|
545
|
+
" There is NO production default.",
|
|
546
|
+
"",
|
|
547
|
+
"Options:",
|
|
548
|
+
" --dry-run Print the target path and the complete unit body.",
|
|
549
|
+
" Performs no consent prompt and no filesystem write.",
|
|
550
|
+
" --yes Explicit consent to create the unit's parent",
|
|
551
|
+
" directory and write the unit. Required when stdin",
|
|
552
|
+
" is not a TTY.",
|
|
553
|
+
" -h, --help Show this help.",
|
|
554
|
+
"",
|
|
555
|
+
"The generated unit contains only the base URL, the generating shell's PATH, the",
|
|
556
|
+
"repositories, the executor id, and the packaged invocation. PATH is baked because",
|
|
557
|
+
"the service manager's minimal default cannot resolve the `claude` worker command.",
|
|
558
|
+
"BAPI_API_KEY and every credential-store value are deliberately omitted —",
|
|
559
|
+
"credentials resolve at launch time from the environment or the user-scoped",
|
|
560
|
+
"bapi:<repo> credential store.",
|
|
561
|
+
"",
|
|
562
|
+
"This command writes a file and nothing else. Starting, stopping, enabling, and",
|
|
563
|
+
"removing the service stay operator-managed via launchctl / systemctl --user /",
|
|
564
|
+
"Task Scheduler.",
|
|
565
|
+
].join("\n");
|
|
566
|
+
}
|
|
567
|
+
/** Default, production dependency set for the install-service CLI. */
|
|
568
|
+
export function createDefaultExecutorServiceInstallDeps() {
|
|
569
|
+
return {
|
|
570
|
+
platform: process.platform,
|
|
571
|
+
env: process.env,
|
|
572
|
+
homeDir: os.homedir(),
|
|
573
|
+
parseContext: {
|
|
574
|
+
hostname: os.hostname(),
|
|
575
|
+
pid: process.pid,
|
|
576
|
+
platform: process.platform,
|
|
577
|
+
env: process.env,
|
|
578
|
+
},
|
|
579
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
580
|
+
resolveInvocation: () => resolvePackagedExecutorInvocation(),
|
|
581
|
+
stat: (filePath) => fsStat(filePath),
|
|
582
|
+
mkdir: (dirPath, options) => fsMkdir(dirPath, options),
|
|
583
|
+
writeFile: (filePath, content) => fsWriteFile(filePath, content, "utf-8"),
|
|
584
|
+
prompt: defaultConsentPrompt,
|
|
585
|
+
log: (message) => console.log(message),
|
|
586
|
+
errorLog: (message) => console.error(message),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** Default-No stdin consent prompt (only reached on a TTY). */
|
|
590
|
+
async function defaultConsentPrompt(question) {
|
|
591
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
592
|
+
try {
|
|
593
|
+
const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve));
|
|
594
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
rl.close();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Recognize only `--yes`, `--dry-run`, and help; forward everything else.
|
|
602
|
+
*
|
|
603
|
+
* Stripping is what makes `--base-url --yes` fail instead of persisting the
|
|
604
|
+
* literal string `--yes` as a URL: with `--yes` removed, `--base-url` has no
|
|
605
|
+
* value left and the executor parser rejects it.
|
|
606
|
+
*/
|
|
607
|
+
export function preprocessInstallServiceFlags(argv) {
|
|
608
|
+
let yes = false;
|
|
609
|
+
let dryRun = false;
|
|
610
|
+
let help = false;
|
|
611
|
+
const forwardArgs = [];
|
|
612
|
+
for (const arg of argv) {
|
|
613
|
+
if (arg === "--yes")
|
|
614
|
+
yes = true;
|
|
615
|
+
else if (arg === "--dry-run")
|
|
616
|
+
dryRun = true;
|
|
617
|
+
else if (arg === "--help" || arg === "-h")
|
|
618
|
+
help = true;
|
|
619
|
+
else
|
|
620
|
+
forwardArgs.push(arg);
|
|
621
|
+
}
|
|
622
|
+
return { yes, dryRun, help, forwardArgs };
|
|
623
|
+
}
|
|
624
|
+
/** Runtime modes that are incoherent for a supervised long-running service. */
|
|
625
|
+
const SERVICE_INCOMPATIBLE_FLAGS = ["--once"];
|
|
626
|
+
/**
|
|
627
|
+
* Write exactly one rendered artifact: stat the target for create-vs-replace
|
|
628
|
+
* messaging, create ONLY the unit's parent directory, and write ONLY the unit.
|
|
629
|
+
* Runs no `launchctl` / `systemctl` / `loginctl` / `schtasks` — lifecycle
|
|
630
|
+
* management is deliberately not this command's job.
|
|
631
|
+
*/
|
|
632
|
+
export async function writeExecutorServiceArtifact(artifact, deps, knownExists) {
|
|
633
|
+
const replaced = knownExists ?? (await targetExists(deps.stat, artifact.path));
|
|
634
|
+
const pathApi = pathApiForPlatform(deps.platform);
|
|
635
|
+
await deps.mkdir(pathApi.dirname(artifact.path), { recursive: true });
|
|
636
|
+
await deps.writeFile(artifact.path, artifact.content);
|
|
637
|
+
return { replaced };
|
|
638
|
+
}
|
|
639
|
+
/** ENOENT means absent; any other rejection is a real failure and propagates. */
|
|
640
|
+
async function targetExists(stat, filePath) {
|
|
641
|
+
try {
|
|
642
|
+
await stat(filePath);
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
const code = error?.code;
|
|
647
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
648
|
+
return false;
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** Sanitize a thrown value into a short, secret-free CLI message. */
|
|
653
|
+
function sanitizeError(error) {
|
|
654
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
655
|
+
return message.slice(0, 300);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* CLI entry for `executor install-service`. Returns a numeric exit code and
|
|
659
|
+
* contains every top-level failure rather than throwing through the process
|
|
660
|
+
* boundary. Ordering matters: EVERY user-supplied value is validated before the
|
|
661
|
+
* first `stat` / `mkdir` / `writeFile`, and `--dry-run` returns before both
|
|
662
|
+
* consent and filesystem access.
|
|
663
|
+
*/
|
|
664
|
+
export async function runExecutorInstallServiceCli(argv, overrides) {
|
|
665
|
+
const deps = {
|
|
666
|
+
...createDefaultExecutorServiceInstallDeps(),
|
|
667
|
+
...overrides,
|
|
668
|
+
};
|
|
669
|
+
const flags = preprocessInstallServiceFlags(argv);
|
|
670
|
+
if (flags.help) {
|
|
671
|
+
deps.log(getExecutorInstallServiceUsage());
|
|
672
|
+
return 0;
|
|
673
|
+
}
|
|
674
|
+
// --- Validation phase: nothing below touches the filesystem. ---
|
|
675
|
+
if (!flags.forwardArgs.includes("--executor-id")) {
|
|
676
|
+
deps.errorLog("Error: --executor-id is required by install-service. A persistent unit needs a stable, " +
|
|
677
|
+
"explicit executor id (one unit maps to exactly one id); the foreground executor's " +
|
|
678
|
+
"<hostname>-<pid> default is never baked into durable state.");
|
|
679
|
+
deps.errorLog("");
|
|
680
|
+
deps.errorLog(getExecutorInstallServiceUsage());
|
|
681
|
+
return 1;
|
|
682
|
+
}
|
|
683
|
+
for (const flag of SERVICE_INCOMPATIBLE_FLAGS) {
|
|
684
|
+
if (flags.forwardArgs.includes(flag)) {
|
|
685
|
+
deps.errorLog(`Error: ${flag} is incompatible with install-service — a supervised service runs ` +
|
|
686
|
+
"continuously, so a single-cycle mode would be silently dropped from the generated unit.");
|
|
687
|
+
return 1;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const parsed = parseExecutorArgs(flags.forwardArgs, deps.parseContext);
|
|
691
|
+
if (parsed.kind === "help") {
|
|
692
|
+
deps.log(getExecutorInstallServiceUsage());
|
|
693
|
+
return 0;
|
|
694
|
+
}
|
|
695
|
+
if (parsed.kind === "error") {
|
|
696
|
+
deps.errorLog(`Error: ${parsed.message}`);
|
|
697
|
+
deps.errorLog("");
|
|
698
|
+
deps.errorLog(getExecutorInstallServiceUsage());
|
|
699
|
+
return 1;
|
|
700
|
+
}
|
|
701
|
+
const options = parsed.options;
|
|
702
|
+
const baseUrlResult = resolveBaseUrl(deps.env, options.baseUrl);
|
|
703
|
+
if (!baseUrlResult.ok) {
|
|
704
|
+
deps.errorLog(`Error: ${EXECUTOR_BASE_URL_REQUIRED_MESSAGE}`);
|
|
705
|
+
return 1;
|
|
706
|
+
}
|
|
707
|
+
const baseUrl = baseUrlResult.baseUrl;
|
|
708
|
+
let parsedUrl;
|
|
709
|
+
try {
|
|
710
|
+
parsedUrl = new URL(baseUrl);
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
deps.errorLog(`Error: --base-url / ${EXECUTOR_SERVICE_BASE_URL_ENV} must be an absolute http(s) URL; ` +
|
|
714
|
+
"the supplied value is not a valid URL.");
|
|
715
|
+
return 1;
|
|
716
|
+
}
|
|
717
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
718
|
+
deps.errorLog(`Error: --base-url / ${EXECUTOR_SERVICE_BASE_URL_ENV} must use http: or https: ` +
|
|
719
|
+
`(got '${parsedUrl.protocol}').`);
|
|
720
|
+
return 1;
|
|
721
|
+
}
|
|
722
|
+
if (deps.platform !== "darwin" && deps.platform !== "linux" && deps.platform !== "win32") {
|
|
723
|
+
deps.errorLog(`Error: install-service supports darwin, linux, and win32 (manual guidance); ` +
|
|
724
|
+
`'${deps.platform}' has no supported persistent-service format.`);
|
|
725
|
+
return 1;
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
validateExecutorId(options.executorId, deps.platform);
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
deps.errorLog(`Error: ${sanitizeError(error)}`);
|
|
732
|
+
return 1;
|
|
733
|
+
}
|
|
734
|
+
const invocationResult = deps.resolveInvocation();
|
|
735
|
+
if (!invocationResult.ok) {
|
|
736
|
+
deps.errorLog(`Error: ${invocationResult.error}`);
|
|
737
|
+
return 1;
|
|
738
|
+
}
|
|
739
|
+
// Capture the generating shell's PATH once, at render time (the
|
|
740
|
+
// scheduler-backends precedent, BAPI-327): the service manager's default PATH
|
|
741
|
+
// cannot resolve the bare `claude` worker command. `Path` is the Windows
|
|
742
|
+
// spelling; a missing/empty value simply omits the directive.
|
|
743
|
+
const envPath = deps.env.PATH ?? deps.env.Path ?? "";
|
|
744
|
+
const renderInput = {
|
|
745
|
+
invocation: invocationResult.invocation,
|
|
746
|
+
executorId: options.executorId,
|
|
747
|
+
repos: options.repos,
|
|
748
|
+
baseUrl,
|
|
749
|
+
homeDir: deps.homeDir,
|
|
750
|
+
envPath,
|
|
751
|
+
};
|
|
752
|
+
// Windows has no generated user-service format here: print the fully resolved,
|
|
753
|
+
// secret-free command line plus manual Task Scheduler guidance and stop. No
|
|
754
|
+
// consent is requested because nothing is written.
|
|
755
|
+
if (deps.platform === "win32") {
|
|
756
|
+
const args = buildExecutorServiceArguments(renderInput);
|
|
757
|
+
deps.log(formatWindowsGuidance(renderInput, args));
|
|
758
|
+
return 0;
|
|
759
|
+
}
|
|
760
|
+
let artifact;
|
|
761
|
+
try {
|
|
762
|
+
artifact = buildExecutorServiceArtifact(renderInput, deps.platform);
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
deps.errorLog(`Error: ${sanitizeError(error)}`);
|
|
766
|
+
return 1;
|
|
767
|
+
}
|
|
768
|
+
// --- Dry run: a complete preview, before consent and before any fs call. ---
|
|
769
|
+
if (flags.dryRun) {
|
|
770
|
+
deps.log(`Target: ${artifact.path}`);
|
|
771
|
+
deps.log("");
|
|
772
|
+
deps.log(artifact.content);
|
|
773
|
+
deps.log("(dry run — nothing was written)");
|
|
774
|
+
return 0;
|
|
775
|
+
}
|
|
776
|
+
// --- Consent phase. ---
|
|
777
|
+
if (!flags.yes && !deps.isTTY) {
|
|
778
|
+
deps.errorLog(`Error: writing ${artifact.path} needs explicit consent, but stdin is not a TTY. ` +
|
|
779
|
+
"Re-run with --yes to authorize the write, or with --dry-run to preview the unit.");
|
|
780
|
+
return 1;
|
|
781
|
+
}
|
|
782
|
+
let exists;
|
|
783
|
+
try {
|
|
784
|
+
exists = await targetExists(deps.stat, artifact.path);
|
|
785
|
+
}
|
|
786
|
+
catch (error) {
|
|
787
|
+
deps.errorLog(`Error: could not inspect ${artifact.path}: ${sanitizeError(error)}`);
|
|
788
|
+
return 1;
|
|
789
|
+
}
|
|
790
|
+
if (!flags.yes) {
|
|
791
|
+
const action = exists ? "Replace" : "Create";
|
|
792
|
+
let approved = false;
|
|
793
|
+
try {
|
|
794
|
+
approved = await deps.prompt(`${action} executor service unit ${artifact.path}?`);
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
deps.errorLog(`Error: could not read consent: ${sanitizeError(error)}`);
|
|
798
|
+
return 1;
|
|
799
|
+
}
|
|
800
|
+
if (!approved) {
|
|
801
|
+
deps.errorLog("Service installation was not confirmed; nothing was written.");
|
|
802
|
+
return 1;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
// --- Write phase: parent directory + the single unit, nothing else. ---
|
|
806
|
+
let replaced = false;
|
|
807
|
+
try {
|
|
808
|
+
({ replaced } = await writeExecutorServiceArtifact(artifact, deps, exists));
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
deps.errorLog(`Error: could not write ${artifact.path}: ${sanitizeError(error)}`);
|
|
812
|
+
return 1;
|
|
813
|
+
}
|
|
814
|
+
deps.log(`${replaced ? "Replaced" : "Created"} ${artifact.path}`);
|
|
815
|
+
deps.log("");
|
|
816
|
+
deps.log(formatPostWriteGuidance(artifact, options.executorId));
|
|
817
|
+
return 0;
|
|
818
|
+
}
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
// Operator guidance formatters.
|
|
821
|
+
//
|
|
822
|
+
// These print command lines a human copy-pastes into a real shell, so they are
|
|
823
|
+
// the ONE surface in this module where a value becomes shell syntax rather than
|
|
824
|
+
// an argument-array element or an escaped unit directive. Executor ids stay
|
|
825
|
+
// deliberately permissive on Darwin (XML metacharacters are valid filename
|
|
826
|
+
// characters there — see `validateExecutorId`), and repository names are only
|
|
827
|
+
// checked for control characters, so every externally-supplied value MUST be
|
|
828
|
+
// shell-quoted here rather than assumed safe.
|
|
829
|
+
//
|
|
830
|
+
// Quoting reuses the escaping helpers the scheduler backends already ship:
|
|
831
|
+
// `posixShellQuote` (single-quote form, `'\''` for embedded quotes) and
|
|
832
|
+
// `windowsCmdQuote` (double-quote form, `""` for embedded quotes). The literal
|
|
833
|
+
// `$(id -u)` in the launchctl templates is intentionally left UNQUOTED — it is
|
|
834
|
+
// command substitution the operator wants evaluated, not interpolated data.
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
/** Manual Task Scheduler guidance plus the resolved, secret-free command line. */
|
|
837
|
+
function formatWindowsGuidance(input, args) {
|
|
838
|
+
// Quote EVERY token, not just the ones containing whitespace: the previous
|
|
839
|
+
// whitespace-only rule left embedded double quotes unescaped, so a value could
|
|
840
|
+
// close its own quoted token and append a command when pasted.
|
|
841
|
+
const commandLine = [input.invocation.executable, ...args].map(windowsCmdQuote).join(" ");
|
|
842
|
+
return [
|
|
843
|
+
"Windows: install-service does not create a service unit automatically.",
|
|
844
|
+
"Register the executor as a Task Scheduler task manually, using this resolved command line:",
|
|
845
|
+
"",
|
|
846
|
+
` ${commandLine}`,
|
|
847
|
+
"",
|
|
848
|
+
`Set the task's environment variable ${EXECUTOR_SERVICE_BASE_URL_ENV}=${windowsCmdQuote(input.baseUrl)}`,
|
|
849
|
+
"and configure the task to run at logon and restart on failure.",
|
|
850
|
+
"",
|
|
851
|
+
"The command line above carries no credential. Provide the Bridge API key at run time via",
|
|
852
|
+
"the BAPI_API_KEY environment variable or the user-scoped bapi:<repo> credential store.",
|
|
853
|
+
].join("\n");
|
|
854
|
+
}
|
|
855
|
+
/** Post-write operator guidance — naming the manual lifecycle step, not running it. */
|
|
856
|
+
function formatPostWriteGuidance(artifact, executorId) {
|
|
857
|
+
if (artifact.kind === "launchd-plist") {
|
|
858
|
+
const label = posixShellQuote(executorLaunchdLabelForId(executorId));
|
|
859
|
+
return [
|
|
860
|
+
"Next step (manual — install-service never manages lifecycle):",
|
|
861
|
+
` launchctl bootstrap gui/$(id -u) ${posixShellQuote(artifact.path)}`,
|
|
862
|
+
` launchctl print gui/$(id -u)/${label} # status`,
|
|
863
|
+
` launchctl bootout gui/$(id -u)/${label} # stop + unload`,
|
|
864
|
+
].join("\n");
|
|
865
|
+
}
|
|
866
|
+
const unit = posixShellQuote(executorSystemdUnitNameForId(executorId));
|
|
867
|
+
return [
|
|
868
|
+
"Next step (manual — install-service never manages lifecycle):",
|
|
869
|
+
" systemctl --user daemon-reload",
|
|
870
|
+
` systemctl --user enable --now ${unit}`,
|
|
871
|
+
` systemctl --user status ${unit}`,
|
|
872
|
+
"",
|
|
873
|
+
"On a headless host, also run `loginctl enable-linger $USER` so the user service",
|
|
874
|
+
"keeps running without an active login session.",
|
|
875
|
+
].join("\n");
|
|
876
|
+
}
|