@clipboard-health/groundcrew 4.0.3 → 4.2.0
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 +37 -13
- package/crew.config.example.ts +5 -18
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +27 -49
- package/dist/commands/resumeWorkspace.d.ts.map +1 -1
- package/dist/commands/resumeWorkspace.js +1 -2
- package/dist/commands/setupWorkspace.d.ts.map +1 -1
- package/dist/commands/setupWorkspace.js +1 -7
- package/dist/commands/upgrade.d.ts +0 -11
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/commands/upgrade.js +14 -100
- package/dist/lib/agentLaunch.d.ts +0 -6
- package/dist/lib/agentLaunch.d.ts.map +1 -1
- package/dist/lib/agentLaunch.js +1 -12
- package/dist/lib/cmuxAdapter.d.ts +8 -0
- package/dist/lib/cmuxAdapter.d.ts.map +1 -0
- package/dist/lib/cmuxAdapter.js +163 -0
- package/dist/lib/config.d.ts +2 -76
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +29 -102
- package/dist/lib/launchCommand.d.ts +3 -3
- package/dist/lib/sandboxName.d.ts +9 -0
- package/dist/lib/sandboxName.d.ts.map +1 -0
- package/dist/lib/sandboxName.js +12 -0
- package/dist/lib/tmuxAdapter.d.ts +9 -0
- package/dist/lib/tmuxAdapter.d.ts.map +1 -0
- package/dist/lib/tmuxAdapter.js +156 -0
- package/dist/lib/workspaceAdapter.d.ts +79 -0
- package/dist/lib/workspaceAdapter.d.ts.map +1 -0
- package/dist/lib/workspaceAdapter.js +17 -0
- package/dist/lib/workspaces.d.ts +7 -55
- package/dist/lib/workspaces.d.ts.map +1 -1
- package/dist/lib/workspaces.js +8 -404
- package/package.json +1 -2
- package/dist/commands/sandbox/auth.d.ts +0 -3
- package/dist/commands/sandbox/auth.d.ts.map +0 -1
- package/dist/commands/sandbox/auth.js +0 -227
- package/dist/commands/sandbox/index.d.ts +0 -2
- package/dist/commands/sandbox/index.d.ts.map +0 -1
- package/dist/commands/sandbox/index.js +0 -47
- package/dist/commands/sandbox/inspect.d.ts +0 -2
- package/dist/commands/sandbox/inspect.d.ts.map +0 -1
- package/dist/commands/sandbox/inspect.js +0 -18
- package/dist/commands/sandbox/lifecycle.d.ts +0 -7
- package/dist/commands/sandbox/lifecycle.d.ts.map +0 -1
- package/dist/commands/sandbox/lifecycle.js +0 -68
- package/dist/commands/sandbox/model.d.ts +0 -10
- package/dist/commands/sandbox/model.d.ts.map +0 -1
- package/dist/commands/sandbox/model.js +0 -37
- package/dist/commands/sandbox/picker.d.ts +0 -20
- package/dist/commands/sandbox/picker.d.ts.map +0 -1
- package/dist/commands/sandbox/picker.js +0 -23
- package/dist/commands/setupRepos.d.ts +0 -44
- package/dist/commands/setupRepos.d.ts.map +0 -1
- package/dist/commands/setupRepos.js +0 -212
- package/dist/lib/dockerSandbox.d.ts +0 -43
- package/dist/lib/dockerSandbox.d.ts.map +0 -1
- package/dist/lib/dockerSandbox.js +0 -69
- package/dist/lib/sandboxGitDefaults.d.ts +0 -10
- package/dist/lib/sandboxGitDefaults.d.ts.map +0 -1
- package/dist/lib/sandboxGitDefaults.js +0 -31
- package/dist/lib/upgrade.d.ts +0 -66
- package/dist/lib/upgrade.d.ts.map +0 -1
- package/dist/lib/upgrade.js +0 -178
package/dist/lib/workspaces.js
CHANGED
|
@@ -1,263 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workspace
|
|
2
|
+
* Workspace facade — opens/lists/closes the host-side terminal session
|
|
3
3
|
* that runs an agent for one ticket. `Workspace.name` is the ticket id;
|
|
4
|
-
* callers key on it.
|
|
5
|
-
*
|
|
4
|
+
* callers key on it. The cmux and tmux backends live in their own files
|
|
5
|
+
* (`cmuxAdapter.ts`, `tmuxAdapter.ts`) behind the shared `Adapter`
|
|
6
|
+
* interface in `workspaceAdapter.ts`; this module resolves which one to
|
|
7
|
+
* use, caches it per config, and exposes the `workspaces` API.
|
|
6
8
|
*/
|
|
7
|
-
import {
|
|
9
|
+
import { cmuxAdapter } from "./cmuxAdapter.js";
|
|
8
10
|
import { detectHostCapabilities } from "./host.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
async function runWorkspaceCommand(command, arguments_, signal) {
|
|
12
|
-
return signal === undefined
|
|
13
|
-
? await runCommandAsync(command, arguments_)
|
|
14
|
-
: await runCommandAsync(command, arguments_, { signal });
|
|
15
|
-
}
|
|
16
|
-
function isSignalAborted(signal) {
|
|
17
|
-
return signal?.aborted === true;
|
|
18
|
-
}
|
|
19
|
-
function parseCmuxList(output) {
|
|
20
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json list-workspaces always emits this shape
|
|
21
|
-
const parsed = JSON.parse(output);
|
|
22
|
-
const items = [];
|
|
23
|
-
/* v8 ignore next @preserve -- cmux always emits a workspaces field; default keeps the loop safe */
|
|
24
|
-
for (const ws of parsed.workspaces ?? []) {
|
|
25
|
-
if (typeof ws.title !== "string" || ws.title.length === 0) {
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
const id = pickCmuxId(ws);
|
|
29
|
-
if (id === undefined) {
|
|
30
|
-
log(`cmux list-workspaces returned workspace "${ws.title}" without a usable id or ref; skipping`);
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
items.push({ title: ws.title, id });
|
|
34
|
-
}
|
|
35
|
-
return items;
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* The stable workspace handle cmux v2 expects in JSON-RPC params. Prefer
|
|
39
|
-
* the UUID; fall back to the legacy `workspace:N` short ref when older
|
|
40
|
-
* cmux builds don't surface it. Returns `undefined` when neither is
|
|
41
|
-
* available — cmux v2 `workspace.close` rejects titles, so we must never
|
|
42
|
-
* forward `title` as a workspace handle.
|
|
43
|
-
*/
|
|
44
|
-
function pickCmuxId(ws) {
|
|
45
|
-
if (typeof ws.id === "string" && ws.id.length > 0) {
|
|
46
|
-
return ws.id;
|
|
47
|
-
}
|
|
48
|
-
if (typeof ws.ref === "string" && ws.ref.length > 0) {
|
|
49
|
-
return ws.ref;
|
|
50
|
-
}
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
async function listCmuxRaw(signal) {
|
|
54
|
-
try {
|
|
55
|
-
return parseCmuxList(await runWorkspaceCommand("cmux", ["--json", "list-workspaces"], signal));
|
|
56
|
-
}
|
|
57
|
-
catch (error) {
|
|
58
|
-
if (isSignalAborted(signal)) {
|
|
59
|
-
throw error;
|
|
60
|
-
}
|
|
61
|
-
log(`cmux list-workspaces failed: ${errorMessage(error)}`);
|
|
62
|
-
return undefined;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
function extractCmuxOpenId(output) {
|
|
66
|
-
try {
|
|
67
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json prints a workspace_id/ref object
|
|
68
|
-
const parsed = JSON.parse(output);
|
|
69
|
-
const uuid = parsed.workspace_id ?? parsed.id ?? "";
|
|
70
|
-
if (uuid.length > 0) {
|
|
71
|
-
return uuid;
|
|
72
|
-
}
|
|
73
|
-
const ref = parsed.workspace_ref ?? parsed.ref ?? "";
|
|
74
|
-
if (ref.length > 0) {
|
|
75
|
-
return ref;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
/* not JSON; fall through to regex */
|
|
80
|
-
}
|
|
81
|
-
const match = /workspace:\d+/.exec(output);
|
|
82
|
-
return match ? match[0] : undefined;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Inspect `cmux current-workspace`. When groundcrew is itself launched
|
|
86
|
-
* inside a cmux SSH workspace, `workspace.create` lands the new workspace
|
|
87
|
-
* on the local (macOS) cmux app rather than the remote where the agent's
|
|
88
|
-
* worktree lives. We can't replicate cmux's full SSH bootstrap
|
|
89
|
-
* (relay_port, daemon, etc.) from the remote side, so we instead wrap the
|
|
90
|
-
* agent launch command in a plain `ssh` to the same destination. Returns
|
|
91
|
-
* `undefined` when there is nothing to inherit, leaving callers free to
|
|
92
|
-
* launch locally as usual.
|
|
93
|
-
*/
|
|
94
|
-
async function probeCurrentCmuxRemote(signal) {
|
|
95
|
-
if (readEnvironmentVariable("CMUX_WORKSPACE_ID") === undefined) {
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
|
-
let output;
|
|
99
|
-
try {
|
|
100
|
-
output = await runWorkspaceCommand("cmux", ["--json", "current-workspace"], signal);
|
|
101
|
-
}
|
|
102
|
-
catch (error) {
|
|
103
|
-
if (isSignalAborted(signal)) {
|
|
104
|
-
throw error;
|
|
105
|
-
}
|
|
106
|
-
// CMUX_WORKSPACE_ID is set, so we are inside a cmux workspace and a
|
|
107
|
-
// probe failure means we cannot tell whether this is an SSH context.
|
|
108
|
-
// Silently degrading to the local path would point cmux at a working
|
|
109
|
-
// directory that lives on a remote host; surface the failure instead
|
|
110
|
-
// so the caller can roll the worktree back rather than launch into
|
|
111
|
-
// the void.
|
|
112
|
-
throw new Error(`cmux current-workspace probe failed while CMUX_WORKSPACE_ID is set: ${errorMessage(error)}`, { cause: error });
|
|
113
|
-
}
|
|
114
|
-
try {
|
|
115
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cmux --json current-workspace shape per v2 API
|
|
116
|
-
const parsed = JSON.parse(output);
|
|
117
|
-
const remote = parsed.workspace?.remote;
|
|
118
|
-
if (remote === undefined ||
|
|
119
|
-
remote.connected !== true ||
|
|
120
|
-
remote.transport !== "ssh" ||
|
|
121
|
-
typeof remote.destination !== "string" ||
|
|
122
|
-
remote.destination.length === 0) {
|
|
123
|
-
return undefined;
|
|
124
|
-
}
|
|
125
|
-
const inherited = { destination: remote.destination };
|
|
126
|
-
if (typeof remote.port === "number") {
|
|
127
|
-
inherited.port = remote.port;
|
|
128
|
-
}
|
|
129
|
-
if (typeof remote.identity_file === "string" && remote.identity_file.length > 0) {
|
|
130
|
-
inherited.identity_file = remote.identity_file;
|
|
131
|
-
}
|
|
132
|
-
if (Array.isArray(remote.ssh_options) && remote.ssh_options.length > 0) {
|
|
133
|
-
inherited.ssh_options = remote.ssh_options;
|
|
134
|
-
}
|
|
135
|
-
return inherited;
|
|
136
|
-
}
|
|
137
|
-
catch (error) {
|
|
138
|
-
// Same reasoning as the command-failure branch above: with
|
|
139
|
-
// CMUX_WORKSPACE_ID set, malformed JSON means we cannot decide
|
|
140
|
-
// between local and SSH context, so refuse rather than silently
|
|
141
|
-
// launching at the wrong working directory.
|
|
142
|
-
throw new Error(`cmux current-workspace returned malformed output while CMUX_WORKSPACE_ID is set: ${errorMessage(error)}`, { cause: error });
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Compose an `ssh -t <destination> -- <cd && cmd>` invocation that lands
|
|
147
|
-
* a new cmux workspace's terminal on the same SSH remote where
|
|
148
|
-
* groundcrew is running. Path-bearing fields (`cwd`, the launch script
|
|
149
|
-
* inside `command`) stay valid because the remote shell evaluates them.
|
|
150
|
-
* The outermost return value is a single shell string suitable for
|
|
151
|
-
* `cmux new-workspace --command`.
|
|
152
|
-
*/
|
|
153
|
-
function buildSshWrappedCommand(spec, remote) {
|
|
154
|
-
const remoteShell = `cd ${shellSingleQuote(spec.cwd)} && ${spec.command}`;
|
|
155
|
-
const sshTokens = ["ssh", "-t"];
|
|
156
|
-
if (remote.port !== undefined) {
|
|
157
|
-
sshTokens.push("-p", String(remote.port));
|
|
158
|
-
}
|
|
159
|
-
if (remote.identity_file !== undefined) {
|
|
160
|
-
sshTokens.push("-i", shellSingleQuote(remote.identity_file));
|
|
161
|
-
}
|
|
162
|
-
for (const option of remote.ssh_options ?? []) {
|
|
163
|
-
sshTokens.push("-o", shellSingleQuote(option));
|
|
164
|
-
}
|
|
165
|
-
sshTokens.push(shellSingleQuote(remote.destination), "--", shellSingleQuote(remoteShell));
|
|
166
|
-
return sshTokens.join(" ");
|
|
167
|
-
}
|
|
168
|
-
async function applyCmuxStatus(workspaceId, status, signal) {
|
|
169
|
-
const arguments_ = ["set-status", "model", status.text];
|
|
170
|
-
if (status.icon !== undefined) {
|
|
171
|
-
arguments_.push("--icon", status.icon);
|
|
172
|
-
}
|
|
173
|
-
if (status.color !== undefined) {
|
|
174
|
-
arguments_.push("--color", status.color);
|
|
175
|
-
}
|
|
176
|
-
arguments_.push("--workspace", workspaceId);
|
|
177
|
-
await runWorkspaceCommand("cmux", arguments_, signal);
|
|
178
|
-
}
|
|
179
|
-
async function closeCmuxWorkspace(workspaceId, signal) {
|
|
180
|
-
await runWorkspaceCommand("cmux", ["close-workspace", "--workspace", workspaceId], signal);
|
|
181
|
-
}
|
|
182
|
-
function isCmuxSetStatusUnsupported(error) {
|
|
183
|
-
return errorMessage(error).includes('unknown command "set-status"');
|
|
184
|
-
}
|
|
185
|
-
const cmuxAdapter = {
|
|
186
|
-
async open(spec, signal) {
|
|
187
|
-
const inheritedRemote = await probeCurrentCmuxRemote(signal);
|
|
188
|
-
const newWorkspaceArguments = ["--json", "new-workspace", "--name", spec.name];
|
|
189
|
-
if (inheritedRemote === undefined) {
|
|
190
|
-
newWorkspaceArguments.push("--cwd", spec.cwd, "--command", spec.command);
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
// Skip --cwd: the path is on the SSH remote and would fall back to
|
|
194
|
-
// $HOME (macOS) when cmux tries to chdir locally. The wrapped ssh
|
|
195
|
-
// command does its own `cd` on the remote side.
|
|
196
|
-
newWorkspaceArguments.push("--command", buildSshWrappedCommand(spec, inheritedRemote));
|
|
197
|
-
}
|
|
198
|
-
const output = await runWorkspaceCommand("cmux", newWorkspaceArguments, signal);
|
|
199
|
-
const workspaceId = extractCmuxOpenId(output);
|
|
200
|
-
if (workspaceId === undefined) {
|
|
201
|
-
log(`cmux new-workspace returned unrecognized output for ${spec.name}; if a workspace was created, run \`cmux close-workspace\` manually.`);
|
|
202
|
-
throw new Error(`Unexpected cmux output: ${output}`);
|
|
203
|
-
}
|
|
204
|
-
if (spec.status !== undefined) {
|
|
205
|
-
try {
|
|
206
|
-
await applyCmuxStatus(workspaceId, spec.status, signal);
|
|
207
|
-
}
|
|
208
|
-
catch (error) {
|
|
209
|
-
// Status pills are best-effort. cmux v2+ dropped `set-status` entirely,
|
|
210
|
-
// so swallow that specific gap silently; surface anything else so a real
|
|
211
|
-
// regression doesn't hide behind the same swallow.
|
|
212
|
-
if (!isCmuxSetStatusUnsupported(error)) {
|
|
213
|
-
log(`cmux set-status failed for ${spec.name} (continuing): ${errorMessage(error)}`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
},
|
|
218
|
-
async list(signal) {
|
|
219
|
-
const raw = await listCmuxRaw(signal);
|
|
220
|
-
return raw?.map((ws) => ({ name: ws.title }));
|
|
221
|
-
},
|
|
222
|
-
async close(name, signal) {
|
|
223
|
-
const raw = await listCmuxRaw(signal);
|
|
224
|
-
if (raw === undefined) {
|
|
225
|
-
// cmux v2 `workspace.close` rejects titles, so forwarding `name`
|
|
226
|
-
// would always fail. The list failure has already been logged by
|
|
227
|
-
// `listCmuxRaw`; bail rather than guarantee a downstream error.
|
|
228
|
-
log(`cmux close-workspace skipped for ${name}: list-workspaces failed, no usable id`);
|
|
229
|
-
return { kind: "unavailable" };
|
|
230
|
-
}
|
|
231
|
-
const match = raw.find((ws) => ws.title === name);
|
|
232
|
-
if (match === undefined) {
|
|
233
|
-
return { kind: "missing" };
|
|
234
|
-
}
|
|
235
|
-
try {
|
|
236
|
-
await closeCmuxWorkspace(match.id, signal);
|
|
237
|
-
return { kind: "closed" };
|
|
238
|
-
}
|
|
239
|
-
catch (error) {
|
|
240
|
-
if (isSignalAborted(signal)) {
|
|
241
|
-
throw error;
|
|
242
|
-
}
|
|
243
|
-
const remaining = await listCmuxRaw(signal);
|
|
244
|
-
if (remaining === undefined) {
|
|
245
|
-
return { kind: "unavailable", error };
|
|
246
|
-
}
|
|
247
|
-
const isStillPresent = remaining.some((ws) => ws.title === name);
|
|
248
|
-
if (!isStillPresent) {
|
|
249
|
-
return { kind: "closed" };
|
|
250
|
-
}
|
|
251
|
-
throw error;
|
|
252
|
-
}
|
|
253
|
-
},
|
|
254
|
-
accessHint(_name) {
|
|
255
|
-
// cmux is a TUI; users surface workspaces by launching the cmux app,
|
|
256
|
-
// not a shell command. No useful hint to emit.
|
|
257
|
-
// oxlint-disable-next-line unicorn/no-useless-undefined -- explicit signal that the backend has no hint
|
|
258
|
-
return undefined;
|
|
259
|
-
},
|
|
260
|
-
};
|
|
11
|
+
import { tmuxAdapter } from "./tmuxAdapter.js";
|
|
12
|
+
import { isSignalAborted, } from "./workspaceAdapter.js";
|
|
261
13
|
export function resolveWorkspaceKind(arguments_) {
|
|
262
14
|
const { config, host } = arguments_;
|
|
263
15
|
const requested = config.workspaceKind;
|
|
@@ -290,154 +42,6 @@ function failIfBinaryUnavailable(kind, host) {
|
|
|
290
42
|
throw new Error(`workspaceKind '${kind}' is set but the ${kind} binary is not on PATH. Install ${kind} or change the setting.`);
|
|
291
43
|
}
|
|
292
44
|
}
|
|
293
|
-
const TMUX_SESSION = "groundcrew";
|
|
294
|
-
// `tmux new-session -d -s …` always creates one initial window. Without
|
|
295
|
-
// `-n`, that window is named after the running shell (e.g. "0" / "zsh") and
|
|
296
|
-
// would surface from `list()` as a phantom workspace. We name it with this
|
|
297
|
-
// sentinel and filter it out — it stays around as a placeholder so the
|
|
298
|
-
// session doesn't collapse when the last ticket window closes.
|
|
299
|
-
const TMUX_IDLE_WINDOW = "_groundcrew_idle";
|
|
300
|
-
function tmuxTarget(name) {
|
|
301
|
-
return `${TMUX_SESSION}:${name}`;
|
|
302
|
-
}
|
|
303
|
-
function isTmuxNotFoundError(error) {
|
|
304
|
-
// runCommand surfaces the child's stderr in error.message, so the "no
|
|
305
|
-
// server" / "missing session" / "can't find window" signatures are visible
|
|
306
|
-
// without a separate stderr probe.
|
|
307
|
-
const message = errorMessage(error);
|
|
308
|
-
return (message.includes("no server running") ||
|
|
309
|
-
message.includes("can't find session") ||
|
|
310
|
-
message.includes("can't find window"));
|
|
311
|
-
}
|
|
312
|
-
async function probeTmuxList(format, signal) {
|
|
313
|
-
try {
|
|
314
|
-
return {
|
|
315
|
-
status: "ok",
|
|
316
|
-
output: await runWorkspaceCommand("tmux", ["list-windows", "-t", TMUX_SESSION, "-F", format], signal),
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
catch (error) {
|
|
320
|
-
if (isSignalAborted(signal)) {
|
|
321
|
-
throw error;
|
|
322
|
-
}
|
|
323
|
-
if (isTmuxNotFoundError(error)) {
|
|
324
|
-
return { status: "missing" };
|
|
325
|
-
}
|
|
326
|
-
return { status: "failed", reason: errorMessage(error) };
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
async function ensureTmuxSession(signal) {
|
|
330
|
-
try {
|
|
331
|
-
await runWorkspaceCommand("tmux", ["has-session", "-t", TMUX_SESSION], signal);
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
catch (error) {
|
|
335
|
-
if (isSignalAborted(signal)) {
|
|
336
|
-
throw error;
|
|
337
|
-
}
|
|
338
|
-
/* session missing or server down; create it */
|
|
339
|
-
}
|
|
340
|
-
try {
|
|
341
|
-
await runWorkspaceCommand("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", TMUX_IDLE_WINDOW], signal);
|
|
342
|
-
}
|
|
343
|
-
catch (error) {
|
|
344
|
-
if (isSignalAborted(signal)) {
|
|
345
|
-
throw error;
|
|
346
|
-
}
|
|
347
|
-
try {
|
|
348
|
-
await runWorkspaceCommand("tmux", ["has-session", "-t", TMUX_SESSION], signal);
|
|
349
|
-
}
|
|
350
|
-
catch {
|
|
351
|
-
throw error;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
function parseTmuxWindows(output) {
|
|
356
|
-
const items = [];
|
|
357
|
-
for (const line of output.split("\n")) {
|
|
358
|
-
if (line.length === 0) {
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
const [name, deadFlag] = line.split("\t");
|
|
362
|
-
/* v8 ignore next 3 @preserve -- split on a non-empty string always yields a non-empty first element */
|
|
363
|
-
if (name === undefined || name.length === 0) {
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
if (name === TMUX_IDLE_WINDOW) {
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
// pane_dead != 0 means the command exited and the window is a zombie
|
|
370
|
-
// (only happens when remain-on-exit is on; defense in depth in case a
|
|
371
|
-
// user-globally-set value beats our per-window override).
|
|
372
|
-
if (deadFlag !== undefined && deadFlag !== "0") {
|
|
373
|
-
continue;
|
|
374
|
-
}
|
|
375
|
-
items.push({ name });
|
|
376
|
-
}
|
|
377
|
-
return items;
|
|
378
|
-
}
|
|
379
|
-
const tmuxAdapter = {
|
|
380
|
-
async open(spec, signal) {
|
|
381
|
-
await ensureTmuxSession(signal);
|
|
382
|
-
const target = tmuxTarget(spec.name);
|
|
383
|
-
const keepDeadWindowsEnv = readEnvironmentVariable("GROUNDCREW_KEEP_DEAD_WINDOWS");
|
|
384
|
-
const keepDeadWindows = keepDeadWindowsEnv !== undefined && keepDeadWindowsEnv.length > 0;
|
|
385
|
-
await runWorkspaceCommand("tmux", [
|
|
386
|
-
"new-window",
|
|
387
|
-
"-d",
|
|
388
|
-
"-t",
|
|
389
|
-
TMUX_SESSION,
|
|
390
|
-
"-n",
|
|
391
|
-
spec.name,
|
|
392
|
-
"-c",
|
|
393
|
-
spec.cwd,
|
|
394
|
-
spec.command,
|
|
395
|
-
";",
|
|
396
|
-
"set-window-option",
|
|
397
|
-
"-t",
|
|
398
|
-
target,
|
|
399
|
-
"remain-on-exit",
|
|
400
|
-
keepDeadWindows ? "on" : "off",
|
|
401
|
-
";",
|
|
402
|
-
"set-window-option",
|
|
403
|
-
"-t",
|
|
404
|
-
target,
|
|
405
|
-
"allow-rename",
|
|
406
|
-
"off",
|
|
407
|
-
], signal);
|
|
408
|
-
// tmux can't paint status pills; spec.status is silently dropped.
|
|
409
|
-
},
|
|
410
|
-
async list(signal) {
|
|
411
|
-
const probe = await probeTmuxList("#{window_name}\t#{pane_dead}", signal);
|
|
412
|
-
if (probe.status === "missing") {
|
|
413
|
-
return [];
|
|
414
|
-
}
|
|
415
|
-
if (probe.status === "failed") {
|
|
416
|
-
log(`tmux list-windows failed: ${probe.reason}`);
|
|
417
|
-
// oxlint-disable-next-line unicorn/no-useless-undefined -- undefined marks the workspace backend as unavailable.
|
|
418
|
-
return undefined;
|
|
419
|
-
}
|
|
420
|
-
return parseTmuxWindows(probe.output);
|
|
421
|
-
},
|
|
422
|
-
async close(name, signal) {
|
|
423
|
-
try {
|
|
424
|
-
await runWorkspaceCommand("tmux", ["kill-window", "-t", tmuxTarget(name)], signal);
|
|
425
|
-
return { kind: "closed" };
|
|
426
|
-
}
|
|
427
|
-
catch (error) {
|
|
428
|
-
if (isSignalAborted(signal)) {
|
|
429
|
-
throw error;
|
|
430
|
-
}
|
|
431
|
-
if (isTmuxNotFoundError(error)) {
|
|
432
|
-
return { kind: "missing" };
|
|
433
|
-
}
|
|
434
|
-
throw error;
|
|
435
|
-
}
|
|
436
|
-
},
|
|
437
|
-
accessHint(name) {
|
|
438
|
-
return { kind: "attachCommand", command: `tmux attach -t ${tmuxTarget(name)}` };
|
|
439
|
-
},
|
|
440
|
-
};
|
|
441
45
|
// Per-config cache: production resolves the adapter once at first use
|
|
442
46
|
// (loadConfig returns a frozen, cached instance); each test uses a fresh
|
|
443
47
|
// config object so the cache invalidates naturally between tests.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clipboard-health/groundcrew",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -68,7 +68,6 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@clipboard-health/clearance": "1.0.8",
|
|
71
|
-
"@inquirer/checkbox": "5.1.5",
|
|
72
71
|
"@linear/sdk": "86.0.0",
|
|
73
72
|
"cosmiconfig": "9.0.1",
|
|
74
73
|
"tslib": "2.8.1",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/commands/sandbox/auth.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAc,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAuNtE,wBAAsB,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAcnF"}
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import { runCommandAsync } from "../../lib/commandRunner.js";
|
|
2
|
-
import { writeOutput } from "../../lib/util.js";
|
|
3
|
-
import { ensureOne } from "./lifecycle.js";
|
|
4
|
-
import { resolveModel, sandboxModels } from "./model.js";
|
|
5
|
-
import { pickTools } from "./picker.js";
|
|
6
|
-
/**
|
|
7
|
-
* Built-in recipes shipped with crew. Users register additional tools
|
|
8
|
-
* by adding entries under `sandbox.authRecipes` in `crew.config.ts`;
|
|
9
|
-
* a user recipe under the same key overrides the built-in.
|
|
10
|
-
*
|
|
11
|
-
* `kind: "agent"` recipes only appear in the picker when the current
|
|
12
|
-
* sandbox's agent matches the recipe key. `kind: "tool"` (the default
|
|
13
|
-
* for user recipes) is cross-cutting and always appears.
|
|
14
|
-
*/
|
|
15
|
-
const BUILTIN_AUTH_RECIPES = {
|
|
16
|
-
claude: {
|
|
17
|
-
displayName: "Claude",
|
|
18
|
-
loginArgs: ["auth", "login"],
|
|
19
|
-
statusArgs: ["auth", "status"],
|
|
20
|
-
authenticatedPattern: /"loggedIn"\s*:\s*true/,
|
|
21
|
-
kind: "agent",
|
|
22
|
-
},
|
|
23
|
-
codex: {
|
|
24
|
-
displayName: "Codex",
|
|
25
|
-
// `--device-auth` keeps the OAuth flow headless: codex prints a URL
|
|
26
|
-
// and a code instead of trying to open a browser inside the sandbox.
|
|
27
|
-
loginArgs: ["login", "--device-auth"],
|
|
28
|
-
statusArgs: ["login", "status"],
|
|
29
|
-
// Match "Logged in using …" but not a hypothetical "Not logged in".
|
|
30
|
-
authenticatedPattern: /(^|\W)Logged in using\b/i,
|
|
31
|
-
kind: "agent",
|
|
32
|
-
},
|
|
33
|
-
cursor: {
|
|
34
|
-
displayName: "Cursor",
|
|
35
|
-
binary: "cursor-agent",
|
|
36
|
-
loginArgs: ["login"],
|
|
37
|
-
statusArgs: ["status"],
|
|
38
|
-
// Authenticated output is "✓ Logged in as <email>"; the unauthenticated
|
|
39
|
-
// output is "Not logged in", which a loose /Logged in/i would falsely
|
|
40
|
-
// match.
|
|
41
|
-
authenticatedPattern: /Logged in as\b/i,
|
|
42
|
-
kind: "agent",
|
|
43
|
-
// cursor-agent tries to open a browser by default and silently
|
|
44
|
-
// writes a partial auth file when xdg-open fails; this env var
|
|
45
|
-
// switches it to a device-code flow that works without a browser.
|
|
46
|
-
env: { NO_OPEN_BROWSER: "1" },
|
|
47
|
-
},
|
|
48
|
-
github: {
|
|
49
|
-
displayName: "GitHub CLI",
|
|
50
|
-
binary: "gh",
|
|
51
|
-
loginArgs: ["auth", "login"],
|
|
52
|
-
statusArgs: ["auth", "status"],
|
|
53
|
-
authenticatedPattern: /Logged in to github\.com/i,
|
|
54
|
-
kind: "tool",
|
|
55
|
-
},
|
|
56
|
-
};
|
|
57
|
-
function binaryFor(toolKey, recipe) {
|
|
58
|
-
return recipe.binary ?? toolKey;
|
|
59
|
-
}
|
|
60
|
-
function envFlags(recipe) {
|
|
61
|
-
const entries = Object.entries(recipe.env ?? {});
|
|
62
|
-
return entries.flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
63
|
-
}
|
|
64
|
-
// User-supplied recipes can carry arbitrary tokens; wrap each in single
|
|
65
|
-
// quotes so spaces and shell metacharacters can't change how the in-sandbox
|
|
66
|
-
// shell tokenizes the status command.
|
|
67
|
-
function shellQuote(value) {
|
|
68
|
-
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
69
|
-
}
|
|
70
|
-
async function probeAuthStatus(sandboxName, toolKey, recipe) {
|
|
71
|
-
// Some CLIs print status to stderr instead of stdout (codex does
|
|
72
|
-
// this). Fold stderr into stdout via the in-sandbox shell so the
|
|
73
|
-
// pattern match sees the message regardless of which stream it
|
|
74
|
-
// landed on.
|
|
75
|
-
const innerCommand = `${[binaryFor(toolKey, recipe), ...recipe.statusArgs]
|
|
76
|
-
.map(shellQuote)
|
|
77
|
-
.join(" ")} 2>&1`;
|
|
78
|
-
try {
|
|
79
|
-
const output = await runCommandAsync("sbx", [
|
|
80
|
-
"exec",
|
|
81
|
-
...envFlags(recipe),
|
|
82
|
-
sandboxName,
|
|
83
|
-
"sh",
|
|
84
|
-
"-c",
|
|
85
|
-
innerCommand,
|
|
86
|
-
]);
|
|
87
|
-
// Reset lastIndex so a /g or /y user recipe doesn't carry state
|
|
88
|
-
// across probes and return a false negative.
|
|
89
|
-
recipe.authenticatedPattern.lastIndex = 0;
|
|
90
|
-
return recipe.authenticatedPattern.test(output);
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
async function loginAndVerify(input) {
|
|
97
|
-
const { sandboxName, toolKey, recipe, modelName, gitDefaults } = input;
|
|
98
|
-
const binary = binaryFor(toolKey, recipe);
|
|
99
|
-
writeOutput(`${sandboxName}: launching '${recipe.displayName}' login...`);
|
|
100
|
-
writeOutput("Complete the login flow in the prompts/browser, then return here.");
|
|
101
|
-
await runCommandAsync("sbx", ["exec", "-it", ...envFlags(recipe), sandboxName, binary, ...recipe.loginArgs], { stdio: "inherit" });
|
|
102
|
-
writeOutput("");
|
|
103
|
-
writeOutput(`${sandboxName}: verifying '${recipe.displayName}' authentication...`);
|
|
104
|
-
const authenticated = await probeAuthStatus(sandboxName, toolKey, recipe);
|
|
105
|
-
if (authenticated) {
|
|
106
|
-
writeOutput(`${sandboxName}: '${recipe.displayName}' authenticated.`);
|
|
107
|
-
if (gitDefaults && toolKey === "github" && binary === "gh") {
|
|
108
|
-
await runGhSetupGit(sandboxName);
|
|
109
|
-
}
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
writeOutput(`${sandboxName}: could not confirm '${recipe.displayName}' authentication — re-run 'crew sandbox auth ${modelName}' to retry.`);
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Register `gh` as git's credential helper inside the sandbox so HTTPS
|
|
116
|
-
* pushes succeed without prompting. Best-effort — a failure here doesn't
|
|
117
|
-
* undo the login itself, so we warn and move on.
|
|
118
|
-
*/
|
|
119
|
-
async function runGhSetupGit(sandboxName) {
|
|
120
|
-
writeOutput(`${sandboxName}: wiring 'gh' as git credential helper...`);
|
|
121
|
-
try {
|
|
122
|
-
await runCommandAsync("sbx", ["exec", sandboxName, "gh", "auth", "setup-git"]);
|
|
123
|
-
writeOutput(`${sandboxName}: 'gh auth setup-git' done.`);
|
|
124
|
-
}
|
|
125
|
-
catch (error) {
|
|
126
|
-
writeOutput(`${sandboxName}: warning — 'gh auth setup-git' failed: ${String(error)}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
function availableRecipes(config) {
|
|
130
|
-
const merged = {
|
|
131
|
-
...BUILTIN_AUTH_RECIPES,
|
|
132
|
-
...config.sandbox.authRecipes,
|
|
133
|
-
};
|
|
134
|
-
return Object.entries(merged)
|
|
135
|
-
.map(([key, recipe]) => ({ key, recipe }))
|
|
136
|
-
.toSorted((a, b) => a.key.localeCompare(b.key));
|
|
137
|
-
}
|
|
138
|
-
function shouldShowInPicker(entry, currentAgent) {
|
|
139
|
-
// Tools (the default) appear in every sandbox. Agent recipes only
|
|
140
|
-
// appear when they match the current sandbox's agent, so opening
|
|
141
|
-
// 'crew sandbox auth codex' doesn't list Claude or Cursor.
|
|
142
|
-
const kind = entry.recipe.kind ?? "tool";
|
|
143
|
-
return kind === "tool" || entry.key === currentAgent;
|
|
144
|
-
}
|
|
145
|
-
const AUTH_USAGE = "Usage: crew sandbox auth <model> | --all";
|
|
146
|
-
function parseAuthArgs(config, argv) {
|
|
147
|
-
const positionals = [];
|
|
148
|
-
let all = false;
|
|
149
|
-
for (const argument of argv) {
|
|
150
|
-
if (argument === "--all") {
|
|
151
|
-
all = true;
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (argument.startsWith("-")) {
|
|
155
|
-
throw new Error(`crew sandbox auth: unknown option '${argument}'`);
|
|
156
|
-
}
|
|
157
|
-
positionals.push(argument);
|
|
158
|
-
}
|
|
159
|
-
if (all && positionals.length > 0) {
|
|
160
|
-
throw new Error("crew sandbox auth: --all cannot be combined with a model name");
|
|
161
|
-
}
|
|
162
|
-
if (all) {
|
|
163
|
-
const models = sandboxModels(config);
|
|
164
|
-
if (models.length === 0) {
|
|
165
|
-
throw new Error("crew sandbox auth --all: no sandbox-bearing models configured");
|
|
166
|
-
}
|
|
167
|
-
return { models: models.map((model) => ({ modelName: model.modelName, model })) };
|
|
168
|
-
}
|
|
169
|
-
const [modelName, ...extras] = positionals;
|
|
170
|
-
if (modelName === undefined || extras.length > 0) {
|
|
171
|
-
throw new Error(AUTH_USAGE);
|
|
172
|
-
}
|
|
173
|
-
return { models: [{ modelName, model: resolveModel(config, modelName) }] };
|
|
174
|
-
}
|
|
175
|
-
export async function runAuth(config, argv) {
|
|
176
|
-
const { models } = parseAuthArgs(config, argv);
|
|
177
|
-
for (const [index, { modelName, model }] of models.entries()) {
|
|
178
|
-
if (models.length > 1) {
|
|
179
|
-
writeOutput("");
|
|
180
|
-
writeOutput(`════ ${modelName} (${index + 1}/${models.length}) ════`);
|
|
181
|
-
}
|
|
182
|
-
writeOutput(`${model.sandboxName}: ensuring sandbox is up...`);
|
|
183
|
-
// oxlint-disable-next-line no-await-in-loop -- each sandbox is interactive; running them sequentially keeps the prompts coherent
|
|
184
|
-
await ensureOne(config, model);
|
|
185
|
-
writeOutput("");
|
|
186
|
-
// oxlint-disable-next-line no-await-in-loop -- intentionally sequential, see above
|
|
187
|
-
await runAuthInteractive(config, model, modelName);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
async function runAuthInteractive(config, model, modelName) {
|
|
191
|
-
const recipes = availableRecipes(config).filter((entry) => shouldShowInPicker(entry, model.sandbox.agent));
|
|
192
|
-
writeOutput(`${model.sandboxName}: probing authentication status for ${recipes.length} tools...`);
|
|
193
|
-
const statuses = await Promise.all(recipes.map(async ({ key, recipe }) => ({
|
|
194
|
-
key,
|
|
195
|
-
recipe,
|
|
196
|
-
authenticated: await probeAuthStatus(model.sandboxName, key, recipe),
|
|
197
|
-
})));
|
|
198
|
-
const choices = statuses.map(({ key, recipe, authenticated }) => ({
|
|
199
|
-
key,
|
|
200
|
-
label: `${recipe.displayName} (${key})`,
|
|
201
|
-
authenticated,
|
|
202
|
-
}));
|
|
203
|
-
writeOutput("");
|
|
204
|
-
const selectedKeys = await pickTools(choices);
|
|
205
|
-
if (selectedKeys.length === 0) {
|
|
206
|
-
writeOutput("Nothing selected. Exiting.");
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
const selectedRecipes = new Map(statuses.map((entry) => [entry.key, entry.recipe]));
|
|
210
|
-
for (const key of selectedKeys) {
|
|
211
|
-
const recipe = selectedRecipes.get(key);
|
|
212
|
-
/* v8 ignore next 3 @preserve - defensive; selectedKeys come from the same map */
|
|
213
|
-
if (recipe === undefined) {
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
writeOutput("");
|
|
217
|
-
writeOutput(`── ${recipe.displayName} ──`);
|
|
218
|
-
// oxlint-disable-next-line no-await-in-loop -- each login is interactive; running them sequentially keeps the prompts coherent
|
|
219
|
-
await loginAndVerify({
|
|
220
|
-
sandboxName: model.sandboxName,
|
|
221
|
-
toolKey: key,
|
|
222
|
-
recipe,
|
|
223
|
-
modelName,
|
|
224
|
-
gitDefaults: config.sandbox.gitDefaults,
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/sandbox/index.ts"],"names":[],"mappings":"AAkBA,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8B9D"}
|