@bridge_gpt/mcp-server 0.2.49 → 0.2.51
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 +25 -8
- package/build/base-ref.js +28 -3
- package/build/claude-review-workflow-drift-probe.js +130 -0
- package/build/claude-review-workflow-drift.js +173 -0
- package/build/claude-review-workflow.js +81 -16
- package/build/commands.generated.js +5 -5
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +51 -0
- package/build/conductor/done-gate.js +25 -3
- package/build/conductor/install-doctor.js +65 -5
- package/build/conductor/latest-check-selector.js +170 -0
- package/build/conductor/local-merge.js +8 -6
- package/build/conductor-bin.js +1 -1
- package/build/{brainstorm-files.js → council-files.js} +15 -15
- package/build/decision-page-schema.js +1 -1
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +352 -4
- package/build/epic-integration-pr.js +280 -0
- package/build/executor/job-runner.js +7 -1
- package/build/executor/merge-job.js +46 -1
- package/build/executor/worktree.js +46 -1
- package/build/index.js +153 -65
- package/build/init.js +9 -2
- package/build/install-bridge.js +60 -2
- package/build/install-reexec.js +47 -9
- package/build/pipelines.generated.js +8 -2
- package/build/plan-epic-conductor-eligibility.js +183 -0
- package/build/plane/cli.js +12 -2
- package/build/plane/manifest.js +25 -1
- package/build/plane/member-roster.js +61 -7
- package/build/plane/preflight.js +24 -9
- package/build/plane/supervisor.js +77 -5
- package/build/plane/types.js +23 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -1
- package/build/setup-epic.js +32 -0
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/stale-worktree-doctor.js +120 -0
- package/build/start-tickets-prereqs.js +70 -0
- package/build/start-tickets.js +91 -3
- package/build/version.generated.js +3 -2
- package/package.json +6 -3
- package/pipelines/plan-epic.json +5 -0
- package/build/chain-orchestrator.js +0 -1457
- package/build/chain-utils.js +0 -68
- package/build/command-catalog.js +0 -376
- package/build/schedule-run.js +0 -1300
- package/build/schedule-store.js +0 -172
- package/build/scheduled-prompt.js +0 -115
- package/build/scheduler-backends/at-fallback.js +0 -139
- package/build/scheduler-backends/escaping.js +0 -143
- package/build/scheduler-backends/index.js +0 -72
- package/build/scheduler-backends/launchd.js +0 -225
- package/build/scheduler-backends/systemd-user.js +0 -250
- package/build/scheduler-backends/task-scheduler.js +0 -214
- package/build/scheduler-backends/types.js +0 -23
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* macOS launchd backend (BAPI-327).
|
|
3
|
-
*
|
|
4
|
-
* Generates and installs a one-shot LaunchAgent plist that fires the baked
|
|
5
|
-
* Claude invocation at a single calendar minute via `StartCalendarInterval`.
|
|
6
|
-
*
|
|
7
|
-
* Drift policy: the plist omits `RunAtLoad` as defense-in-depth (it avoids an
|
|
8
|
-
* immediate run when the agent is reloaded at login), but this is NOT a true
|
|
9
|
-
* no-late-fire guarantee — launchd still fires a missed `StartCalendarInterval`
|
|
10
|
-
* event on the next wake. Unlike systemd `Persistent=false` / Task Scheduler,
|
|
11
|
-
* launchd has no OS-level "skip if missed" knob, so the authoritative staleness
|
|
12
|
-
* guard is the baked `--scheduled-at <T>` value in the prompt, which
|
|
13
|
-
* `/full-automation` (Phase C) checks before running. The working directory is
|
|
14
|
-
* set by the `WorkingDirectory` key, not a Claude flag. `ProgramArguments` is a
|
|
15
|
-
* string array (no shell concatenation).
|
|
16
|
-
*/
|
|
17
|
-
import { promises as fs } from "node:fs";
|
|
18
|
-
import { pathApiForPlatform } from "./types.js";
|
|
19
|
-
import { bakedEnvFromCreateInput, bakedEnvEntries, xmlEscape } from "./escaping.js";
|
|
20
|
-
/** launchd label for a NEW schedule id (neutral, generic schedule-run naming). */
|
|
21
|
-
export function launchdLabelForId(id) {
|
|
22
|
-
return `com.bridge-gpt.schedule-run.${id}`;
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Resolve the launchd label for an existing schedule, preferring the label baked
|
|
26
|
-
* into the recorded plist path so legacy `com.bridge-gpt.full-automation.<id>`
|
|
27
|
-
* schedules remain list/cancel compatible after the neutral-naming change.
|
|
28
|
-
*/
|
|
29
|
-
export function launchdLabelForMetadata(metadata) {
|
|
30
|
-
const unitPath = metadata.unit_path;
|
|
31
|
-
if (unitPath && unitPath.endsWith(".plist")) {
|
|
32
|
-
const base = unitPath.split(/[\\/]/).pop();
|
|
33
|
-
if (base)
|
|
34
|
-
return base.slice(0, -".plist".length);
|
|
35
|
-
}
|
|
36
|
-
return launchdLabelForId(metadata.id);
|
|
37
|
-
}
|
|
38
|
-
/** `~/Library/LaunchAgents/<label>.plist` for a schedule id. */
|
|
39
|
-
export function launchdPlistPathForId(id, homeDir) {
|
|
40
|
-
const pathApi = pathApiForPlatform("darwin");
|
|
41
|
-
return pathApi.join(homeDir, "Library", "LaunchAgents", `${launchdLabelForId(id)}.plist`);
|
|
42
|
-
}
|
|
43
|
-
/** Convert an ISO timestamp to local launchd calendar fields (minute precision). */
|
|
44
|
-
export function startCalendarIntervalFromIso(runAtIso) {
|
|
45
|
-
const date = new Date(runAtIso);
|
|
46
|
-
return {
|
|
47
|
-
Year: date.getFullYear(),
|
|
48
|
-
Month: date.getMonth() + 1, // launchd months are 1-12
|
|
49
|
-
Day: date.getDate(),
|
|
50
|
-
Hour: date.getHours(),
|
|
51
|
-
Minute: date.getMinutes(),
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
/** UID used by `launchctl` domain targets; falls back to the process UID. */
|
|
55
|
-
function resolveUid(deps) {
|
|
56
|
-
const fromEnv = deps.env.UID;
|
|
57
|
-
if (fromEnv && /^\d+$/.test(fromEnv))
|
|
58
|
-
return fromEnv;
|
|
59
|
-
const procUid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
60
|
-
return procUid !== undefined ? String(procUid) : "501";
|
|
61
|
-
}
|
|
62
|
-
/** Render a valid one-shot launchd plist. */
|
|
63
|
-
export function renderLaunchdPlist(input) {
|
|
64
|
-
const label = launchdLabelForId(input.id);
|
|
65
|
-
const cal = startCalendarIntervalFromIso(input.runAtIso);
|
|
66
|
-
const env = bakedEnvEntries(bakedEnvFromCreateInput(input));
|
|
67
|
-
// The OS unit runs the Node execution shim (trigger invocation), which then
|
|
68
|
-
// spawns the stored agent invocation and records run-history events.
|
|
69
|
-
const programArgs = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
|
|
70
|
-
.map((arg) => ` <string>${xmlEscape(arg)}</string>`)
|
|
71
|
-
.join("\n");
|
|
72
|
-
const envEntries = env
|
|
73
|
-
.map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`)
|
|
74
|
-
.join("\n");
|
|
75
|
-
// No run-at-load key (defense-in-depth: avoids an immediate run on agent
|
|
76
|
-
// reload). This does NOT stop a missed StartCalendarInterval from firing on
|
|
77
|
-
// wake — staleness is enforced by `/full-automation --scheduled-at` (Phase C).
|
|
78
|
-
return [
|
|
79
|
-
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
80
|
-
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
81
|
-
'<plist version="1.0">',
|
|
82
|
-
"<dict>",
|
|
83
|
-
" <key>Label</key>",
|
|
84
|
-
` <string>${xmlEscape(label)}</string>`,
|
|
85
|
-
" <key>StartCalendarInterval</key>",
|
|
86
|
-
" <dict>",
|
|
87
|
-
` <key>Year</key><integer>${cal.Year}</integer>`,
|
|
88
|
-
` <key>Month</key><integer>${cal.Month}</integer>`,
|
|
89
|
-
` <key>Day</key><integer>${cal.Day}</integer>`,
|
|
90
|
-
` <key>Hour</key><integer>${cal.Hour}</integer>`,
|
|
91
|
-
` <key>Minute</key><integer>${cal.Minute}</integer>`,
|
|
92
|
-
" </dict>",
|
|
93
|
-
" <key>EnvironmentVariables</key>",
|
|
94
|
-
" <dict>",
|
|
95
|
-
envEntries,
|
|
96
|
-
" </dict>",
|
|
97
|
-
" <key>ProgramArguments</key>",
|
|
98
|
-
" <array>",
|
|
99
|
-
programArgs,
|
|
100
|
-
" </array>",
|
|
101
|
-
" <key>WorkingDirectory</key>",
|
|
102
|
-
` <string>${xmlEscape(input.repoPath)}</string>`,
|
|
103
|
-
" <key>StandardOutPath</key>",
|
|
104
|
-
` <string>${xmlEscape(input.paths.stdoutPath)}</string>`,
|
|
105
|
-
" <key>StandardErrorPath</key>",
|
|
106
|
-
` <string>${xmlEscape(input.paths.stderrPath)}</string>`,
|
|
107
|
-
"</dict>",
|
|
108
|
-
"</plist>",
|
|
109
|
-
"",
|
|
110
|
-
].join("\n");
|
|
111
|
-
}
|
|
112
|
-
/** Create the macOS launchd scheduler backend. */
|
|
113
|
-
export function createLaunchdBackend() {
|
|
114
|
-
return {
|
|
115
|
-
name: "launchd",
|
|
116
|
-
async isAvailable(deps) {
|
|
117
|
-
return deps.platform === "darwin";
|
|
118
|
-
},
|
|
119
|
-
async create(input) {
|
|
120
|
-
const plistPath = launchdPlistPathForId(input.id, input.deps.homeDir);
|
|
121
|
-
const content = renderLaunchdPlist(input);
|
|
122
|
-
const artifact = { path: plistPath, content, kind: "launchd-plist" };
|
|
123
|
-
if (input.dryRun) {
|
|
124
|
-
return {
|
|
125
|
-
ok: true,
|
|
126
|
-
backend: "launchd",
|
|
127
|
-
unitPath: plistPath,
|
|
128
|
-
unitPaths: [plistPath],
|
|
129
|
-
backendJobId: null,
|
|
130
|
-
artifacts: [artifact],
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
// ~/Library/LaunchAgents may not exist yet for a fresh user account.
|
|
134
|
-
await fs.mkdir(pathApiForPlatform("darwin").dirname(plistPath), { recursive: true });
|
|
135
|
-
await fs.writeFile(plistPath, content, "utf-8");
|
|
136
|
-
const uid = resolveUid(input.deps);
|
|
137
|
-
const result = await input.deps.runCommand("launchctl", [
|
|
138
|
-
"bootstrap",
|
|
139
|
-
`gui/${uid}`,
|
|
140
|
-
plistPath,
|
|
141
|
-
]);
|
|
142
|
-
if (result.exitCode !== 0) {
|
|
143
|
-
// Don't leave an orphaned plist behind when bootstrap fails.
|
|
144
|
-
await fs.unlink(plistPath).catch(() => undefined);
|
|
145
|
-
return {
|
|
146
|
-
ok: false,
|
|
147
|
-
backend: "launchd",
|
|
148
|
-
unitPath: plistPath,
|
|
149
|
-
unitPaths: [plistPath],
|
|
150
|
-
backendJobId: null,
|
|
151
|
-
artifacts: [artifact],
|
|
152
|
-
error: `launchctl bootstrap failed: ${(result.stderr || result.stdout).trim()}`,
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
return {
|
|
156
|
-
ok: true,
|
|
157
|
-
backend: "launchd",
|
|
158
|
-
unitPath: plistPath,
|
|
159
|
-
unitPaths: [plistPath],
|
|
160
|
-
backendJobId: null,
|
|
161
|
-
artifacts: [artifact],
|
|
162
|
-
};
|
|
163
|
-
},
|
|
164
|
-
async list(input) {
|
|
165
|
-
const uid = resolveUid(input.deps);
|
|
166
|
-
const entries = [];
|
|
167
|
-
for (const metadata of input.recorded) {
|
|
168
|
-
const plistPath = metadata.unit_path ?? launchdPlistPathForId(metadata.id, input.deps.homeDir);
|
|
169
|
-
let exists = true;
|
|
170
|
-
try {
|
|
171
|
-
await fs.access(plistPath);
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
exists = false;
|
|
175
|
-
}
|
|
176
|
-
if (!exists) {
|
|
177
|
-
entries.push({ metadata, status: "stale", detail: "plist missing" });
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
const label = launchdLabelForMetadata(metadata);
|
|
181
|
-
const printed = await input.deps.runCommand("launchctl", ["print", `gui/${uid}/${label}`]);
|
|
182
|
-
entries.push({
|
|
183
|
-
metadata,
|
|
184
|
-
status: printed.exitCode === 0 ? "active" : "stale",
|
|
185
|
-
detail: printed.exitCode === 0 ? "loaded" : "plist present but not loaded",
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
return entries;
|
|
189
|
-
},
|
|
190
|
-
async cancel(input) {
|
|
191
|
-
const uid = resolveUid(input.deps);
|
|
192
|
-
const label = launchdLabelForMetadata(input.metadata);
|
|
193
|
-
const plistPath = input.metadata.unit_path ?? launchdPlistPathForId(input.metadata.id, input.deps.homeDir);
|
|
194
|
-
let plistExisted = true;
|
|
195
|
-
try {
|
|
196
|
-
await fs.access(plistPath);
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
plistExisted = false;
|
|
200
|
-
}
|
|
201
|
-
// bootout is best-effort: an already-unloaded label returns non-zero, which
|
|
202
|
-
// is treated as stale-but-cancelable rather than a hard failure.
|
|
203
|
-
const bootout = await input.deps.runCommand("launchctl", [
|
|
204
|
-
"bootout",
|
|
205
|
-
`gui/${uid}/${label}`,
|
|
206
|
-
]);
|
|
207
|
-
if (plistExisted) {
|
|
208
|
-
try {
|
|
209
|
-
await fs.unlink(plistPath);
|
|
210
|
-
}
|
|
211
|
-
catch (error) {
|
|
212
|
-
if (error.code !== "ENOENT") {
|
|
213
|
-
return { ok: false, nativeRemoved: false, stale: false, error: String(error) };
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
const nativeRemoved = bootout.exitCode === 0;
|
|
218
|
-
return {
|
|
219
|
-
ok: true,
|
|
220
|
-
nativeRemoved,
|
|
221
|
-
stale: !plistExisted || !nativeRemoved,
|
|
222
|
-
};
|
|
223
|
-
},
|
|
224
|
-
};
|
|
225
|
-
}
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Linux systemd-user backend (BAPI-327, primary Linux scheduler).
|
|
3
|
-
*
|
|
4
|
-
* Generates a `.service` + `.timer` pair under `~/.config/systemd/user/` and
|
|
5
|
-
* installs them with `systemctl --user`. The timer fires once via
|
|
6
|
-
* `OnCalendar=<run_at_iso>`.
|
|
7
|
-
*
|
|
8
|
-
* Drift policy: `Persistent=false` gives a real OS-level no-catch-up guarantee —
|
|
9
|
-
* a run missed during downtime is dropped, not fired late on next boot/login.
|
|
10
|
-
* (launchd and `at` cannot guarantee this; the cross-backend staleness guard is
|
|
11
|
-
* the baked `--scheduled-at <T>` value checked by `/full-automation` in Phase C.)
|
|
12
|
-
* Working directory is set by the service's `WorkingDirectory=` directive, never
|
|
13
|
-
* via a shell `cd` in ExecStart and never a Claude `--cwd` flag.
|
|
14
|
-
*/
|
|
15
|
-
import { promises as fs } from "node:fs";
|
|
16
|
-
import { pathApiForPlatform } from "./types.js";
|
|
17
|
-
import { bakedEnvFromCreateInput, bakedEnvEntries, systemdQuote } from "./escaping.js";
|
|
18
|
-
/** Return the unit names for a NEW schedule id (neutral schedule-run naming). */
|
|
19
|
-
export function systemdUnitNamesForId(id) {
|
|
20
|
-
return {
|
|
21
|
-
service: `bridge-gpt-schedule-run-${id}.service`,
|
|
22
|
-
timer: `bridge-gpt-schedule-run-${id}.timer`,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
/** Return the unit file paths for a schedule id. */
|
|
26
|
-
export function systemdUnitPathsForId(id, homeDir) {
|
|
27
|
-
const pathApi = pathApiForPlatform("linux");
|
|
28
|
-
const dir = pathApi.join(homeDir, ".config", "systemd", "user");
|
|
29
|
-
const names = systemdUnitNamesForId(id);
|
|
30
|
-
return {
|
|
31
|
-
service: pathApi.join(dir, names.service),
|
|
32
|
-
timer: pathApi.join(dir, names.timer),
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Resolve unit names + paths for an EXISTING schedule, preferring the names baked
|
|
37
|
-
* into the recorded unit paths so legacy `bridge-gpt-full-automation-<id>` units
|
|
38
|
-
* remain list/cancel compatible after the neutral-naming change.
|
|
39
|
-
*/
|
|
40
|
-
export function systemdUnitsForMetadata(metadata, homeDir) {
|
|
41
|
-
const timerPath = metadata.unit_path && metadata.unit_path.endsWith(".timer") ? metadata.unit_path : undefined;
|
|
42
|
-
const servicePath = (metadata.unit_paths ?? []).find((p) => p.endsWith(".service"));
|
|
43
|
-
if (timerPath && servicePath) {
|
|
44
|
-
const basename = (p) => p.split(/[\\/]/).pop();
|
|
45
|
-
return {
|
|
46
|
-
names: { service: basename(servicePath), timer: basename(timerPath) },
|
|
47
|
-
paths: { service: servicePath, timer: timerPath },
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
return {
|
|
51
|
-
names: systemdUnitNamesForId(metadata.id),
|
|
52
|
-
paths: systemdUnitPathsForId(metadata.id, homeDir),
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
/** Render the systemd service unit. */
|
|
56
|
-
export function renderSystemdService(input) {
|
|
57
|
-
const env = bakedEnvEntries(bakedEnvFromCreateInput(input));
|
|
58
|
-
const envLines = env
|
|
59
|
-
.map(([key, value]) => `Environment=${key}=${systemdQuote(value)}`)
|
|
60
|
-
.join("\n");
|
|
61
|
-
// ExecStart runs the Node execution shim (trigger invocation); the shim spawns
|
|
62
|
-
// the stored agent invocation and records run-history events.
|
|
63
|
-
const execStart = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
|
|
64
|
-
.map((part) => systemdQuote(part))
|
|
65
|
-
.join(" ");
|
|
66
|
-
return [
|
|
67
|
-
"[Unit]",
|
|
68
|
-
`Description=Bridge GPT schedule-run one-shot (${input.id}: ${input.command})`,
|
|
69
|
-
"",
|
|
70
|
-
"[Service]",
|
|
71
|
-
"Type=oneshot",
|
|
72
|
-
envLines,
|
|
73
|
-
`WorkingDirectory=${systemdQuote(input.repoPath)}`,
|
|
74
|
-
`ExecStart=${execStart}`,
|
|
75
|
-
`StandardOutput=append:${input.paths.stdoutPath}`,
|
|
76
|
-
`StandardError=append:${input.paths.stderrPath}`,
|
|
77
|
-
"",
|
|
78
|
-
].join("\n");
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Render an `OnCalendar` value systemd actually accepts. systemd's calendar
|
|
82
|
-
* grammar is `YYYY-MM-DD HH:MM:SS` with an optional timezone token — it does NOT
|
|
83
|
-
* accept the ISO `T` separator, fractional seconds, or a bare `Z`, so emitting
|
|
84
|
-
* `run_at_iso` verbatim makes `systemd-analyze calendar` reject the unit and the
|
|
85
|
-
* timer silently never fires. Render from the UTC fields (host-timezone
|
|
86
|
-
* independent) and append the `UTC` token.
|
|
87
|
-
*/
|
|
88
|
-
export function formatSystemdOnCalendar(runAtIso) {
|
|
89
|
-
const d = new Date(runAtIso);
|
|
90
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
91
|
-
const date = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
|
|
92
|
-
const time = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
|
93
|
-
return `${date} ${time} UTC`;
|
|
94
|
-
}
|
|
95
|
-
/** Render the systemd timer unit. */
|
|
96
|
-
export function renderSystemdTimer(input) {
|
|
97
|
-
const names = systemdUnitNamesForId(input.id);
|
|
98
|
-
return [
|
|
99
|
-
"[Unit]",
|
|
100
|
-
`Description=Bridge GPT schedule-run one-shot timer (${input.id}: ${input.command})`,
|
|
101
|
-
"",
|
|
102
|
-
"[Timer]",
|
|
103
|
-
`OnCalendar=${formatSystemdOnCalendar(input.runAtIso)}`,
|
|
104
|
-
// Drift policy: a missed run must NOT fire late on next boot.
|
|
105
|
-
"Persistent=false",
|
|
106
|
-
`Unit=${names.service}`,
|
|
107
|
-
"",
|
|
108
|
-
"[Install]",
|
|
109
|
-
"WantedBy=timers.target",
|
|
110
|
-
"",
|
|
111
|
-
].join("\n");
|
|
112
|
-
}
|
|
113
|
-
/** Whether `systemctl --user` is usable (used by isAvailable). */
|
|
114
|
-
async function systemctlUserUsable(deps) {
|
|
115
|
-
const probe = await deps.runCommand("systemctl", ["--user", "--version"]);
|
|
116
|
-
return probe.exitCode === 0;
|
|
117
|
-
}
|
|
118
|
-
/** Create the Linux systemd-user backend. */
|
|
119
|
-
export function createSystemdUserBackend() {
|
|
120
|
-
return {
|
|
121
|
-
name: "systemd-user",
|
|
122
|
-
async isAvailable(deps) {
|
|
123
|
-
if (deps.platform !== "linux")
|
|
124
|
-
return false;
|
|
125
|
-
return systemctlUserUsable(deps);
|
|
126
|
-
},
|
|
127
|
-
async create(input) {
|
|
128
|
-
const paths = systemdUnitPathsForId(input.id, input.deps.homeDir);
|
|
129
|
-
const names = systemdUnitNamesForId(input.id);
|
|
130
|
-
const serviceContent = renderSystemdService(input);
|
|
131
|
-
const timerContent = renderSystemdTimer(input);
|
|
132
|
-
const artifacts = [
|
|
133
|
-
{ path: paths.service, content: serviceContent, kind: "systemd-service" },
|
|
134
|
-
{ path: paths.timer, content: timerContent, kind: "systemd-timer" },
|
|
135
|
-
];
|
|
136
|
-
const unitPaths = [paths.service, paths.timer];
|
|
137
|
-
if (input.dryRun) {
|
|
138
|
-
return {
|
|
139
|
-
ok: true,
|
|
140
|
-
backend: "systemd-user",
|
|
141
|
-
unitPath: paths.timer,
|
|
142
|
-
unitPaths,
|
|
143
|
-
backendJobId: names.timer,
|
|
144
|
-
artifacts,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
const pathApi = pathApiForPlatform("linux");
|
|
148
|
-
await fs.mkdir(pathApi.dirname(paths.service), { recursive: true });
|
|
149
|
-
await fs.writeFile(paths.service, serviceContent, "utf-8");
|
|
150
|
-
await fs.writeFile(paths.timer, timerContent, "utf-8");
|
|
151
|
-
// Remove the orphaned unit files if a systemctl step fails.
|
|
152
|
-
const cleanupUnitFiles = async () => {
|
|
153
|
-
await fs.unlink(paths.timer).catch(() => undefined);
|
|
154
|
-
await fs.unlink(paths.service).catch(() => undefined);
|
|
155
|
-
};
|
|
156
|
-
const reload = await input.deps.runCommand("systemctl", ["--user", "daemon-reload"]);
|
|
157
|
-
if (reload.exitCode !== 0) {
|
|
158
|
-
await cleanupUnitFiles();
|
|
159
|
-
return {
|
|
160
|
-
ok: false,
|
|
161
|
-
backend: "systemd-user",
|
|
162
|
-
unitPath: paths.timer,
|
|
163
|
-
unitPaths,
|
|
164
|
-
backendJobId: names.timer,
|
|
165
|
-
artifacts,
|
|
166
|
-
error: `systemctl --user daemon-reload failed: ${(reload.stderr || reload.stdout).trim()}`,
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
const enable = await input.deps.runCommand("systemctl", [
|
|
170
|
-
"--user",
|
|
171
|
-
"enable",
|
|
172
|
-
"--now",
|
|
173
|
-
names.timer,
|
|
174
|
-
]);
|
|
175
|
-
if (enable.exitCode !== 0) {
|
|
176
|
-
await cleanupUnitFiles();
|
|
177
|
-
return {
|
|
178
|
-
ok: false,
|
|
179
|
-
backend: "systemd-user",
|
|
180
|
-
unitPath: paths.timer,
|
|
181
|
-
unitPaths,
|
|
182
|
-
backendJobId: names.timer,
|
|
183
|
-
artifacts,
|
|
184
|
-
error: `systemctl --user enable --now failed: ${(enable.stderr || enable.stdout).trim()}`,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
return {
|
|
188
|
-
ok: true,
|
|
189
|
-
backend: "systemd-user",
|
|
190
|
-
unitPath: paths.timer,
|
|
191
|
-
unitPaths,
|
|
192
|
-
backendJobId: names.timer,
|
|
193
|
-
artifacts,
|
|
194
|
-
};
|
|
195
|
-
},
|
|
196
|
-
async list(input) {
|
|
197
|
-
const entries = [];
|
|
198
|
-
for (const metadata of input.recorded) {
|
|
199
|
-
const { paths, names } = systemdUnitsForMetadata(metadata, input.deps.homeDir);
|
|
200
|
-
let timerExists = true;
|
|
201
|
-
try {
|
|
202
|
-
await fs.access(paths.timer);
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
timerExists = false;
|
|
206
|
-
}
|
|
207
|
-
if (!timerExists) {
|
|
208
|
-
entries.push({ metadata, status: "stale", detail: "timer unit file missing" });
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
const status = await input.deps.runCommand("systemctl", ["--user", "status", names.timer]);
|
|
212
|
-
if (status.exitCode === 0) {
|
|
213
|
-
entries.push({ metadata, status: "active", detail: "timer active" });
|
|
214
|
-
}
|
|
215
|
-
else if (status.exitCode === 3) {
|
|
216
|
-
// exit 3 = unit known but inactive; still registered, treat as stale.
|
|
217
|
-
entries.push({ metadata, status: "stale", detail: "timer inactive" });
|
|
218
|
-
}
|
|
219
|
-
else {
|
|
220
|
-
entries.push({ metadata, status: "backend-unavailable", detail: "systemctl unavailable" });
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return entries;
|
|
224
|
-
},
|
|
225
|
-
async cancel(input) {
|
|
226
|
-
const { paths, names } = systemdUnitsForMetadata(input.metadata, input.deps.homeDir);
|
|
227
|
-
const disable = await input.deps.runCommand("systemctl", [
|
|
228
|
-
"--user",
|
|
229
|
-
"disable",
|
|
230
|
-
"--now",
|
|
231
|
-
names.timer,
|
|
232
|
-
]);
|
|
233
|
-
let removedAny = false;
|
|
234
|
-
for (const unitPath of [paths.timer, paths.service]) {
|
|
235
|
-
try {
|
|
236
|
-
await fs.unlink(unitPath);
|
|
237
|
-
removedAny = true;
|
|
238
|
-
}
|
|
239
|
-
catch (error) {
|
|
240
|
-
if (error.code !== "ENOENT") {
|
|
241
|
-
return { ok: false, nativeRemoved: false, stale: false, error: String(error) };
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
await input.deps.runCommand("systemctl", ["--user", "daemon-reload"]);
|
|
246
|
-
const nativeRemoved = disable.exitCode === 0 || removedAny;
|
|
247
|
-
return { ok: true, nativeRemoved, stale: disable.exitCode !== 0 };
|
|
248
|
-
},
|
|
249
|
-
};
|
|
250
|
-
}
|