@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.
Files changed (62) hide show
  1. package/README.md +25 -8
  2. package/build/base-ref.js +28 -3
  3. package/build/claude-review-workflow-drift-probe.js +130 -0
  4. package/build/claude-review-workflow-drift.js +173 -0
  5. package/build/claude-review-workflow.js +81 -16
  6. package/build/commands.generated.js +5 -5
  7. package/build/conduct-epic/bridge-client.js +115 -1
  8. package/build/conduct-epic/cli.js +351 -33
  9. package/build/conduct-epic/cut-protocol.js +51 -0
  10. package/build/conductor/done-gate.js +25 -3
  11. package/build/conductor/install-doctor.js +65 -5
  12. package/build/conductor/latest-check-selector.js +170 -0
  13. package/build/conductor/local-merge.js +8 -6
  14. package/build/conductor-bin.js +1 -1
  15. package/build/{brainstorm-files.js → council-files.js} +15 -15
  16. package/build/decision-page-schema.js +1 -1
  17. package/build/docs.generated.js +1 -1
  18. package/build/doctor.js +352 -4
  19. package/build/epic-integration-pr.js +280 -0
  20. package/build/executor/job-runner.js +7 -1
  21. package/build/executor/merge-job.js +46 -1
  22. package/build/executor/worktree.js +46 -1
  23. package/build/index.js +153 -65
  24. package/build/init.js +9 -2
  25. package/build/install-bridge.js +60 -2
  26. package/build/install-reexec.js +47 -9
  27. package/build/pipelines.generated.js +8 -2
  28. package/build/plan-epic-conductor-eligibility.js +183 -0
  29. package/build/plane/cli.js +12 -2
  30. package/build/plane/manifest.js +25 -1
  31. package/build/plane/member-roster.js +61 -7
  32. package/build/plane/preflight.js +24 -9
  33. package/build/plane/supervisor.js +77 -5
  34. package/build/plane/types.js +23 -3
  35. package/build/readme.generated.js +1 -1
  36. package/build/run-unit-tests-launcher.js +2 -1
  37. package/build/setup-epic.js +32 -0
  38. package/build/sfcc/reads-custom-object-def.js +10 -13
  39. package/build/sfcc/reads-site-preference.js +5 -5
  40. package/build/sfcc/reads-system-object.js +4 -4
  41. package/build/sfcc/writes-custom-object-def.js +7 -7
  42. package/build/sfcc/writes-site-preference.js +4 -3
  43. package/build/sfcc/writes-system-object.js +7 -6
  44. package/build/stale-worktree-doctor.js +120 -0
  45. package/build/start-tickets-prereqs.js +70 -0
  46. package/build/start-tickets.js +91 -3
  47. package/build/version.generated.js +3 -2
  48. package/package.json +6 -3
  49. package/pipelines/plan-epic.json +5 -0
  50. package/build/chain-orchestrator.js +0 -1457
  51. package/build/chain-utils.js +0 -68
  52. package/build/command-catalog.js +0 -376
  53. package/build/schedule-run.js +0 -1300
  54. package/build/schedule-store.js +0 -172
  55. package/build/scheduled-prompt.js +0 -115
  56. package/build/scheduler-backends/at-fallback.js +0 -139
  57. package/build/scheduler-backends/escaping.js +0 -143
  58. package/build/scheduler-backends/index.js +0 -72
  59. package/build/scheduler-backends/launchd.js +0 -225
  60. package/build/scheduler-backends/systemd-user.js +0 -250
  61. package/build/scheduler-backends/task-scheduler.js +0 -214
  62. package/build/scheduler-backends/types.js +0 -23
@@ -1,172 +0,0 @@
1
- /**
2
- * Local-only schedule metadata storage (BAPI-327).
3
- *
4
- * Schedules live entirely under `~/.bridge-gpt/schedules/` on the user's machine
5
- * and are NEVER mirrored to the Bridge server. One pretty-printed JSON file per
6
- * schedule id holds the metadata; stdout/stderr land in `logs/<id>.{out,err}`.
7
- *
8
- * All paths are rooted at the injected `homeDir` (never `os.homedir()` directly)
9
- * and built with the platform path API, so the store is fully unit-testable for
10
- * any OS from any host and never touches the real home directory in tests.
11
- */
12
- import { promises as fs } from "node:fs";
13
- import { pathApiForPlatform } from "./scheduler-backends/types.js";
14
- /** Upper bound on retained run-history events per schedule (oldest dropped). */
15
- export const MAX_RUN_HISTORY_EVENTS = 50;
16
- /** Monotonic suffix source for atomic temp files (avoids same-tick collisions). */
17
- let atomicWriteCounter = 0;
18
- /**
19
- * Build the schedule root from the injected home directory and platform path
20
- * API: `~/.bridge-gpt/schedules` on POSIX, `%USERPROFILE%\.bridge-gpt\schedules`
21
- * on Windows.
22
- */
23
- export function getScheduleRoot(homeDir, platform) {
24
- const pathApi = pathApiForPlatform(platform);
25
- return pathApi.join(homeDir, ".bridge-gpt", "schedules");
26
- }
27
- /** Return the metadata + log paths for a schedule id under the schedule root. */
28
- export function getSchedulePaths(id, homeDir, platform) {
29
- const pathApi = pathApiForPlatform(platform);
30
- const schedulesDir = getScheduleRoot(homeDir, platform);
31
- const logsDir = pathApi.join(schedulesDir, "logs");
32
- return {
33
- schedulesDir,
34
- logsDir,
35
- metadataPath: pathApi.join(schedulesDir, `${id}.json`),
36
- stdoutPath: pathApi.join(logsDir, `${id}.out`),
37
- stderrPath: pathApi.join(logsDir, `${id}.err`),
38
- };
39
- }
40
- /**
41
- * Create only the schedules directory and the logs directory. Must NOT be called
42
- * by dry-run code paths — dry-run never touches the filesystem.
43
- */
44
- export async function ensureScheduleDirectories(homeDir, platform) {
45
- const pathApi = pathApiForPlatform(platform);
46
- const schedulesDir = getScheduleRoot(homeDir, platform);
47
- const logsDir = pathApi.join(schedulesDir, "logs");
48
- await fs.mkdir(schedulesDir, { recursive: true });
49
- await fs.mkdir(logsDir, { recursive: true });
50
- }
51
- /**
52
- * Persist one pretty-printed JSON file per schedule id with a trailing newline.
53
- * Called only AFTER the scheduler backend successfully creates the unit/job.
54
- *
55
- * The write is atomic (BAPI-351): the content is written to a temp file in the
56
- * same directory and renamed into place, so a concurrent `_execute` run-history
57
- * append never observes a partially-written metadata file, and a crash mid-write
58
- * leaves the prior valid file intact rather than a truncated one.
59
- */
60
- export async function writeScheduleMetadata(metadata, homeDir, platform) {
61
- const { metadataPath } = getSchedulePaths(metadata.id, homeDir, platform);
62
- const content = `${JSON.stringify(metadata, null, 2)}\n`;
63
- const tmpPath = `${metadataPath}.tmp-${process.pid}-${atomicWriteCounter++}`;
64
- try {
65
- await fs.writeFile(tmpPath, content, "utf-8");
66
- await fs.rename(tmpPath, metadataPath);
67
- }
68
- catch (error) {
69
- // Best-effort cleanup of the temp file; never leave it orphaned, and never
70
- // partially overwrite the real metadata file.
71
- await fs.unlink(tmpPath).catch(() => undefined);
72
- throw error;
73
- }
74
- }
75
- /**
76
- * Append a bounded run-history event to a schedule's metadata and rewrite it
77
- * atomically (BAPI-351). Tolerant of legacy records that have no `run_history`
78
- * (initializes it). Returns the updated metadata, or `null` if the schedule
79
- * metadata no longer exists.
80
- */
81
- export async function appendScheduleRunEvent(id, event, homeDir, platform) {
82
- const metadata = await readScheduleMetadata(id, homeDir, platform);
83
- if (!metadata)
84
- return null;
85
- const history = Array.isArray(metadata.run_history) ? [...metadata.run_history] : [];
86
- history.push(event);
87
- // Keep the newest events when bounding (drop from the front).
88
- metadata.run_history =
89
- history.length > MAX_RUN_HISTORY_EVENTS
90
- ? history.slice(history.length - MAX_RUN_HISTORY_EVENTS)
91
- : history;
92
- await writeScheduleMetadata(metadata, homeDir, platform);
93
- return metadata;
94
- }
95
- /** Read and parse a single schedule metadata JSON file; `null` when missing. */
96
- export async function readScheduleMetadata(id, homeDir, platform) {
97
- const { metadataPath } = getSchedulePaths(id, homeDir, platform);
98
- let raw;
99
- try {
100
- raw = await fs.readFile(metadataPath, "utf-8");
101
- }
102
- catch (error) {
103
- if (error.code === "ENOENT")
104
- return null;
105
- throw error;
106
- }
107
- return JSON.parse(raw);
108
- }
109
- /**
110
- * Read every `*.json` metadata file from the schedule root. Malformed JSON is
111
- * surfaced as a controlled parse-error row (never silently swallowed); a missing
112
- * schedule directory yields an empty list, but any other filesystem error
113
- * (e.g. EACCES) propagates rather than masquerading as "no schedules".
114
- */
115
- export async function listScheduleMetadata(homeDir, platform) {
116
- const pathApi = pathApiForPlatform(platform);
117
- const schedulesDir = getScheduleRoot(homeDir, platform);
118
- let entries;
119
- try {
120
- entries = await fs.readdir(schedulesDir);
121
- }
122
- catch (error) {
123
- if (error.code === "ENOENT")
124
- return [];
125
- throw error;
126
- }
127
- const rows = [];
128
- for (const entry of entries.sort()) {
129
- if (!entry.endsWith(".json"))
130
- continue;
131
- const id = entry.slice(0, -".json".length);
132
- const fullPath = pathApi.join(schedulesDir, entry);
133
- let raw;
134
- try {
135
- raw = await fs.readFile(fullPath, "utf-8");
136
- }
137
- catch (error) {
138
- if (error.code === "ENOENT")
139
- continue; // raced delete
140
- throw error;
141
- }
142
- try {
143
- const metadata = JSON.parse(raw);
144
- rows.push({ ok: true, id, metadata, path: fullPath });
145
- }
146
- catch (error) {
147
- rows.push({
148
- ok: false,
149
- id,
150
- path: fullPath,
151
- error: error instanceof Error ? error.message : String(error),
152
- });
153
- }
154
- }
155
- return rows;
156
- }
157
- /**
158
- * Delete the schedule metadata JSON file after a successful or stale cancel. The
159
- * local log files (`logs/<id>.out|.err`) are deliberately left in place for
160
- * post-run inspection.
161
- */
162
- export async function deleteScheduleMetadata(id, homeDir, platform) {
163
- const { metadataPath } = getSchedulePaths(id, homeDir, platform);
164
- try {
165
- await fs.unlink(metadataPath);
166
- }
167
- catch (error) {
168
- if (error.code === "ENOENT")
169
- return;
170
- throw error;
171
- }
172
- }
@@ -1,115 +0,0 @@
1
- import { schemaSupportsAutoFlag } from "./command-catalog.js";
2
- /** The fixed late-fire threshold, in seconds, shared by every scheduled run. */
3
- export const LATE_FIRE_THRESHOLD_SECONDS = 60;
4
- /**
5
- * Quote a single argument token for safe inclusion in the rendered prompt while
6
- * preserving its boundary. Simple tokens (alphanumerics plus a small set of
7
- * path/flag-safe punctuation) are left bare; anything containing whitespace or
8
- * prompt/markup-sensitive characters is wrapped in double quotes with embedded
9
- * double quotes escaped. This is prompt-token quoting, NOT shell quoting — no
10
- * shell ever sees these strings.
11
- */
12
- export function quotePromptToken(token) {
13
- if (token === "")
14
- return '""';
15
- // Safe: letters, digits, and characters that never need quoting in a prompt
16
- // (path separators, ISO-timestamp punctuation, flag dashes, etc.).
17
- if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(token))
18
- return token;
19
- return `"${token.replace(/"/g, '\\"')}"`;
20
- }
21
- /**
22
- * Compute the augmented argv tokens the scheduled run delegates to the command:
23
- * the normalized `input.args`, plus (only where the command can parse them)
24
- * `--scheduled-at <ISO>` and `--auto`.
25
- *
26
- * `--scheduled-at` is appended for `full-automation` (legacy) and `epic-tick`
27
- * (BAPI-418), both of whose parsers accept it as a first-class argument. Every
28
- * other command rejects unrecognized flags and halts (e.g. `start-tickets` stops
29
- * on any unsupported flag), and the shared late-fire gate already embeds the
30
- * scheduled time — so injecting `--scheduled-at` into their argv would break an
31
- * otherwise `schedulable: true` command for no benefit.
32
- *
33
- * `--auto` is appended when auto-approve is set AND the command supports it (its
34
- * schema declares a boolean `--auto` flag, or it is `full-automation` / `epic-tick`).
35
- * It is never duplicated if already present in `input.args`.
36
- *
37
- * This is the SINGLE source of the delegated argv: both the rendered target
38
- * command line and the `$ARGUMENTS` body substitution derive from it, so the body
39
- * parse and the command line never disagree (e.g. `review-ticket`, which reads
40
- * `--auto` out of `$ARGUMENTS`, sees the same `--auto` the command line shows).
41
- */
42
- export function buildAugmentedArgs(input) {
43
- const args = [...input.args];
44
- // Commands whose parsers accept --scheduled-at as a first-class argument.
45
- // epic-tick already accepts --scheduled-at (parseEpicTickArgs, cli.ts) so it
46
- // receives the scheduled time as a structured arg for the late-fire decision,
47
- // not just via the embedded gate text.
48
- if (input.commandName === "full-automation" || input.commandName === "epic-tick") {
49
- args.push("--scheduled-at", input.scheduledAt);
50
- }
51
- const supportsAuto = schemaSupportsAutoFlag(input.schema) ||
52
- input.commandName === "full-automation" ||
53
- input.commandName === "epic-tick";
54
- const alreadyHasAuto = input.args.includes("--auto");
55
- if (input.autoApprove && supportsAuto && !alreadyHasAuto) {
56
- args.push("--auto");
57
- }
58
- return args;
59
- }
60
- /**
61
- * Render the delegated target command line as
62
- * `/<commandName> <augmented args...>` (see {@link buildAugmentedArgs}).
63
- */
64
- export function buildTargetCommandLine(input) {
65
- const parts = [`/${input.commandName}`];
66
- for (const arg of buildAugmentedArgs(input))
67
- parts.push(quotePromptToken(arg));
68
- return parts.join(" ");
69
- }
70
- /** Render only the fixed late-fire gate text (exposed for focused testing). */
71
- export function renderLateFireGate(scheduleId, scheduledAt, autoApprove) {
72
- const lateAction = autoApprove
73
- ? "explain that the run is firing late and then PROCEED with the command below (this schedule was created with auto-approve)."
74
- : "explain that the run is firing late and then HALT WITHOUT EXECUTING the command below (this schedule was created without auto-approve, and a headless run must not proceed unconfirmed).";
75
- return [
76
- "## Scheduled-run drift gate",
77
- "",
78
- `This is an automated, headless scheduled run (schedule id: ${scheduleId}).`,
79
- `It was scheduled to fire at ${scheduledAt}.`,
80
- "",
81
- `Before doing anything else, check how late this run is firing. If it is more than ${LATE_FIRE_THRESHOLD_SECONDS} seconds later than the scheduled time above, ${lateAction}`,
82
- `If it is within ${LATE_FIRE_THRESHOLD_SECONDS} seconds of the scheduled time, proceed silently.`,
83
- "",
84
- "Then run the following command exactly as written:",
85
- ].join("\n");
86
- }
87
- /**
88
- * Replace `$ARGUMENTS` in a command body with the safely-quoted, normalized
89
- * argument string. Every occurrence is replaced (commands often repeat the
90
- * placeholder in a title and a body line).
91
- */
92
- function replaceArgumentsPlaceholder(body, argString) {
93
- return body.split("$ARGUMENTS").join(argString);
94
- }
95
- /**
96
- * Render the full scheduled-run prompt: gate + delegated command line + command
97
- * body (with `$ARGUMENTS` substituted). Deterministic for identical inputs.
98
- */
99
- export function renderScheduledPrompt(input) {
100
- const gate = renderLateFireGate(input.scheduleId, input.scheduledAt, input.autoApprove);
101
- const targetInput = {
102
- commandName: input.commandName,
103
- args: input.args,
104
- scheduledAt: input.scheduledAt,
105
- autoApprove: input.autoApprove,
106
- schema: input.schema,
107
- };
108
- const targetCommandLine = buildTargetCommandLine(targetInput);
109
- // `$ARGUMENTS` must match what the command line shows — including the appended
110
- // `--scheduled-at` / `--auto` — so a body that re-parses `$ARGUMENTS` (e.g.
111
- // review-ticket reading `--auto`) agrees with the rendered command line.
112
- const argString = buildAugmentedArgs(targetInput).map(quotePromptToken).join(" ");
113
- const body = replaceArgumentsPlaceholder(input.commandBody, argString);
114
- return [gate, "", targetCommandLine, "", body].join("\n");
115
- }
@@ -1,139 +0,0 @@
1
- import { bakedEnvFromCreateInput, formatEnvExportsForGeneratedUnit, posixShellQuote, } from "./escaping.js";
2
- /** Virtual artifact path for the script piped to `at` (never written to disk). */
3
- export const AT_STDIN_ARTIFACT_PATH = "<at-stdin>";
4
- /** Convert an ISO timestamp to the `YYYYMMDDHHMM` form required by `at -t`. */
5
- export function formatAtTimestamp(runAtIso) {
6
- const d = new Date(runAtIso);
7
- const pad = (n) => String(n).padStart(2, "0");
8
- return (`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
9
- `${pad(d.getHours())}${pad(d.getMinutes())}`);
10
- }
11
- /** Render the script piped into `at`. */
12
- export function renderAtScript(input) {
13
- const exports = formatEnvExportsForGeneratedUnit(bakedEnvFromCreateInput(input));
14
- // The script runs the Node execution shim (trigger invocation); the shim spawns
15
- // the stored agent invocation and records run-history events.
16
- const command = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
17
- .map((part) => posixShellQuote(part))
18
- .join(" ");
19
- return [
20
- "#!/bin/sh",
21
- exports,
22
- `cd ${posixShellQuote(input.repoPath)}`,
23
- `${command} >>${posixShellQuote(input.paths.stdoutPath)} 2>>${posixShellQuote(input.paths.stderrPath)}`,
24
- "",
25
- ].join("\n");
26
- }
27
- /**
28
- * Parse an `at` job id from common stdout/stderr forms; `null` when absent.
29
- * Anchored to the start of a line so a stray earlier "job" word can't be
30
- * mistaken for the job line, and (defensively, should `at` ever print more than
31
- * one) the LAST `job <id> at …` line wins.
32
- */
33
- export function parseAtJobId(stdout, stderr) {
34
- const combined = `${stdout}\n${stderr}`;
35
- const matches = [...combined.matchAll(/^job\s+(\d+)\s+at\b/gim)];
36
- return matches.length > 0 ? matches[matches.length - 1][1] : null;
37
- }
38
- /** Whether both `at` and `atq` resolve (used by isAvailable). */
39
- async function atToolsResolvable(deps) {
40
- const at = await deps.runCommand("which", ["at"]);
41
- if (at.exitCode !== 0)
42
- return false;
43
- const atq = await deps.runCommand("which", ["atq"]);
44
- return atq.exitCode === 0;
45
- }
46
- /** Create the Linux `at` fallback backend. */
47
- export function createAtFallbackBackend() {
48
- return {
49
- name: "at-fallback",
50
- async isAvailable(deps) {
51
- if (deps.platform !== "linux")
52
- return false;
53
- return atToolsResolvable(deps);
54
- },
55
- async create(input) {
56
- const script = renderAtScript(input);
57
- const artifact = {
58
- path: AT_STDIN_ARTIFACT_PATH,
59
- content: script,
60
- kind: "at-script",
61
- };
62
- if (input.dryRun) {
63
- return {
64
- ok: true,
65
- backend: "at-fallback",
66
- unitPath: null,
67
- unitPaths: [],
68
- backendJobId: null,
69
- artifacts: [artifact],
70
- };
71
- }
72
- const timestamp = formatAtTimestamp(input.runAtIso);
73
- const result = await input.deps.runCommand("at", ["-t", timestamp], { input: script });
74
- if (result.exitCode !== 0) {
75
- return {
76
- ok: false,
77
- backend: "at-fallback",
78
- unitPath: null,
79
- unitPaths: [],
80
- backendJobId: null,
81
- artifacts: [artifact],
82
- error: `at scheduling failed: ${(result.stderr || result.stdout).trim()}`,
83
- };
84
- }
85
- const jobId = parseAtJobId(result.stdout, result.stderr);
86
- if (!jobId) {
87
- return {
88
- ok: false,
89
- backend: "at-fallback",
90
- unitPath: null,
91
- unitPaths: [],
92
- backendJobId: null,
93
- artifacts: [artifact],
94
- error: "Could not parse an at job id from the scheduler output.",
95
- };
96
- }
97
- return {
98
- ok: true,
99
- backend: "at-fallback",
100
- unitPath: null,
101
- unitPaths: [],
102
- backendJobId: jobId,
103
- artifacts: [artifact],
104
- };
105
- },
106
- async list(input) {
107
- const atq = await input.deps.runCommand("atq", []);
108
- if (atq.exitCode !== 0) {
109
- return input.recorded.map((metadata) => ({
110
- metadata,
111
- status: "backend-unavailable",
112
- detail: "atq unavailable",
113
- }));
114
- }
115
- const liveJobIds = new Set(atq.stdout
116
- .split(/\r?\n/)
117
- .map((line) => line.trim().match(/^(\d+)\b/)?.[1])
118
- .filter((id) => Boolean(id)));
119
- return input.recorded.map((metadata) => {
120
- const jobId = metadata.backend_job_id;
121
- const active = jobId !== null && liveJobIds.has(jobId);
122
- return {
123
- metadata,
124
- status: active ? "active" : "stale",
125
- detail: active ? `at job ${jobId}` : "at job not queued",
126
- };
127
- });
128
- },
129
- async cancel(input) {
130
- const jobId = input.metadata.backend_job_id;
131
- if (!jobId) {
132
- return { ok: true, nativeRemoved: false, stale: true };
133
- }
134
- const result = await input.deps.runCommand("atrm", [jobId]);
135
- const nativeRemoved = result.exitCode === 0;
136
- return { ok: true, nativeRemoved, stale: !nativeRemoved };
137
- },
138
- };
139
- }
@@ -1,143 +0,0 @@
1
- /**
2
- * Escaping / quoting helpers shared by every scheduler backend (BAPI-327).
3
- *
4
- * Generated unit content is attacker-adjacent: idea-file paths, repo paths, and
5
- * prompts flow into plist XML, Windows `.cmd` wrappers, systemd unit directives,
6
- * and `at` heredoc scripts. Each target has a different quoting grammar, so each
7
- * gets a dedicated helper. Every helper first rejects NUL bytes — a NUL can
8
- * truncate a generated file mid-write and silently drop a security-relevant
9
- * suffix — via {@link assertNoNul}.
10
- */
11
- /**
12
- * All baked PATH-trap / schedule-context environment variable names, in a stable
13
- * order. Generalized in BAPI-351: the full-automation-specific `BRIDGE_GPT_CLAUDE`
14
- * became the agent-neutral `BRIDGE_GPT_AGENT_PATH`, and schedule-context vars
15
- * (`BRIDGE_GPT_SCHEDULE_ID`, `BRIDGE_GPT_COMMAND`, `BRIDGE_GPT_COMMAND_ARGS_JSON`)
16
- * were added. `BRIDGE_GPT_IDEA_FILE` is now legacy-only (baked only when present).
17
- */
18
- export const BAKED_ENV_VAR_NAMES = [
19
- "PATH",
20
- "BRIDGE_GPT_NODE",
21
- "BRIDGE_GPT_NPX",
22
- "BRIDGE_GPT_AGENT",
23
- "BRIDGE_GPT_AGENT_PATH",
24
- "BRIDGE_GPT_REPO_PATH",
25
- "BRIDGE_GPT_SCHEDULE_ID",
26
- "BRIDGE_GPT_COMMAND",
27
- "BRIDGE_GPT_COMMAND_ARGS_JSON",
28
- ];
29
- /**
30
- * Reject any generated value containing a NUL byte. NUL can prematurely
31
- * terminate a C-string when a unit file is parsed by the OS scheduler, silently
32
- * dropping the rest of the value. The `label` names the offending field so the
33
- * error is actionable.
34
- */
35
- export function assertNoNul(value, label) {
36
- if (value.includes("\0")) {
37
- throw new Error(`Refusing to generate scheduler content: ${label} contains a NUL byte, which is not allowed.`);
38
- }
39
- }
40
- /** Escape a string for use inside a launchd plist `<string>` value. */
41
- export function xmlEscape(value) {
42
- assertNoNul(value, "xml value");
43
- return value
44
- .replace(/&/g, "&amp;")
45
- .replace(/</g, "&lt;")
46
- .replace(/>/g, "&gt;")
47
- .replace(/"/g, "&quot;")
48
- .replace(/'/g, "&apos;");
49
- }
50
- /**
51
- * Quote a value as a single POSIX shell argument (single-quote form). Empty
52
- * strings and strings with spaces become a quoted token; embedded single quotes
53
- * use the canonical close/escape/reopen `'\''` form. Used by the `at` heredoc.
54
- */
55
- export function posixShellQuote(value) {
56
- assertNoNul(value, "posix shell value");
57
- if (value === "")
58
- return "''";
59
- return `'${value.replace(/'/g, "'\\''")}'`;
60
- }
61
- /**
62
- * Quote a value for a Windows command line (a single argument inside a `.cmd`
63
- * wrapper). Wraps in double quotes and escapes embedded double quotes the
64
- * cmd-compatible way. Always quoting keeps paths/prompts with spaces intact.
65
- */
66
- export function windowsCmdQuote(value) {
67
- assertNoNul(value, "windows cmd value");
68
- // Within double quotes, cmd treats "" as a literal quote.
69
- return `"${value.replace(/"/g, '""')}"`;
70
- }
71
- /**
72
- * Escape a value to be assigned via `set "KEY=value"` in a `.cmd` wrapper. The
73
- * `set "KEY=..."` quoted form already neutralises `& | < > ^`, but a literal `%`
74
- * still triggers variable expansion, so it is doubled to `%%`. A double quote
75
- * would close the `set` quoting, so it is dropped/escaped to keep the line safe.
76
- */
77
- export function escapeWindowsCmdSetValue(value) {
78
- assertNoNul(value, "windows cmd set value");
79
- return value.replace(/%/g, "%%").replace(/"/g, "");
80
- }
81
- /**
82
- * Quote a value for a systemd unit directive (e.g. `ExecStart=`, `Environment=`,
83
- * `WorkingDirectory=`). systemd accepts double-quoted tokens with C-style
84
- * backslash escaping for embedded backslashes and quotes. Empty strings render
85
- * as an explicit empty quoted token.
86
- */
87
- export function systemdQuote(value) {
88
- assertNoNul(value, "systemd value");
89
- const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
90
- return `"${escaped}"`;
91
- }
92
- /**
93
- * Build the ordered baked PATH-trap + schedule-context env entries from explicit,
94
- * schedule-time values — never `process.env`. Each value is NUL-checked. This is
95
- * the single source every backend uses so the baked environment is consistent
96
- * across launchd / Task Scheduler / systemd / at. `BRIDGE_GPT_IDEA_FILE` is
97
- * appended only for the legacy full-automation path (when `ideaFile` is set).
98
- */
99
- export function bakedEnvEntries(env) {
100
- const entries = [
101
- ["PATH", env.envPath],
102
- ["BRIDGE_GPT_NODE", env.nodePath],
103
- ["BRIDGE_GPT_NPX", env.npxPath],
104
- ["BRIDGE_GPT_AGENT", env.agent],
105
- ["BRIDGE_GPT_AGENT_PATH", env.agentPath],
106
- ["BRIDGE_GPT_REPO_PATH", env.repoPath],
107
- ["BRIDGE_GPT_SCHEDULE_ID", env.scheduleId],
108
- ["BRIDGE_GPT_COMMAND", env.command],
109
- ["BRIDGE_GPT_COMMAND_ARGS_JSON", env.commandArgsJson],
110
- ];
111
- if (env.ideaFile !== undefined) {
112
- entries.push(["BRIDGE_GPT_IDEA_FILE", env.ideaFile]);
113
- }
114
- for (const [key, value] of entries) {
115
- assertNoNul(value, key);
116
- }
117
- return entries;
118
- }
119
- /** Build a {@link BakedEnv} from a generalized scheduler create input. */
120
- export function bakedEnvFromCreateInput(input) {
121
- return {
122
- envPath: input.envPath,
123
- nodePath: input.nodePath,
124
- npxPath: input.npxPath,
125
- agent: input.agent,
126
- agentPath: input.agentPath,
127
- repoPath: input.repoPath,
128
- scheduleId: input.id,
129
- command: input.command,
130
- commandArgsJson: JSON.stringify(input.args),
131
- ideaFile: input.legacyIdeaFile,
132
- };
133
- }
134
- /**
135
- * Render the baked env as POSIX `export KEY='value'` lines (used by the `at`
136
- * heredoc script). Values come only from {@link bakedEnvEntries}; no value is
137
- * ever read from the ambient process environment.
138
- */
139
- export function formatEnvExportsForGeneratedUnit(env) {
140
- return bakedEnvEntries(env)
141
- .map(([key, value]) => `export ${key}=${posixShellQuote(value)}`)
142
- .join("\n");
143
- }
@@ -1,72 +0,0 @@
1
- import { createLaunchdBackend } from "./launchd.js";
2
- import { createTaskSchedulerBackend } from "./task-scheduler.js";
3
- import { createSystemdUserBackend } from "./systemd-user.js";
4
- import { createAtFallbackBackend } from "./at-fallback.js";
5
- export * from "./types.js";
6
- const LAUNCHD = createLaunchdBackend();
7
- const TASK_SCHEDULER = createTaskSchedulerBackend();
8
- const SYSTEMD_USER = createSystemdUserBackend();
9
- const AT_FALLBACK = createAtFallbackBackend();
10
- const SUPPORTED_PLATFORMS = ["darwin", "win32", "linux"];
11
- /** Controlled message for an unsupported `process.platform`. */
12
- export function unsupportedSchedulerPlatformMessage(platform) {
13
- return (`Unsupported platform '${platform}'. schedule-run supports ` +
14
- `${SUPPORTED_PLATFORMS.join(", ")}.`);
15
- }
16
- /** Return the backend instance for a specific name, or `null` when unknown. */
17
- export function getSchedulerBackendByName(name) {
18
- switch (name) {
19
- case "launchd":
20
- return LAUNCHD;
21
- case "task-scheduler":
22
- return TASK_SCHEDULER;
23
- case "systemd-user":
24
- return SYSTEMD_USER;
25
- case "at-fallback":
26
- return AT_FALLBACK;
27
- default:
28
- return null;
29
- }
30
- }
31
- /** Return the ordered candidate backends for a platform (or unsupported). */
32
- export function getSchedulerBackendsForPlatform(platform) {
33
- switch (platform) {
34
- case "darwin":
35
- return { ok: true, backends: [LAUNCHD] };
36
- case "win32":
37
- return { ok: true, backends: [TASK_SCHEDULER] };
38
- case "linux":
39
- return { ok: true, backends: [SYSTEMD_USER, AT_FALLBACK] };
40
- default:
41
- return { ok: false, error: unsupportedSchedulerPlatformMessage(platform) };
42
- }
43
- }
44
- /**
45
- * Select the scheduler backend for a create.
46
- *
47
- * For dry-run, return the platform's PRIMARY backend without probing tool
48
- * availability — previewing a unit must not depend on installed schedulers. For
49
- * real creates, return the first backend whose `isAvailable` resolves true
50
- * (Linux prefers systemd-user, then at-fallback). Returns a controlled error
51
- * (never throws) for unsupported platforms or when nothing is available.
52
- */
53
- export async function selectSchedulerBackend(deps, dryRun) {
54
- const platformResult = getSchedulerBackendsForPlatform(deps.platform);
55
- if (!platformResult.ok) {
56
- return { ok: false, error: platformResult.error };
57
- }
58
- const candidates = platformResult.backends;
59
- if (dryRun) {
60
- return { ok: true, backend: candidates[0] };
61
- }
62
- for (const backend of candidates) {
63
- if (await backend.isAvailable(deps)) {
64
- return { ok: true, backend };
65
- }
66
- }
67
- return {
68
- ok: false,
69
- error: `No available scheduler backend for platform '${deps.platform}'. ` +
70
- `Tried: ${candidates.map((b) => b.name).join(", ")}.`,
71
- };
72
- }