@bridge_gpt/mcp-server 0.1.13 → 0.1.16
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 +58 -0
- package/build/agent-registry.js +68 -0
- package/build/commands.generated.js +4 -3
- package/build/doctor.js +172 -0
- package/build/index.js +818 -95
- package/build/pipeline-orchestrator.js +593 -0
- package/build/pipeline-utils.js +50 -12
- package/build/pipelines.generated.js +44 -29
- package/build/start-tickets-prereqs.js +346 -0
- package/build/start-tickets.js +1210 -0
- package/build/version.generated.js +1 -1
- package/package.json +7 -7
- package/pipelines/check-ci-ticket.json +8 -2
- package/pipelines/implement-ticket.json +8 -2
- package/pipelines/pr-ticket.json +1 -2
- package/build/decision-page-schema.test.js +0 -248
|
@@ -0,0 +1,1210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* start-tickets — packaged CLI subcommand of the bridge-api-mcp-server bin.
|
|
3
|
+
*
|
|
4
|
+
* Spawns one Worktrunk worktree + selected-agent session per Jira ticket key.
|
|
5
|
+
* The agent defaults to Claude Code (`claude`) and is configurable via `--agent`
|
|
6
|
+
* (see agent-registry.ts). This module ports the behaviour of the former repo-local
|
|
7
|
+
* `scripts/start-tickets.sh` into TypeScript that ships inside the
|
|
8
|
+
* `@bridge_gpt/mcp-server` npm package, fixing the distribution bug where
|
|
9
|
+
* external `--init` consumers received a `/start-tickets` slash command that
|
|
10
|
+
* shelled out to a script that does not exist in their checkout.
|
|
11
|
+
*
|
|
12
|
+
* ---------------------------------------------------------------------------
|
|
13
|
+
* Implemented worktree-creation model (BAPI-302)
|
|
14
|
+
* ---------------------------------------------------------------------------
|
|
15
|
+
* The CLI owns worktree creation; the terminal tabs only run the agent:
|
|
16
|
+
*
|
|
17
|
+
* 1. The CLI creates / switches Worktrunk worktrees up front via
|
|
18
|
+
* `wt switch [--create] -y <branch> --format=json` (no `-x`; `-y`
|
|
19
|
+
* auto-approves hooks since there is no TTY), parsing the JSON output to
|
|
20
|
+
* capture each worktree path and per-ticket success. On Windows the
|
|
21
|
+
* Worktrunk binary is `git-wt`, NOT `wt` (see platform routing below).
|
|
22
|
+
* 2. Worktree operations are throttled by `--max-parallel` (default 3) so the
|
|
23
|
+
* expensive per-worktree `pre-start` hooks (uv venv / uv pip sync) do not
|
|
24
|
+
* all run at once.
|
|
25
|
+
* 3. Only after a worktree is created does the CLI open one terminal tab per
|
|
26
|
+
* successful worktree, running the per-platform agent shell command for the
|
|
27
|
+
* selected agent (POSIX `cd '<path>' && <agent> '...'` or PowerShell
|
|
28
|
+
* `Set-Location ...; <agent> '...'`; the agent defaults to `claude`).
|
|
29
|
+
*
|
|
30
|
+
* ---------------------------------------------------------------------------
|
|
31
|
+
* Cross-platform spawning (BAPI-303)
|
|
32
|
+
* ---------------------------------------------------------------------------
|
|
33
|
+
* Spawning is routed per platform behind the injected `spawnTerminalTab`
|
|
34
|
+
* boundary:
|
|
35
|
+
*
|
|
36
|
+
* - macOS (darwin): Terminal.app / iTerm via `osascript`.
|
|
37
|
+
* - Windows (win32): Windows Terminal (`wt.exe new-tab`) with a
|
|
38
|
+
* `Start-Process powershell.exe` fallback when Windows Terminal is absent.
|
|
39
|
+
* - Linux (linux): one detached `tmux` session per ticket (pane retained
|
|
40
|
+
* via `; exec $SHELL`); attach with `tmux attach -t <session>`.
|
|
41
|
+
*
|
|
42
|
+
* Any other `process.platform` value fails fast with a clear "unsupported
|
|
43
|
+
* platform" message. `--dry-run` short-circuits before routing so it previews
|
|
44
|
+
* the platform-correct command form on any OS.
|
|
45
|
+
*
|
|
46
|
+
* The single highest-risk Windows detail is the `wt` name collision: Windows
|
|
47
|
+
* Terminal is `wt.exe` (tab launcher) while Worktrunk installs as `git-wt`
|
|
48
|
+
* (worktree creator). The two are resolved and referenced independently.
|
|
49
|
+
*
|
|
50
|
+
* Subprocess safety: every git / Worktrunk / AppleScript / Windows Terminal /
|
|
51
|
+
* PowerShell / tmux invocation goes through the injected `runCommand` boundary
|
|
52
|
+
* using list-based arguments (`execFile`, never `shell: true`), so all logic is
|
|
53
|
+
* unit-testable on Linux CI without spawning real commands or terminals.
|
|
54
|
+
*/
|
|
55
|
+
import { execFile } from "child_process";
|
|
56
|
+
import path from "path";
|
|
57
|
+
// Per-OS prerequisite knowledge + low-level command probes live in the shared
|
|
58
|
+
// prereqs module so `runPreflight` (enforce) and the read-only `doctor` (render)
|
|
59
|
+
// can never drift. `start-tickets.ts` imports VALUES from there; the prereqs
|
|
60
|
+
// module imports only TYPES back, so the runtime graph stays acyclic.
|
|
61
|
+
import { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERSHELL_CANDIDATES, DEFAULT_WINDOWS_WORKTRUNK_BINARY, DEFAULT_POSIX_WORKTRUNK_BINARY, TMUX_COMMAND, GIT_FOR_WINDOWS_BASH_HINT, isSupportedStartTicketsPlatform, unsupportedPlatformMessage, resolveWorktrunkBinary, commandSucceeded, getCommandProbe, isCommandOnPath, resolveFirstCommandOnPath, enforcePreflightPrerequisites, appendDoctorHint, } from "./start-tickets-prereqs.js";
|
|
62
|
+
import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
|
|
63
|
+
// Re-export the shared prereq surface (constants, platform helpers, command
|
|
64
|
+
// probes) so existing import sites that read them from "./start-tickets.js"
|
|
65
|
+
// keep working unchanged.
|
|
66
|
+
export { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERSHELL_CANDIDATES, DEFAULT_WINDOWS_WORKTRUNK_BINARY, DEFAULT_POSIX_WORKTRUNK_BINARY, TMUX_COMMAND, GIT_FOR_WINDOWS_BASH_HINT, isSupportedStartTicketsPlatform, unsupportedPlatformMessage, resolveWorktrunkBinary, commandSucceeded, getCommandProbe, isCommandOnPath, resolveFirstCommandOnPath, };
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Constants
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/** Jira-style ticket key, e.g. BAPI-248. */
|
|
71
|
+
export const TICKET_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
|
|
72
|
+
/** Default cap on concurrently-created worktrees. */
|
|
73
|
+
export const DEFAULT_MAX_PARALLEL = 3;
|
|
74
|
+
/** Default tmux session-name prefix; one detached session is created per ticket. */
|
|
75
|
+
export const DEFAULT_TMUX_SESSION_PREFIX = "bridge-start-tickets";
|
|
76
|
+
/** Environment variable overriding the tmux session-name prefix. */
|
|
77
|
+
export const TMUX_SESSION_OVERRIDE_ENV = "BAPI_TMUX_SESSION";
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Usage / argument parsing
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
/** User-facing usage text for the packaged `start-tickets` subcommand. */
|
|
82
|
+
export function getStartTicketsUsage() {
|
|
83
|
+
return [
|
|
84
|
+
"Usage:",
|
|
85
|
+
" npx -y @bridge_gpt/mcp-server start-tickets [flags] KEY [KEY ...]",
|
|
86
|
+
"",
|
|
87
|
+
"Flags:",
|
|
88
|
+
" --agent claude|cursor-agent Agent command to launch in each worktree (default: claude)",
|
|
89
|
+
" --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
|
|
90
|
+
" --dry-run Print intended actions; create no worktrees, open no tabs",
|
|
91
|
+
" --branch KEY=BRANCH Use BRANCH instead of feature/KEY for that ticket (repeatable)",
|
|
92
|
+
" --no-refresh-main Skip 'git fetch origin main' + fast-forward of local main",
|
|
93
|
+
" --max-parallel N Max worktrees to create concurrently (default: 3)",
|
|
94
|
+
" -h, --help Show this help",
|
|
95
|
+
"",
|
|
96
|
+
"Environment:",
|
|
97
|
+
` ${WORKTRUNK_BINARY_OVERRIDE_ENV} Override the Worktrunk executable name/path for nonstandard installs`,
|
|
98
|
+
` ${TMUX_SESSION_OVERRIDE_ENV} Override the tmux session-name prefix on Linux (default: ${DEFAULT_TMUX_SESSION_PREFIX})`,
|
|
99
|
+
"",
|
|
100
|
+
"Prerequisites:",
|
|
101
|
+
" macOS wt, git, osascript",
|
|
102
|
+
" Windows git-wt, Git for Windows / Git Bash, Windows Terminal or PowerShell",
|
|
103
|
+
" Linux wt, git, tmux",
|
|
104
|
+
"",
|
|
105
|
+
"Each KEY must match [A-Z]+-[0-9]+ (e.g., BAPI-248).",
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse argv strictly and without an arg-parser dependency. Returns a
|
|
110
|
+
* discriminated result: help, a validation error, or validated options.
|
|
111
|
+
*/
|
|
112
|
+
export function parseStartTicketsArgs(argv) {
|
|
113
|
+
// Help is honoured before any other validation.
|
|
114
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
115
|
+
return { status: "help", usage: getStartTicketsUsage() };
|
|
116
|
+
}
|
|
117
|
+
let terminal;
|
|
118
|
+
let dryRun = false;
|
|
119
|
+
let refreshMain = true;
|
|
120
|
+
let maxParallelRaw;
|
|
121
|
+
let agentName = DEFAULT_AGENT_NAME;
|
|
122
|
+
const branchEntries = [];
|
|
123
|
+
const keys = [];
|
|
124
|
+
for (let i = 0; i < argv.length; i++) {
|
|
125
|
+
const arg = argv[i];
|
|
126
|
+
const takeValue = () => {
|
|
127
|
+
// The `--flag=value` form is handled inline by callers; this reads the
|
|
128
|
+
// separate next token for the `--flag value` form.
|
|
129
|
+
if (i + 1 >= argv.length)
|
|
130
|
+
return undefined;
|
|
131
|
+
i += 1;
|
|
132
|
+
return argv[i];
|
|
133
|
+
};
|
|
134
|
+
if (arg === "--agent" || arg.startsWith("--agent=")) {
|
|
135
|
+
let value;
|
|
136
|
+
if (arg.startsWith("--agent=")) {
|
|
137
|
+
value = arg.slice("--agent=".length);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
value = takeValue();
|
|
141
|
+
if (value === undefined) {
|
|
142
|
+
return { status: "error", message: "--agent requires a value (an agent name)." };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!isAgentName(value)) {
|
|
146
|
+
return {
|
|
147
|
+
status: "error",
|
|
148
|
+
message: `Invalid --agent value: '${value}' (allowed agents: ${formatValidAgentNames()}).`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
agentName = value;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (arg === "--terminal" || arg.startsWith("--terminal=")) {
|
|
155
|
+
let value;
|
|
156
|
+
if (arg.startsWith("--terminal=")) {
|
|
157
|
+
value = arg.slice("--terminal=".length);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
value = takeValue();
|
|
161
|
+
if (value === undefined) {
|
|
162
|
+
return { status: "error", message: "--terminal requires a value (terminal or iterm)." };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (value !== "terminal" && value !== "iterm") {
|
|
166
|
+
return {
|
|
167
|
+
status: "error",
|
|
168
|
+
message: `Invalid --terminal value: '${value}' (allowed values: terminal, iterm).`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
terminal = value;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (arg === "--max-parallel" || arg.startsWith("--max-parallel=")) {
|
|
175
|
+
if (arg.startsWith("--max-parallel=")) {
|
|
176
|
+
maxParallelRaw = arg.slice("--max-parallel=".length);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const value = takeValue();
|
|
180
|
+
if (value === undefined) {
|
|
181
|
+
return { status: "error", message: "--max-parallel requires a positive integer value." };
|
|
182
|
+
}
|
|
183
|
+
maxParallelRaw = value;
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (arg === "--branch" || arg.startsWith("--branch=")) {
|
|
188
|
+
let value;
|
|
189
|
+
if (arg.startsWith("--branch=")) {
|
|
190
|
+
value = arg.slice("--branch=".length);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
value = takeValue();
|
|
194
|
+
if (value === undefined) {
|
|
195
|
+
return { status: "error", message: "--branch requires a KEY=BRANCH value." };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
branchEntries.push(value);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (arg === "--dry-run") {
|
|
202
|
+
dryRun = true;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (arg === "--no-refresh-main") {
|
|
206
|
+
refreshMain = false;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (arg.startsWith("-")) {
|
|
210
|
+
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
211
|
+
}
|
|
212
|
+
keys.push(arg);
|
|
213
|
+
}
|
|
214
|
+
// --- key validation ---
|
|
215
|
+
if (keys.length === 0) {
|
|
216
|
+
return {
|
|
217
|
+
status: "error",
|
|
218
|
+
message: "At least one ticket key is required (e.g., BAPI-248).",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
for (const key of keys) {
|
|
223
|
+
if (!TICKET_KEY_PATTERN.test(key)) {
|
|
224
|
+
return {
|
|
225
|
+
status: "error",
|
|
226
|
+
message: `Invalid ticket key: '${key}' (keys must match [A-Z]+-[0-9]+, e.g., BAPI-248).`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (seen.has(key)) {
|
|
230
|
+
return { status: "error", message: `Duplicate ticket key: '${key}'.` };
|
|
231
|
+
}
|
|
232
|
+
seen.add(key);
|
|
233
|
+
}
|
|
234
|
+
// --- max-parallel validation ---
|
|
235
|
+
let maxParallel = DEFAULT_MAX_PARALLEL;
|
|
236
|
+
if (maxParallelRaw !== undefined) {
|
|
237
|
+
if (!/^[0-9]+$/.test(maxParallelRaw) || Number(maxParallelRaw) < 1) {
|
|
238
|
+
return {
|
|
239
|
+
status: "error",
|
|
240
|
+
message: `Invalid --max-parallel value: '${maxParallelRaw}' (must be a positive integer).`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
maxParallel = Number(maxParallelRaw);
|
|
244
|
+
}
|
|
245
|
+
// --- branch override validation ---
|
|
246
|
+
const branchOverrides = {};
|
|
247
|
+
for (const entry of branchEntries) {
|
|
248
|
+
const sepIndex = entry.indexOf("=");
|
|
249
|
+
if (sepIndex <= 0) {
|
|
250
|
+
return {
|
|
251
|
+
status: "error",
|
|
252
|
+
message: `Invalid --branch override: '${entry}' (expected KEY=BRANCH).`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const overrideKey = entry.slice(0, sepIndex);
|
|
256
|
+
const branchName = entry.slice(sepIndex + 1);
|
|
257
|
+
if (!TICKET_KEY_PATTERN.test(overrideKey)) {
|
|
258
|
+
return {
|
|
259
|
+
status: "error",
|
|
260
|
+
message: `Invalid --branch override key: '${overrideKey}' (keys must match [A-Z]+-[0-9]+).`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (!seen.has(overrideKey)) {
|
|
264
|
+
return {
|
|
265
|
+
status: "error",
|
|
266
|
+
message: `--branch override key '${overrideKey}' is not one of the requested tickets.`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const branchError = validateBranchName(branchName);
|
|
270
|
+
if (branchError) {
|
|
271
|
+
return {
|
|
272
|
+
status: "error",
|
|
273
|
+
message: `Invalid branch name for ${overrideKey}: ${branchError}`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
branchOverrides[overrideKey] = branchName;
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
status: "ok",
|
|
280
|
+
options: { keys, terminal, dryRun, refreshMain, maxParallel, branchOverrides, agentName },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** Returns an error string for an unsafe branch name, or null when valid. */
|
|
284
|
+
function validateBranchName(branch) {
|
|
285
|
+
if (branch.length === 0)
|
|
286
|
+
return "branch name must not be empty.";
|
|
287
|
+
if (branch.startsWith("-"))
|
|
288
|
+
return "branch name must not start with '-'.";
|
|
289
|
+
// Reject ASCII control characters (0x00-0x1F and 0x7F) without embedding
|
|
290
|
+
// raw control bytes in source.
|
|
291
|
+
for (let i = 0; i < branch.length; i++) {
|
|
292
|
+
const code = branch.charCodeAt(i);
|
|
293
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
294
|
+
return "branch name must not contain control characters.";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
/** Resolve the branch for a ticket: explicit override, else feature/<KEY>. */
|
|
300
|
+
export function resolveBranchForTicket(key, overrides) {
|
|
301
|
+
if (Object.prototype.hasOwnProperty.call(overrides, key)) {
|
|
302
|
+
return overrides[key];
|
|
303
|
+
}
|
|
304
|
+
return `feature/${key}`;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Determine which macOS terminal to drive. An explicit choice wins; otherwise
|
|
308
|
+
* auto-detect iTerm from `$TERM_PROGRAM` (case-insensitive), defaulting to
|
|
309
|
+
* Terminal.app. Only consumed by the macOS spawner; ignored on Windows/Linux.
|
|
310
|
+
*/
|
|
311
|
+
export function detectTerminal(explicit, env) {
|
|
312
|
+
if (explicit)
|
|
313
|
+
return explicit;
|
|
314
|
+
const termProgram = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
315
|
+
if (termProgram.includes("iterm"))
|
|
316
|
+
return "iterm";
|
|
317
|
+
return "terminal";
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Platform routing
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
//
|
|
323
|
+
// `isSupportedStartTicketsPlatform`, `unsupportedPlatformMessage`, and
|
|
324
|
+
// `resolveWorktrunkBinary` now live in ./start-tickets-prereqs.js (imported and
|
|
325
|
+
// re-exported above) so preflight enforcement and the doctor share them.
|
|
326
|
+
/** Map a platform to its default terminal spawner. */
|
|
327
|
+
export function getDefaultSpawnTerminalTabForPlatform(platform) {
|
|
328
|
+
switch (platform) {
|
|
329
|
+
case "darwin":
|
|
330
|
+
return spawnMacOSTerminalTab;
|
|
331
|
+
case "win32":
|
|
332
|
+
return spawnWindowsTerminalTab;
|
|
333
|
+
case "linux":
|
|
334
|
+
return spawnLinuxTmuxTerminalTab;
|
|
335
|
+
default:
|
|
336
|
+
return spawnUnsupportedPlatformTerminalTab;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The single source of truth for platform selection. Resolves the Worktrunk
|
|
341
|
+
* binary, closes over the selected `agent` to build the per-platform shell
|
|
342
|
+
* command, and preserves the injected `deps.spawnTerminalTab` (so mocks/overrides
|
|
343
|
+
* are always honored). Returns a structured error for unsupported platforms;
|
|
344
|
+
* never throws.
|
|
345
|
+
*/
|
|
346
|
+
export function resolveStartTicketsPlatformConfig(deps, agent) {
|
|
347
|
+
if (!isSupportedStartTicketsPlatform(deps.platform)) {
|
|
348
|
+
return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
|
|
349
|
+
}
|
|
350
|
+
const platform = deps.platform;
|
|
351
|
+
return {
|
|
352
|
+
ok: true,
|
|
353
|
+
config: {
|
|
354
|
+
platform,
|
|
355
|
+
worktrunkBinary: resolveWorktrunkBinary(platform, deps.env),
|
|
356
|
+
buildAgentShellCommand: (key, worktreePath) => buildAgentShellCommand(agent, key, worktreePath, platform),
|
|
357
|
+
spawnTerminalTab: deps.spawnTerminalTab,
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Pure escaping + slug helpers
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
/**
|
|
365
|
+
* Escape a string for inclusion inside a single-quoted shell context: each
|
|
366
|
+
* embedded single-quote becomes the sequence `'\''`. Returns only the inner
|
|
367
|
+
* escaped text (no surrounding quotes). Mirrors the bash `sh_squote_inner`.
|
|
368
|
+
*/
|
|
369
|
+
export function shSquoteInner(value) {
|
|
370
|
+
return value.replace(/'/g, "'\\''");
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Escape a string for inclusion inside an AppleScript double-quoted literal:
|
|
374
|
+
* backslashes are doubled first, then double quotes are backslash-escaped.
|
|
375
|
+
* Returns only the inner escaped text (no surrounding quotes). Mirrors the
|
|
376
|
+
* bash `applescript_dquote_inner`.
|
|
377
|
+
*/
|
|
378
|
+
export function applescriptDquoteInner(value) {
|
|
379
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Escape a string for inclusion inside a PowerShell single-quoted literal: each
|
|
383
|
+
* embedded single-quote is doubled (`'` -> `''`). Returns only the inner
|
|
384
|
+
* escaped text (no surrounding quotes). Mirrors the POSIX `shSquoteInner`
|
|
385
|
+
* pattern for the PowerShell quoting rules.
|
|
386
|
+
*/
|
|
387
|
+
export function powershellSquoteInner(value) {
|
|
388
|
+
return value.replace(/'/g, "''");
|
|
389
|
+
}
|
|
390
|
+
/** Wrap `value` in a PowerShell single-quoted literal, escaping inner quotes. */
|
|
391
|
+
export function powershellSquote(value) {
|
|
392
|
+
return `'${powershellSquoteInner(value)}'`;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Slugify a ticket summary into a branch-name fragment. Mirrors the slash
|
|
396
|
+
* command's enrichment rules so behaviour stays consistent; the CLI itself
|
|
397
|
+
* does NOT call the Bridge API — enrichment happens in the slash command and
|
|
398
|
+
* is passed in via `--branch` overrides.
|
|
399
|
+
*
|
|
400
|
+
* Rules: lowercase, collapse runs of [^a-z0-9]+ to a single '-', trim leading
|
|
401
|
+
* and trailing '-', truncate to at most 40 chars preferring a dash boundary.
|
|
402
|
+
* Returns "" when no usable slug remains.
|
|
403
|
+
*/
|
|
404
|
+
export function slugifyTicketSummaryForBranch(summary) {
|
|
405
|
+
let slug = summary
|
|
406
|
+
.toLowerCase()
|
|
407
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
408
|
+
.replace(/^-+/, "")
|
|
409
|
+
.replace(/-+$/, "");
|
|
410
|
+
if (slug.length <= 40)
|
|
411
|
+
return slug;
|
|
412
|
+
let truncated = slug.slice(0, 40);
|
|
413
|
+
const lastDash = truncated.lastIndexOf("-");
|
|
414
|
+
if (lastDash > 0) {
|
|
415
|
+
truncated = truncated.slice(0, lastDash);
|
|
416
|
+
}
|
|
417
|
+
return truncated.replace(/-+$/, "");
|
|
418
|
+
}
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Subprocess boundary
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
/** Build the default dependency set backed by real subprocesses / process state. */
|
|
423
|
+
export function createDefaultStartTicketsDeps() {
|
|
424
|
+
const runCommand = (file, args, options) => new Promise((resolve) => {
|
|
425
|
+
execFile(file, args, {
|
|
426
|
+
cwd: options?.cwd,
|
|
427
|
+
// Worktrunk JSON / git porcelain output can be large; give it room.
|
|
428
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
429
|
+
encoding: "utf-8",
|
|
430
|
+
}, (error, stdout, stderr) => {
|
|
431
|
+
const exitCode = error && typeof error.code === "number"
|
|
432
|
+
? (error.code)
|
|
433
|
+
: error
|
|
434
|
+
? 1
|
|
435
|
+
: 0;
|
|
436
|
+
resolve({
|
|
437
|
+
stdout: stdout ?? "",
|
|
438
|
+
stderr: stderr ?? "",
|
|
439
|
+
exitCode,
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
const deps = {
|
|
444
|
+
runCommand,
|
|
445
|
+
platform: process.platform,
|
|
446
|
+
env: process.env,
|
|
447
|
+
cwd: process.cwd(),
|
|
448
|
+
// The platform router is the single source of truth for spawner selection.
|
|
449
|
+
spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
|
|
450
|
+
};
|
|
451
|
+
return deps;
|
|
452
|
+
}
|
|
453
|
+
/** Combine a result's stderr + stdout into a single trimmed detail string. */
|
|
454
|
+
function combineCommandOutput(result) {
|
|
455
|
+
return [result.stderr, result.stdout]
|
|
456
|
+
.map((s) => s.trim())
|
|
457
|
+
.filter(Boolean)
|
|
458
|
+
.join(" ");
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Verify a tool is on PATH via the platform-correct probe. An optional
|
|
462
|
+
* `installHint` is appended to the failure message. Thin wrapper around the
|
|
463
|
+
* shared `isCommandOnPath`; retained for callers/tests that prefer a structured
|
|
464
|
+
* `VoidResult` over a boolean.
|
|
465
|
+
*/
|
|
466
|
+
export async function requireCommandOnPath(deps, tool, installHint) {
|
|
467
|
+
if (await isCommandOnPath(deps, tool))
|
|
468
|
+
return { ok: true };
|
|
469
|
+
const hint = installHint ? ` ${installHint}` : "";
|
|
470
|
+
return { ok: false, error: `Required command not found on PATH: ${tool}.${hint}` };
|
|
471
|
+
}
|
|
472
|
+
/** Succeed when any candidate is on PATH; otherwise return `errorMessage`. */
|
|
473
|
+
export async function requireAnyCommandOnPath(deps, candidates, errorMessage) {
|
|
474
|
+
const found = await resolveFirstCommandOnPath(deps, candidates);
|
|
475
|
+
if (found)
|
|
476
|
+
return { ok: true };
|
|
477
|
+
return { ok: false, error: errorMessage };
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Minimal, fail-fast, per-platform pre-flight. Skipped entirely for dry runs.
|
|
481
|
+
* Delegates the per-OS prerequisite knowledge (worktrunk binary, git, the
|
|
482
|
+
* platform launchers, Git Bash, and the git work-tree check) to the shared
|
|
483
|
+
* `enforcePreflightPrerequisites`, so `runPreflight` and the read-only `doctor`
|
|
484
|
+
* never drift. A dependency-missing failure is annotated with a hint to run the
|
|
485
|
+
* read-only `doctor`; an unsupported-platform failure is returned as-is (doctor
|
|
486
|
+
* cannot fix it, and orchestration/CLI tests assert the bare message). `uv` and
|
|
487
|
+
* the selected agent are NOT enforced here — they are doctor-only. Never throws.
|
|
488
|
+
*/
|
|
489
|
+
export async function runPreflight(deps, options) {
|
|
490
|
+
if (options.dryRun)
|
|
491
|
+
return { ok: true };
|
|
492
|
+
const result = await enforcePreflightPrerequisites(deps);
|
|
493
|
+
if (result.ok)
|
|
494
|
+
return { ok: true };
|
|
495
|
+
if (result.reason === "unsupported-platform") {
|
|
496
|
+
return { ok: false, error: result.error };
|
|
497
|
+
}
|
|
498
|
+
return { ok: false, error: appendDoctorHint(result.error) };
|
|
499
|
+
}
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// main refresh + worktree parsing
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
/** Parse `git worktree list --porcelain` into entries with path + optional branch. */
|
|
504
|
+
export function parseGitWorktreeList(output) {
|
|
505
|
+
const entries = [];
|
|
506
|
+
let current = null;
|
|
507
|
+
for (const rawLine of output.split("\n")) {
|
|
508
|
+
const line = rawLine.replace(/\r$/, "");
|
|
509
|
+
if (line.startsWith("worktree ")) {
|
|
510
|
+
if (current)
|
|
511
|
+
entries.push(current);
|
|
512
|
+
current = { path: line.slice("worktree ".length) };
|
|
513
|
+
}
|
|
514
|
+
else if (line.startsWith("branch ") && current) {
|
|
515
|
+
const ref = line.slice("branch ".length);
|
|
516
|
+
current.branch = ref.startsWith("refs/heads/")
|
|
517
|
+
? ref.slice("refs/heads/".length)
|
|
518
|
+
: ref;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (current)
|
|
522
|
+
entries.push(current);
|
|
523
|
+
return entries;
|
|
524
|
+
}
|
|
525
|
+
/** Return the worktree path owning branch `main`, or null. */
|
|
526
|
+
export function findMainWorktreePath(entries) {
|
|
527
|
+
for (const entry of entries) {
|
|
528
|
+
if (entry.branch === "main")
|
|
529
|
+
return entry.path;
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Fetch origin/main and fast-forward local `main`. No-op when `refreshMain` is
|
|
535
|
+
* false. Handles both checkout states:
|
|
536
|
+
*
|
|
537
|
+
* - `main` IS checked out in a worktree → `git merge --ff-only` inside that
|
|
538
|
+
* worktree, so its index/working tree stay consistent (git forbids
|
|
539
|
+
* force-moving a checked-out branch ref).
|
|
540
|
+
* - `main` is NOT checked out anywhere (e.g. the primary checkout is on a
|
|
541
|
+
* feature/chore branch) → fast-forward the ref directly with
|
|
542
|
+
* `git branch --force`, since no working tree depends on it. This is guarded
|
|
543
|
+
* by an ancestry check so a diverged local `main` is never clobbered, and it
|
|
544
|
+
* creates `main` from origin/main when no local ref exists yet.
|
|
545
|
+
*
|
|
546
|
+
* Returns a structured failure for any expected problem (fetch failure, diverged
|
|
547
|
+
* main, or a failed ref update).
|
|
548
|
+
*/
|
|
549
|
+
export async function refreshMainBranch(deps, options) {
|
|
550
|
+
if (!options.refreshMain)
|
|
551
|
+
return { ok: true };
|
|
552
|
+
const fetch = await deps.runCommand("git", ["fetch", "origin", "main"], {
|
|
553
|
+
cwd: deps.cwd,
|
|
554
|
+
});
|
|
555
|
+
if (!commandSucceeded(fetch)) {
|
|
556
|
+
return {
|
|
557
|
+
ok: false,
|
|
558
|
+
error: "git fetch origin main failed. Check your network and 'git remote get-url origin', or pass --no-refresh-main to skip.",
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const list = await deps.runCommand("git", ["worktree", "list", "--porcelain"], { cwd: deps.cwd });
|
|
562
|
+
if (!commandSucceeded(list)) {
|
|
563
|
+
return {
|
|
564
|
+
ok: false,
|
|
565
|
+
error: "git worktree list --porcelain failed; cannot locate the main worktree.",
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const mainPath = findMainWorktreePath(parseGitWorktreeList(list.stdout));
|
|
569
|
+
// `main` is checked out somewhere: fast-forward it in place. git refuses to
|
|
570
|
+
// force-move a checked-out branch ref, so we must go through merge.
|
|
571
|
+
if (mainPath) {
|
|
572
|
+
const merge = await deps.runCommand("git", ["merge", "--ff-only", "origin/main"], { cwd: mainPath });
|
|
573
|
+
if (!commandSucceeded(merge)) {
|
|
574
|
+
return {
|
|
575
|
+
ok: false,
|
|
576
|
+
error: `Local main has diverged from origin/main (checked out at ${mainPath}). Resolve the divergence manually, or rerun with --no-refresh-main.`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return { ok: true };
|
|
580
|
+
}
|
|
581
|
+
// `main` is not checked out in any worktree. No working tree depends on the
|
|
582
|
+
// ref, so fast-forward it directly — but only when it is a true fast-forward,
|
|
583
|
+
// never clobbering local-only commits. When local `main` does not exist yet,
|
|
584
|
+
// create it from origin/main.
|
|
585
|
+
if (await branchExists(deps, "main")) {
|
|
586
|
+
const ancestor = await deps.runCommand("git", ["merge-base", "--is-ancestor", "main", "origin/main"], { cwd: deps.cwd });
|
|
587
|
+
if (!commandSucceeded(ancestor)) {
|
|
588
|
+
return {
|
|
589
|
+
ok: false,
|
|
590
|
+
error: "Local main has diverged from origin/main. Resolve the divergence manually, or rerun with --no-refresh-main.",
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const update = await deps.runCommand("git", ["branch", "--force", "main", "origin/main"], { cwd: deps.cwd });
|
|
595
|
+
if (!commandSucceeded(update)) {
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
error: "Failed to fast-forward local main to origin/main. Resolve manually, or rerun with --no-refresh-main.",
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
return { ok: true };
|
|
602
|
+
}
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Concurrency + worktree creation
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
/**
|
|
607
|
+
* Run `worker` over `items` with at most `limit` concurrent workers, preserving
|
|
608
|
+
* result order regardless of completion order.
|
|
609
|
+
*/
|
|
610
|
+
export async function runWithConcurrency(items, limit, worker) {
|
|
611
|
+
const results = new Array(items.length);
|
|
612
|
+
const effectiveLimit = Math.max(1, Math.floor(limit));
|
|
613
|
+
let nextIndex = 0;
|
|
614
|
+
async function runner() {
|
|
615
|
+
while (true) {
|
|
616
|
+
const index = nextIndex;
|
|
617
|
+
if (index >= items.length)
|
|
618
|
+
return;
|
|
619
|
+
nextIndex += 1;
|
|
620
|
+
results[index] = await worker(items[index], index);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const runners = [];
|
|
624
|
+
const poolSize = Math.min(effectiveLimit, items.length);
|
|
625
|
+
for (let i = 0; i < poolSize; i++) {
|
|
626
|
+
runners.push(runner());
|
|
627
|
+
}
|
|
628
|
+
await Promise.all(runners);
|
|
629
|
+
return results;
|
|
630
|
+
}
|
|
631
|
+
/** True only when `git show-ref --verify --quiet refs/heads/<branch>` exits 0. */
|
|
632
|
+
export async function branchExists(deps, branch) {
|
|
633
|
+
const result = await deps.runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: deps.cwd });
|
|
634
|
+
return commandSucceeded(result);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Build `wt switch` args. Include `--create` only when the branch does not yet
|
|
638
|
+
* exist. Always include `-y` (auto-approve): the CLI runs the Worktrunk binary
|
|
639
|
+
* via `execFile` with no TTY, so without `-y` the `pre-start` / `post-start`
|
|
640
|
+
* hook-approval prompts would have no input source and the call would hang or
|
|
641
|
+
* fail — this mirrors the deleted `scripts/start-tickets.sh`, which ran
|
|
642
|
+
* `wt switch -c -y`. Never includes `-x` — the CLI owns worktree creation and
|
|
643
|
+
* tab spawning separately. The args are platform-agnostic; only the binary
|
|
644
|
+
* differs (`wt` vs `git-wt`).
|
|
645
|
+
*/
|
|
646
|
+
export function buildWtSwitchArgs(branch, exists) {
|
|
647
|
+
if (exists) {
|
|
648
|
+
return ["switch", "-y", branch, "--format=json"];
|
|
649
|
+
}
|
|
650
|
+
return ["switch", "--create", "-y", branch, "--format=json"];
|
|
651
|
+
}
|
|
652
|
+
/** Return the Node `path` API matching a platform (`win32` vs POSIX). */
|
|
653
|
+
export function pathApiForPlatform(platform) {
|
|
654
|
+
return platform === "win32" ? path.win32 : path.posix;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Extract a worktree path from Worktrunk JSON stdout. Accepts several common
|
|
658
|
+
* shapes defensively and resolves relative paths against `cwd` using the
|
|
659
|
+
* platform-correct path semantics (so Windows-style paths resolve correctly
|
|
660
|
+
* even when the test/host OS differs). Throws when no usable path can be
|
|
661
|
+
* extracted.
|
|
662
|
+
*/
|
|
663
|
+
export function extractWorktreePath(stdout, cwd, platform = process.platform) {
|
|
664
|
+
let parsed;
|
|
665
|
+
try {
|
|
666
|
+
parsed = JSON.parse(stdout);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
throw new Error(`Could not parse Worktrunk JSON output: ${stdout.slice(0, 200)}`);
|
|
670
|
+
}
|
|
671
|
+
const candidate = pickWorktreePathField(parsed);
|
|
672
|
+
if (!candidate) {
|
|
673
|
+
throw new Error(`Worktrunk JSON did not include a worktree path: ${stdout.slice(0, 200)}`);
|
|
674
|
+
}
|
|
675
|
+
const pathApi = pathApiForPlatform(platform);
|
|
676
|
+
return pathApi.isAbsolute(candidate) ? candidate : pathApi.resolve(cwd, candidate);
|
|
677
|
+
}
|
|
678
|
+
function pickWorktreePathField(parsed) {
|
|
679
|
+
if (!parsed || typeof parsed !== "object")
|
|
680
|
+
return undefined;
|
|
681
|
+
const obj = parsed;
|
|
682
|
+
if (typeof obj.path === "string")
|
|
683
|
+
return obj.path;
|
|
684
|
+
if (typeof obj.worktree_path === "string")
|
|
685
|
+
return obj.worktree_path;
|
|
686
|
+
if (typeof obj.directory === "string")
|
|
687
|
+
return obj.directory;
|
|
688
|
+
if (obj.worktree && typeof obj.worktree === "object") {
|
|
689
|
+
const nested = obj.worktree;
|
|
690
|
+
if (typeof nested.path === "string")
|
|
691
|
+
return nested.path;
|
|
692
|
+
}
|
|
693
|
+
return undefined;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Create / switch the worktree for a single ticket using the resolved Worktrunk
|
|
697
|
+
* binary (`wt` on macOS/Linux, `git-wt` on Windows). Returns a `created` row on
|
|
698
|
+
* success (with key, branch, path) or a `create-failed` row on any expected
|
|
699
|
+
* failure — never throws for per-ticket problems.
|
|
700
|
+
*/
|
|
701
|
+
export async function createWorktreeForTicket(deps, key, branchOverrides, worktrunkBinary) {
|
|
702
|
+
const branch = resolveBranchForTicket(key, branchOverrides);
|
|
703
|
+
try {
|
|
704
|
+
const exists = await branchExists(deps, branch);
|
|
705
|
+
const args = buildWtSwitchArgs(branch, exists);
|
|
706
|
+
const result = await deps.runCommand(worktrunkBinary, args, { cwd: deps.cwd });
|
|
707
|
+
if (!commandSucceeded(result)) {
|
|
708
|
+
const reason = (result.stderr || result.stdout || "").trim();
|
|
709
|
+
return {
|
|
710
|
+
key,
|
|
711
|
+
branch,
|
|
712
|
+
status: "create-failed",
|
|
713
|
+
error: `${worktrunkBinary} ${args.join(" ")} failed${reason ? `: ${reason}` : ""}`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
const worktreePath = extractWorktreePath(result.stdout, deps.cwd, deps.platform);
|
|
717
|
+
return { key, branch, status: "created", path: worktreePath };
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
721
|
+
return { key, branch, status: "create-failed", error: message };
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Create / switch worktrees for every ticket, throttled to `maxParallel`, using
|
|
726
|
+
* the resolved Worktrunk binary. Returns one row per ticket in original key
|
|
727
|
+
* order; per-ticket failures are recorded as `create-failed` rows rather than
|
|
728
|
+
* aborting the run.
|
|
729
|
+
*/
|
|
730
|
+
export async function createWorktrees(deps, options, worktrunkBinary) {
|
|
731
|
+
return runWithConcurrency(options.keys, options.maxParallel, (key) => createWorktreeForTicket(deps, key, options.branchOverrides, worktrunkBinary));
|
|
732
|
+
}
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
// Per-platform shell-command construction
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
/** The starter prompt handed to the selected agent. Identical for every agent. */
|
|
737
|
+
export function buildAgentPrompt(key) {
|
|
738
|
+
return `/implement-ticket ${key}`;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Build the agent invocation (`<command> <quotedPrompt>`) for the agent's prompt
|
|
742
|
+
* style. Only `positional` exists today; the `switch` keeps a future flag-style
|
|
743
|
+
* agent a typed extension rather than a silent fall-through. `quote` applies the
|
|
744
|
+
* platform-correct quoting to the prompt.
|
|
745
|
+
*/
|
|
746
|
+
export function buildAgentInvocation(agent, prompt, quote) {
|
|
747
|
+
switch (agent.promptArgStyle) {
|
|
748
|
+
case "positional":
|
|
749
|
+
return `${agent.command} ${quote(prompt)}`;
|
|
750
|
+
default: {
|
|
751
|
+
const exhaustive = agent.promptArgStyle;
|
|
752
|
+
throw new Error(`Unsupported agent promptArgStyle: ${String(exhaustive)}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/** POSIX agent shell command: `cd '<path>' && <agent> '<prompt>'`. */
|
|
757
|
+
export function buildPosixAgentShellCommand(agent, key, worktreePath) {
|
|
758
|
+
const invocation = buildAgentInvocation(agent, buildAgentPrompt(key), (p) => `'${shSquoteInner(p)}'`);
|
|
759
|
+
return `cd '${shSquoteInner(worktreePath)}' && ${invocation}`;
|
|
760
|
+
}
|
|
761
|
+
/** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> '<prompt>'`. */
|
|
762
|
+
export function buildPowerShellAgentShellCommand(agent, key, worktreePath) {
|
|
763
|
+
const invocation = buildAgentInvocation(agent, buildAgentPrompt(key), powershellSquote);
|
|
764
|
+
return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${invocation}`;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Build the shell command run inside each spawned tab/session, dispatched by
|
|
768
|
+
* platform. PowerShell on Windows; POSIX everywhere else (incl. the unsupported
|
|
769
|
+
* dry-run fallback). The selected `agent` (never a module-level constant)
|
|
770
|
+
* determines the launched command.
|
|
771
|
+
*/
|
|
772
|
+
export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin") {
|
|
773
|
+
if (platform === "win32")
|
|
774
|
+
return buildPowerShellAgentShellCommand(agent, key, worktreePath);
|
|
775
|
+
return buildPosixAgentShellCommand(agent, key, worktreePath);
|
|
776
|
+
}
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
// macOS terminal spawning (behind the injected boundary)
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
/** Generate AppleScript that runs `shellCommand` in a Terminal.app tab. */
|
|
781
|
+
export function buildTerminalAppleScript(shellCommand) {
|
|
782
|
+
const esc = applescriptDquoteInner(shellCommand);
|
|
783
|
+
return [
|
|
784
|
+
'tell application "Terminal"',
|
|
785
|
+
" activate",
|
|
786
|
+
" if (count of windows) is 0 then",
|
|
787
|
+
` do script "${esc}"`,
|
|
788
|
+
" else",
|
|
789
|
+
' tell application "System Events" to keystroke "t" using command down',
|
|
790
|
+
" delay 0.2",
|
|
791
|
+
` do script "${esc}" in selected tab of front window`,
|
|
792
|
+
" end if",
|
|
793
|
+
"end tell",
|
|
794
|
+
].join("\n");
|
|
795
|
+
}
|
|
796
|
+
/** Generate AppleScript that runs `shellCommand` in an iTerm2 tab. */
|
|
797
|
+
export function buildITermAppleScript(shellCommand) {
|
|
798
|
+
const esc = applescriptDquoteInner(shellCommand);
|
|
799
|
+
return [
|
|
800
|
+
'tell application "iTerm"',
|
|
801
|
+
" activate",
|
|
802
|
+
" if (count of windows) = 0 then",
|
|
803
|
+
" set newWindow to (create window with default profile)",
|
|
804
|
+
` tell current session of newWindow to write text "${esc}"`,
|
|
805
|
+
" else",
|
|
806
|
+
" tell current window",
|
|
807
|
+
" set newTab to (create tab with default profile)",
|
|
808
|
+
` tell current session of newTab to write text "${esc}"`,
|
|
809
|
+
" end tell",
|
|
810
|
+
" end if",
|
|
811
|
+
"end tell",
|
|
812
|
+
].join("\n");
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Spawn a single macOS terminal tab running `shellCommand`. Selects the
|
|
816
|
+
* AppleScript builder by terminal choice and runs it via `osascript -e`. The
|
|
817
|
+
* optional `context` is accepted to satisfy the shared spawner signature but is
|
|
818
|
+
* unused on macOS. Returns a structured failure for expected spawn errors
|
|
819
|
+
* (never throws).
|
|
820
|
+
*/
|
|
821
|
+
export async function spawnMacOSTerminalTab(deps, terminal, shellCommand, _context) {
|
|
822
|
+
const script = terminal === "iterm"
|
|
823
|
+
? buildITermAppleScript(shellCommand)
|
|
824
|
+
: buildTerminalAppleScript(shellCommand);
|
|
825
|
+
const result = await deps.runCommand("osascript", ["-e", script]);
|
|
826
|
+
if (commandSucceeded(result))
|
|
827
|
+
return { ok: true };
|
|
828
|
+
const reason = (result.stderr || result.stdout || "").trim();
|
|
829
|
+
return {
|
|
830
|
+
ok: false,
|
|
831
|
+
error: `osascript failed to open a ${terminal} tab${reason ? `: ${reason}` : ""}`,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
// Windows terminal spawning (behind the injected boundary)
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
/**
|
|
838
|
+
* List-based Windows Terminal args: open at `-d <path>` and run PowerShell.
|
|
839
|
+
*
|
|
840
|
+
* `wt.exe` parses `;` inside ANY argv item as a sub-command delimiter, so a raw
|
|
841
|
+
* `Set-Location ...; claude ...` PowerShell command would be split — the tab
|
|
842
|
+
* would only run `Set-Location` and the agent would never launch. Windows
|
|
843
|
+
* Terminal's documented escape for a literal semicolon is `\;`; wt.exe unescapes
|
|
844
|
+
* it back to `;` before handing the command to PowerShell. We therefore escape
|
|
845
|
+
* every `;` in the PowerShell command passed to wt.exe. (The PowerShell
|
|
846
|
+
* `Start-Process` fallback below does its own quoting via `-ArgumentList` and is
|
|
847
|
+
* unaffected.)
|
|
848
|
+
*/
|
|
849
|
+
export function buildWindowsTerminalArgs(worktreePath, shellCommand) {
|
|
850
|
+
const wtEscapedCommand = shellCommand.replace(/;/g, "\\;");
|
|
851
|
+
return ["new-tab", "-d", worktreePath, "powershell.exe", "-NoExit", "-Command", wtEscapedCommand];
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Build the PowerShell `Start-Process` command used as the no-Windows-Terminal
|
|
855
|
+
* fallback. `Start-Process` opens a detached, visible window per ticket (a bare
|
|
856
|
+
* `powershell.exe -Command` invoked through `execFile` cannot). Nested quoting
|
|
857
|
+
* uses PowerShell single-quote escaping.
|
|
858
|
+
*/
|
|
859
|
+
export function buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand) {
|
|
860
|
+
const argumentList = `@('-NoExit', '-Command', ${powershellSquote(shellCommand)})`;
|
|
861
|
+
return (`Start-Process -FilePath 'powershell.exe' ` +
|
|
862
|
+
`-WorkingDirectory ${powershellSquote(worktreePath)} ` +
|
|
863
|
+
`-ArgumentList ${argumentList}`);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Spawn a Windows tab. Prefers Windows Terminal (`wt.exe new-tab`); falls back
|
|
867
|
+
* to `Start-Process powershell.exe` when Windows Terminal is absent. Never uses
|
|
868
|
+
* the Worktrunk `git-wt` binary for tab launching. Returns a structured failure
|
|
869
|
+
* (never throws).
|
|
870
|
+
*/
|
|
871
|
+
export async function spawnWindowsTerminalTab(deps, _terminal, shellCommand, context) {
|
|
872
|
+
const worktreePath = context?.worktreePath;
|
|
873
|
+
if (!worktreePath) {
|
|
874
|
+
return {
|
|
875
|
+
ok: false,
|
|
876
|
+
error: "Windows spawner requires a worktreePath context to open a tab.",
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
if (await isCommandOnPath(deps, WINDOWS_TERMINAL_COMMAND)) {
|
|
880
|
+
const args = buildWindowsTerminalArgs(worktreePath, shellCommand);
|
|
881
|
+
const result = await deps.runCommand(WINDOWS_TERMINAL_COMMAND, args);
|
|
882
|
+
if (commandSucceeded(result))
|
|
883
|
+
return { ok: true };
|
|
884
|
+
const reason = combineCommandOutput(result);
|
|
885
|
+
return {
|
|
886
|
+
ok: false,
|
|
887
|
+
error: `wt.exe failed to open a Windows Terminal tab${reason ? `: ${reason}` : ""}`,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
const powershell = await resolveFirstCommandOnPath(deps, WINDOWS_POWERSHELL_CANDIDATES);
|
|
891
|
+
if (!powershell) {
|
|
892
|
+
return {
|
|
893
|
+
ok: false,
|
|
894
|
+
error: "Windows Terminal (wt.exe) or PowerShell is required to open a tab, but neither was found on PATH.",
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
const fallback = buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand);
|
|
898
|
+
const result = await deps.runCommand(powershell, [
|
|
899
|
+
"-NoProfile",
|
|
900
|
+
"-ExecutionPolicy",
|
|
901
|
+
"Bypass",
|
|
902
|
+
"-Command",
|
|
903
|
+
fallback,
|
|
904
|
+
]);
|
|
905
|
+
if (commandSucceeded(result))
|
|
906
|
+
return { ok: true };
|
|
907
|
+
const reason = combineCommandOutput(result);
|
|
908
|
+
return {
|
|
909
|
+
ok: false,
|
|
910
|
+
error: `PowerShell failed to open a window via Start-Process${reason ? `: ${reason}` : ""}`,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
// Linux tmux spawning (behind the injected boundary)
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
/** Sanitize a value into a tmux-safe identifier (no whitespace, '.', or ':'). */
|
|
917
|
+
export function sanitizeTmuxName(value) {
|
|
918
|
+
const cleaned = value
|
|
919
|
+
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
|
920
|
+
.replace(/^-+/, "")
|
|
921
|
+
.replace(/-+$/, "");
|
|
922
|
+
return cleaned.length > 0 ? cleaned : "ticket";
|
|
923
|
+
}
|
|
924
|
+
/** Safe tmux window name derived from the ticket key. */
|
|
925
|
+
export function tmuxWindowNameForTicket(key) {
|
|
926
|
+
return sanitizeTmuxName(key);
|
|
927
|
+
}
|
|
928
|
+
/** Resolve the tmux session-name prefix (env override, else the default). */
|
|
929
|
+
export function tmuxSessionPrefix(deps) {
|
|
930
|
+
const override = deps.env[TMUX_SESSION_OVERRIDE_ENV];
|
|
931
|
+
if (override !== undefined) {
|
|
932
|
+
const trimmed = override.trim();
|
|
933
|
+
if (trimmed.length > 0)
|
|
934
|
+
return trimmed;
|
|
935
|
+
}
|
|
936
|
+
return DEFAULT_TMUX_SESSION_PREFIX;
|
|
937
|
+
}
|
|
938
|
+
/** One detached tmux session per ticket: `<prefix>-<safe-key>`. */
|
|
939
|
+
export function tmuxSessionNameForTicket(deps, key) {
|
|
940
|
+
return `${tmuxSessionPrefix(deps)}-${sanitizeTmuxName(key)}`;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Wrap the agent shell command so the tmux pane stays open after the agent
|
|
944
|
+
* exits or crashes (otherwise tmux closes the window and the user loses all
|
|
945
|
+
* output). `exec $SHELL` replaces the pane with an interactive login shell.
|
|
946
|
+
*/
|
|
947
|
+
export function buildTmuxPaneCommand(shellCommand) {
|
|
948
|
+
return `${shellCommand}; exec $SHELL`;
|
|
949
|
+
}
|
|
950
|
+
/** Args for `tmux new-session -d -s <session> -n <window> -c <path> <cmd>`. */
|
|
951
|
+
export function buildTmuxNewSessionArgs(session, window, worktreePath, paneCommand) {
|
|
952
|
+
return ["new-session", "-d", "-s", session, "-n", window, "-c", worktreePath, paneCommand];
|
|
953
|
+
}
|
|
954
|
+
/** Args for `tmux new-window -t <session> -n <window> -c <path> <cmd>`. */
|
|
955
|
+
export function buildTmuxNewWindowArgs(session, window, worktreePath, paneCommand) {
|
|
956
|
+
return ["new-window", "-t", session, "-n", window, "-c", worktreePath, paneCommand];
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Spawn a Linux tmux session for one ticket. Creates one detached session per
|
|
960
|
+
* ticket (adding a window if that session already exists, e.g. on re-run),
|
|
961
|
+
* keeping the pane open after the agent exits. Emits a clear, actionable error
|
|
962
|
+
* when tmux is missing. No per-emulator detection. Returns a structured failure
|
|
963
|
+
* (never throws).
|
|
964
|
+
*/
|
|
965
|
+
export async function spawnLinuxTmuxTerminalTab(deps, _terminal, shellCommand, context) {
|
|
966
|
+
const worktreePath = context?.worktreePath;
|
|
967
|
+
const key = context?.key;
|
|
968
|
+
if (!worktreePath || !key) {
|
|
969
|
+
return {
|
|
970
|
+
ok: false,
|
|
971
|
+
error: "Linux tmux spawner requires a worktreePath context to open a session.",
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
if (!(await isCommandOnPath(deps, TMUX_COMMAND))) {
|
|
975
|
+
return {
|
|
976
|
+
ok: false,
|
|
977
|
+
error: "tmux is required to spawn Linux sessions but was not found on PATH. Install tmux and retry.",
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
const session = tmuxSessionNameForTicket(deps, key);
|
|
981
|
+
const window = tmuxWindowNameForTicket(key);
|
|
982
|
+
const paneCommand = buildTmuxPaneCommand(shellCommand);
|
|
983
|
+
const hasSession = await deps.runCommand(TMUX_COMMAND, ["has-session", "-t", session]);
|
|
984
|
+
const args = commandSucceeded(hasSession)
|
|
985
|
+
? buildTmuxNewWindowArgs(session, window, worktreePath, paneCommand)
|
|
986
|
+
: buildTmuxNewSessionArgs(session, window, worktreePath, paneCommand);
|
|
987
|
+
const result = await deps.runCommand(TMUX_COMMAND, args);
|
|
988
|
+
if (commandSucceeded(result))
|
|
989
|
+
return { ok: true };
|
|
990
|
+
const reason = combineCommandOutput(result);
|
|
991
|
+
return {
|
|
992
|
+
ok: false,
|
|
993
|
+
error: `tmux failed to create a session/window${reason ? `: ${reason}` : ""}`,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
// ---------------------------------------------------------------------------
|
|
997
|
+
// Unsupported-platform spawner
|
|
998
|
+
// ---------------------------------------------------------------------------
|
|
999
|
+
/** Spawner for unsupported platforms: always a structured failure, never throws. */
|
|
1000
|
+
export async function spawnUnsupportedPlatformTerminalTab(deps, _terminal, _shellCommand, _context) {
|
|
1001
|
+
return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
|
|
1002
|
+
}
|
|
1003
|
+
// ---------------------------------------------------------------------------
|
|
1004
|
+
// Tab spawning across created worktrees
|
|
1005
|
+
// ---------------------------------------------------------------------------
|
|
1006
|
+
/**
|
|
1007
|
+
* Open one tab/session per successfully-created worktree, building each shell
|
|
1008
|
+
* command with the platform-correct `buildShellCommand` and passing per-ticket
|
|
1009
|
+
* context to the spawner. `created` rows become `spawned` (or `spawn-failed`);
|
|
1010
|
+
* `create-failed` rows pass through untouched. The `terminal` choice is only
|
|
1011
|
+
* consumed by the macOS spawner; Windows/Linux spawners intentionally ignore it.
|
|
1012
|
+
*/
|
|
1013
|
+
export async function spawnTabsForCreatedWorktrees(deps, rows, terminal, buildShellCommand) {
|
|
1014
|
+
const out = [];
|
|
1015
|
+
for (const row of rows) {
|
|
1016
|
+
if (row.status !== "created" || !row.path) {
|
|
1017
|
+
out.push(row);
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
const shellCommand = buildShellCommand(row.key, row.path);
|
|
1021
|
+
const result = await deps.spawnTerminalTab(deps, terminal, shellCommand, {
|
|
1022
|
+
key: row.key,
|
|
1023
|
+
worktreePath: row.path,
|
|
1024
|
+
});
|
|
1025
|
+
if (result.ok) {
|
|
1026
|
+
out.push({ ...row, status: "spawned" });
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
out.push({ ...row, status: "spawn-failed", error: result.error });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return out;
|
|
1033
|
+
}
|
|
1034
|
+
// ---------------------------------------------------------------------------
|
|
1035
|
+
// Dry-run, summary, orchestration, CLI wrapper
|
|
1036
|
+
// ---------------------------------------------------------------------------
|
|
1037
|
+
/** Build one dry-run summary row per ticket with resolved branches. */
|
|
1038
|
+
export function buildDryRunResults(keys, overrides) {
|
|
1039
|
+
return keys.map((key) => ({
|
|
1040
|
+
key,
|
|
1041
|
+
branch: resolveBranchForTicket(key, overrides),
|
|
1042
|
+
status: "dry-run",
|
|
1043
|
+
}));
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Pure platform resolution for dry-run rendering only (no side effects). Closes
|
|
1047
|
+
* over the selected `agent` to build the preview command. Uses `git-wt` +
|
|
1048
|
+
* PowerShell on Windows, `wt` + POSIX on macOS/Linux, and a non-throwing `wt` +
|
|
1049
|
+
* POSIX fallback for unsupported platforms.
|
|
1050
|
+
*/
|
|
1051
|
+
export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env) {
|
|
1052
|
+
return {
|
|
1053
|
+
worktrunkBinary: resolveWorktrunkBinary(platform, env),
|
|
1054
|
+
buildAgentShellCommand: (key, worktreePath) => buildAgentShellCommand(agent, key, worktreePath, platform),
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Build the user-facing dry-run detail lines for one ticket, rendering the
|
|
1059
|
+
* platform-correct Worktrunk binary and the selected agent's shell command. Pure
|
|
1060
|
+
* platform formatting only — no preflight, no routing failures.
|
|
1061
|
+
*/
|
|
1062
|
+
export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env) {
|
|
1063
|
+
const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env);
|
|
1064
|
+
const wtArgs = buildWtSwitchArgs(branch, false);
|
|
1065
|
+
const agentInvocation = build(key, "<worktree-path>");
|
|
1066
|
+
return [
|
|
1067
|
+
`DRY-RUN: ${key} -> branch=${branch}`,
|
|
1068
|
+
`DRY-RUN: ${worktrunkBinary} ${wtArgs.join(" ")}`,
|
|
1069
|
+
`DRY-RUN: ${agentInvocation}`,
|
|
1070
|
+
];
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Format the final summary report. Emits a delimited `Summary` section with one
|
|
1074
|
+
* stable parseable line per ticket (`KEY branch=BRANCH status=STATUS`, plus
|
|
1075
|
+
* ` path=PATH` when available) followed by warning lines for any create/spawn
|
|
1076
|
+
* failures.
|
|
1077
|
+
*/
|
|
1078
|
+
export function formatSummaryReport(rows) {
|
|
1079
|
+
const lines = ["Summary:"];
|
|
1080
|
+
for (const row of rows) {
|
|
1081
|
+
let line = `${row.key} branch=${row.branch} status=${row.status}`;
|
|
1082
|
+
if (row.path)
|
|
1083
|
+
line += ` path=${row.path}`;
|
|
1084
|
+
lines.push(line);
|
|
1085
|
+
}
|
|
1086
|
+
const failures = rows.filter((r) => r.status === "create-failed" || r.status === "spawn-failed");
|
|
1087
|
+
if (failures.length > 0) {
|
|
1088
|
+
lines.push("");
|
|
1089
|
+
lines.push("Warnings:");
|
|
1090
|
+
for (const row of failures) {
|
|
1091
|
+
lines.push(` ${row.key}: ${row.error ?? row.status}`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return lines.join("\n");
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Top-level orchestration. Dry runs short-circuit to dry-run rows (before any
|
|
1098
|
+
* preflight or routing). Otherwise run preflight, resolve the platform config,
|
|
1099
|
+
* refresh main (unless disabled), create worktrees with throttling using the
|
|
1100
|
+
* resolved Worktrunk binary, then spawn tabs/sessions for successful worktrees
|
|
1101
|
+
* using the platform-correct shell builder. Global preflight/refresh failures
|
|
1102
|
+
* are returned as `{ ok: false }`; per-ticket failures stay in the rows.
|
|
1103
|
+
*/
|
|
1104
|
+
export async function orchestrateStartTickets(deps, options) {
|
|
1105
|
+
if (options.dryRun) {
|
|
1106
|
+
return { ok: true, rows: buildDryRunResults(options.keys, options.branchOverrides) };
|
|
1107
|
+
}
|
|
1108
|
+
// Resolve the selected agent once, before any side effects, so an invalid
|
|
1109
|
+
// agent fails fast with a clear error and no Worktrunk/terminal work occurs.
|
|
1110
|
+
const agent = resolveAgentSpec(options.agentName);
|
|
1111
|
+
if (!agent) {
|
|
1112
|
+
return {
|
|
1113
|
+
ok: false,
|
|
1114
|
+
error: `Unknown agent: '${options.agentName}'. Valid agents: ${formatValidAgentNames()}.`,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const preflight = await runPreflight(deps, options);
|
|
1118
|
+
if (!preflight.ok)
|
|
1119
|
+
return { ok: false, error: preflight.error };
|
|
1120
|
+
const platformConfig = resolveStartTicketsPlatformConfig(deps, agent);
|
|
1121
|
+
if (!platformConfig.ok)
|
|
1122
|
+
return { ok: false, error: platformConfig.error };
|
|
1123
|
+
const refresh = await refreshMainBranch(deps, options);
|
|
1124
|
+
if (!refresh.ok)
|
|
1125
|
+
return { ok: false, error: refresh.error };
|
|
1126
|
+
const created = await createWorktrees(deps, options, platformConfig.config.worktrunkBinary);
|
|
1127
|
+
const terminal = detectTerminal(options.terminal, deps.env);
|
|
1128
|
+
const rows = await spawnTabsForCreatedWorktrees(deps, created, terminal, platformConfig.config.buildAgentShellCommand);
|
|
1129
|
+
return { ok: true, rows };
|
|
1130
|
+
}
|
|
1131
|
+
/** Platform-specific guidance printed when one or more tabs fail to spawn. */
|
|
1132
|
+
function spawnFailureHintForPlatform(platform) {
|
|
1133
|
+
if (platform === "darwin") {
|
|
1134
|
+
return ("One or more tabs failed to spawn. If osascript reported a permission error, grant the " +
|
|
1135
|
+
"required macOS permissions under System Settings -> Privacy & Security, then re-run: " +
|
|
1136
|
+
"Automation (to let the terminal app be controlled) and, for the Terminal.app path, " +
|
|
1137
|
+
"Accessibility (the new-tab step sends Cmd-T via System Events keystrokes, which Automation " +
|
|
1138
|
+
"alone does not cover).");
|
|
1139
|
+
}
|
|
1140
|
+
if (platform === "win32") {
|
|
1141
|
+
return ("One or more tabs failed to spawn. Ensure Windows Terminal (wt.exe) or PowerShell is " +
|
|
1142
|
+
"installed and on PATH; see the per-ticket warnings above for details.");
|
|
1143
|
+
}
|
|
1144
|
+
if (platform === "linux") {
|
|
1145
|
+
return ("One or more tabs failed to spawn. Ensure tmux is installed and on PATH; see the " +
|
|
1146
|
+
"per-ticket warnings above for details.");
|
|
1147
|
+
}
|
|
1148
|
+
return "One or more tabs failed to spawn; see the per-ticket warnings above for details.";
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* CLI entry for the `start-tickets` subcommand. Returns a process exit code.
|
|
1152
|
+
* Help returns 0; validation / global failures return 1; a normal run returns
|
|
1153
|
+
* 0 even when individual tickets have create-failed / spawn-failed statuses.
|
|
1154
|
+
*/
|
|
1155
|
+
export async function runStartTicketsCli(argv, overrides = {}) {
|
|
1156
|
+
const log = overrides.log ?? ((m) => console.log(m));
|
|
1157
|
+
const errorLog = overrides.errorLog ?? ((m) => console.error(m));
|
|
1158
|
+
const parsed = parseStartTicketsArgs(argv);
|
|
1159
|
+
if (parsed.status === "help") {
|
|
1160
|
+
log(parsed.usage);
|
|
1161
|
+
return 0;
|
|
1162
|
+
}
|
|
1163
|
+
if (parsed.status === "error") {
|
|
1164
|
+
errorLog(`Error: ${parsed.message}`);
|
|
1165
|
+
errorLog("");
|
|
1166
|
+
errorLog(getStartTicketsUsage());
|
|
1167
|
+
return 1;
|
|
1168
|
+
}
|
|
1169
|
+
const options = parsed.options;
|
|
1170
|
+
const deps = overrides.deps ?? createDefaultStartTicketsDeps();
|
|
1171
|
+
const orchestrate = overrides.orchestrate ?? orchestrateStartTickets;
|
|
1172
|
+
// The parser validates `--agent`, so this resolves to a real spec; the guard
|
|
1173
|
+
// is defensive only.
|
|
1174
|
+
const agent = resolveAgentSpec(options.agentName);
|
|
1175
|
+
if (!agent) {
|
|
1176
|
+
errorLog(`Error: Unknown agent: '${options.agentName}'. Valid agents: ${formatValidAgentNames()}.`);
|
|
1177
|
+
return 1;
|
|
1178
|
+
}
|
|
1179
|
+
if (options.dryRun) {
|
|
1180
|
+
for (const key of options.keys) {
|
|
1181
|
+
const branch = resolveBranchForTicket(key, options.branchOverrides);
|
|
1182
|
+
for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env)) {
|
|
1183
|
+
log(line);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
log("");
|
|
1187
|
+
}
|
|
1188
|
+
const result = await orchestrate(deps, options);
|
|
1189
|
+
if (!result.ok) {
|
|
1190
|
+
errorLog(`Error: ${result.error}`);
|
|
1191
|
+
return 1;
|
|
1192
|
+
}
|
|
1193
|
+
log(formatSummaryReport(result.rows));
|
|
1194
|
+
// Linux: each spawned ticket runs in a detached tmux session — tell the user
|
|
1195
|
+
// how to attach.
|
|
1196
|
+
const spawned = result.rows.filter((r) => r.status === "spawned");
|
|
1197
|
+
if (deps.platform === "linux" && spawned.length > 0) {
|
|
1198
|
+
log("");
|
|
1199
|
+
log("Linux: each ticket runs in a detached tmux session. Attach with:");
|
|
1200
|
+
for (const row of spawned) {
|
|
1201
|
+
log(` tmux attach -t ${tmuxSessionNameForTicket(deps, row.key)}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const spawnFailures = result.rows.filter((r) => r.status === "spawn-failed");
|
|
1205
|
+
if (spawnFailures.length > 0) {
|
|
1206
|
+
errorLog("");
|
|
1207
|
+
errorLog(spawnFailureHintForPlatform(deps.platform));
|
|
1208
|
+
}
|
|
1209
|
+
return 0;
|
|
1210
|
+
}
|