@clipboard-health/groundcrew 1.10.2 → 1.12.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 +15 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +18 -1
- package/dist/commands/remoteSetup.d.ts +1 -0
- package/dist/commands/remoteSetup.d.ts.map +1 -1
- package/dist/commands/remoteSetup.js +114 -0
- package/dist/commands/setupRepos.d.ts +44 -0
- package/dist/commands/setupRepos.d.ts.map +1 -0
- package/dist/commands/setupRepos.js +222 -0
- package/dist/lib/spriteRemoteRunnerProvider.d.ts +3 -0
- package/dist/lib/spriteRemoteRunnerProvider.d.ts.map +1 -1
- package/dist/lib/spriteRemoteRunnerProvider.js +99 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,16 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
|
|
|
37
37
|
gh repo clone owner/repo ~/dev/groundcrew-workspaces/owner/repo
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
Or let `crew` clone every missing `owner/repo` entry for you using your `gh` login:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
crew setup repos # clone all missing entries
|
|
44
|
+
crew setup repos --dry-run # preview what would be cloned
|
|
45
|
+
crew setup repos owner/repo # restrict to one entry
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`crew setup repos` is idempotent — already-cloned repos are reported `[exists]` and untouched. Bare-name entries (no `owner/`) are skipped with an instruction to clone manually, since groundcrew can't safely guess the org. The command fails fast with an install hint when `gh` is not on `PATH`.
|
|
49
|
+
|
|
40
50
|
`crew` resolves the config path as: `GROUNDCREW_CONFIG` if set → `${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/config.ts` if it exists → a `config.ts` sitting next to `crew`'s own source files (only useful from a local checkout; see [Hacking on groundcrew](#hacking-on-groundcrew)). Set `GROUNDCREW_CONFIG` only when you want to override the XDG location.
|
|
41
51
|
|
|
42
52
|
4. **Provide a Linear API key.** `crew` expects `LINEAR_API_KEY` in its environment. Any mechanism works — shell export, [direnv](https://direnv.net/), a `.env` file you `source`, or piping through `op run` if you store the credential in 1Password:
|
|
@@ -81,6 +91,7 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
|
|
|
81
91
|
--claude \
|
|
82
92
|
--codex \
|
|
83
93
|
--copy-local-codex-auth \
|
|
94
|
+
--datadog \
|
|
84
95
|
--github \
|
|
85
96
|
--git-name "Your Name" \
|
|
86
97
|
--git-email "you@users.noreply.github.com" \
|
|
@@ -89,7 +100,7 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
|
|
|
89
100
|
--checkpoint
|
|
90
101
|
```
|
|
91
102
|
|
|
92
|
-
Known MCP aliases are `linear`, `slack`, and `notion`. For another HTTP MCP server, pass `--mcp name=https://example.com/mcp`. The command creates the remote runner if needed, prepares `~/dev`, configures Git, runs selected auth flows, adds selected MCP servers to Claude Code, and then opens Claude so you can run `/mcp` and authenticate only those selected servers. With the Sprite provider, `--copy-local-codex-auth` copies `${CODEX_HOME:-$HOME/.codex}/auth.json` into `/home/sprite/.codex/auth.json` and then verifies `codex login status`; it never prints the file contents. Use `--skip-mcp-auth` when you only want to add MCP definitions, and run the `/mcp` step later.
|
|
103
|
+
Known MCP aliases are `linear`, `slack`, and `notion`. For another HTTP MCP server, pass `--mcp name=https://example.com/mcp`. The command creates the remote runner if needed, prepares `~/dev`, configures Git, runs selected auth flows, adds selected MCP servers to Claude Code, and then opens Claude so you can run `/mcp` and authenticate only those selected servers. With the Sprite provider, `--copy-local-codex-auth` copies `${CODEX_HOME:-$HOME/.codex}/auth.json` into `/home/sprite/.codex/auth.json` and then verifies `codex login status`; it never prints the file contents. `--datadog` installs a pinned `pup` release in the remote runner, verifies its checksum, installs the `dd-pup` guidance for Claude and Codex, verifies `pup auth status`, and when auth is missing temporarily runs `sprite proxy` for the OAuth callback while `pup auth login --read-only` runs in the remote runner. Open the Datadog URL printed by `pup` if your terminal does not open it automatically. Use `--skip-mcp-auth` when you only want to add MCP definitions, and run the `/mcp` step later.
|
|
93
104
|
|
|
94
105
|
Repo setup is separate from runner setup and should run after the ticket branch exists, immediately before launching an agent. It clones/fetches the repo in the remote runner, checks out the requested branch (creating it from the base branch when it does not exist on origin), forwards only build-time secrets for the dependency install, removes the temporary secret file, clears those env vars, and then exits. It uses groundcrew's remote setup command.
|
|
95
106
|
|
|
@@ -180,13 +191,14 @@ Rules:
|
|
|
180
191
|
## Manual commands
|
|
181
192
|
|
|
182
193
|
```bash
|
|
183
|
-
crew remote setup crew-claude-1 --claude --codex --copy-local-codex-auth --github --mcp linear --checkpoint
|
|
194
|
+
crew remote setup crew-claude-1 --claude --codex --copy-local-codex-auth --datadog --github --mcp linear --checkpoint
|
|
184
195
|
crew remote bootstrap crew-claude-1 core-utils --branch rocky-team-123
|
|
185
196
|
crew remote sessions
|
|
186
197
|
crew remote attach <session-id-or-command> --runner crew-claude-1
|
|
187
198
|
crew remote ps crew-claude-1
|
|
188
199
|
crew remote interrupt <process-group-id> --runner crew-claude-1
|
|
189
200
|
crew run --ticket <TICKET>
|
|
201
|
+
crew setup repos [--dry-run] [<repo>...]
|
|
190
202
|
crew cleanup <TICKET>
|
|
191
203
|
```
|
|
192
204
|
|
|
@@ -211,6 +223,7 @@ crew cleanup <TICKET>
|
|
|
211
223
|
- **Doctor checks every enabled model, including shipped defaults you didn't disable.** `models.definitions` includes both shipped defaults (`claude`, `codex`) by default via additive merge. If you only intend to label tickets `agent-claude` and don't have `codex` installed, set `models.definitions.codex: { disabled: true }` (see "Disabling a shipped default" under "Config reference"). Without that, doctor exits non-zero on a missing `codex` binary even though `crew run` would never route to it.
|
|
212
224
|
- **Switch to tmux if cmux is misbehaving.** Set `workspaceKind: "tmux"` to force the tmux backend when cmux's CLI/socket bridge is flaky (symptoms: `cmux --json list-workspaces` returning `Failed to write to socket (Broken pipe)` or `Socket not found at ...cmux.sock` on every tick). tmux is more reliable — just a unix socket, no GUI app — at the cost of losing cmux's status pills, notifications, and vertical-tab sidebar.
|
|
213
225
|
- **Agent CLI must accept a positional prompt.** The handoff is `<your cmd> "<prompt>"`. `claude`, `codex`, and `cursor-agent` all support this.
|
|
226
|
+
- **`crew setup repos` only auto-clones `owner/repo` entries.** Bare-name entries in `workspace.knownRepositories` (e.g. `"api"` rather than `"clipboardhealth/api"`) are skipped with a hint to clone manually — the command refuses to guess the owner. After a partial setup, the exit code is non-zero so CI gates notice; rerun is idempotent once you clone the bare ones into `<projectDir>/<name>` yourself. Adding a new repo to `knownRepositories` later? Just rerun `crew setup repos`; already-present entries report `[exists]` and are untouched.
|
|
214
227
|
|
|
215
228
|
## Hacking on groundcrew
|
|
216
229
|
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAsIA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
|
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,21 @@ import { cleanupWorkspaceCli } from "./commands/cleanupWorkspace.js";
|
|
|
3
3
|
import { doctor } from "./commands/doctor.js";
|
|
4
4
|
import { orchestrate } from "./commands/orchestrator.js";
|
|
5
5
|
import { remoteCli } from "./commands/remoteSetup.js";
|
|
6
|
+
import { setupReposCli } from "./commands/setupRepos.js";
|
|
6
7
|
import { setupWorkspaceCli } from "./commands/setupWorkspace.js";
|
|
7
8
|
import { errorMessage, writeError, writeOutput } from "./lib/util.js";
|
|
8
9
|
const requireFromCli = createRequire(import.meta.url);
|
|
10
|
+
function setupUsage() {
|
|
11
|
+
return "Usage: crew setup repos [--dry-run] [<repo>...]";
|
|
12
|
+
}
|
|
13
|
+
async function setupCli(argv) {
|
|
14
|
+
const [verb, ...rest] = argv;
|
|
15
|
+
if (verb === "repos") {
|
|
16
|
+
await setupReposCli(rest);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
throw new Error(setupUsage());
|
|
20
|
+
}
|
|
9
21
|
async function runCli(argv) {
|
|
10
22
|
let watch = false;
|
|
11
23
|
let dryRun = false;
|
|
@@ -60,9 +72,14 @@ const SUBCOMMANDS = {
|
|
|
60
72
|
usage: "[--force] <ticket>",
|
|
61
73
|
invoke: cleanupWorkspaceCli,
|
|
62
74
|
},
|
|
75
|
+
setup: {
|
|
76
|
+
summary: "Project-level setup commands (currently: repos)",
|
|
77
|
+
usage: "repos [--dry-run] [<repo>...]",
|
|
78
|
+
invoke: setupCli,
|
|
79
|
+
},
|
|
63
80
|
remote: {
|
|
64
81
|
summary: "Create, authenticate, bootstrap, and inspect a remote runner",
|
|
65
|
-
usage: "setup <runner-name> [--claude] [--github] [--mcp <alias|name=url>] [--checkpoint]\n" +
|
|
82
|
+
usage: "setup <runner-name> [--claude] [--codex] [--datadog] [--github] [--mcp <alias|name=url>] [--checkpoint]\n" +
|
|
66
83
|
" → crew remote bootstrap <runner-name> <repo> [--branch <branch>]\n" +
|
|
67
84
|
" → crew remote sessions [<runner-name>]\n" +
|
|
68
85
|
" → crew remote attach <session-id-or-command> [--runner <runner-name>]\n" +
|
|
@@ -8,6 +8,7 @@ export interface RemoteSetupOptions {
|
|
|
8
8
|
shouldAuthenticateClaude: boolean;
|
|
9
9
|
shouldAuthenticateCodex: boolean;
|
|
10
10
|
shouldCopyLocalCodexAuth?: boolean;
|
|
11
|
+
shouldSetupDatadog?: boolean;
|
|
11
12
|
shouldAuthenticateGithub: boolean;
|
|
12
13
|
shouldAuthenticateMcp: boolean;
|
|
13
14
|
shouldCheckpoint: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remoteSetup.d.ts","sourceRoot":"","sources":["../../src/commands/remoteSetup.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"remoteSetup.d.ts","sourceRoot":"","sources":["../../src/commands/remoteSetup.ts"],"names":[],"mappings":"AAyBA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,OAAO,CAAC;IACtB,wBAAwB,EAAE,OAAO,CAAC;IAClC,uBAAuB,EAAE,OAAO,CAAC;IACjC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IACnC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,wBAAwB,EAAE,OAAO,CAAC;IAClC,qBAAqB,EAAE,OAAO,CAAC;IAC/B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,SAAS,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,4BAA4B,EAAE,OAAO,CAAC;IACtC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,sBAAsB;IACrC,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAy0BD,wBAAsB,yBAAyB,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6B9F;AAUD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAItF;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAGrF;AAED,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CAItF;AAED,wBAAsB,2BAA2B,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAGhG;AAYD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgClF;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B7D"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
5
|
import { BUILD_SECRET_NAMES, DEFAULT_REMOTE_SETUP_COMMAND, loadConfig, } from "../lib/config.js";
|
|
5
6
|
import { shellSingleQuote } from "../lib/shell.js";
|
|
6
7
|
import { getRemoteRunnerProvider, remoteConfigWithRunnerName, } from "../lib/spriteRemoteRunnerProvider.js";
|
|
@@ -14,6 +15,10 @@ const DEFAULT_CHECKPOINT_COMMENT = "groundcrew remote runner baseline: selected
|
|
|
14
15
|
const CLAUDE_SUBSCRIPTION_LOGIN_FLAG = ["--claude", "ai"].join("");
|
|
15
16
|
const DEFAULT_REPOSITORY_OWNER = "ClipboardHealth";
|
|
16
17
|
const DEFAULT_BASE_BRANCH = "main";
|
|
18
|
+
const DATADOG_PUP_VERSION = "0.63.0";
|
|
19
|
+
const DATADOG_OAUTH_CALLBACK_PORT = 8000;
|
|
20
|
+
const DATADOG_AUTH_STATUS_RETRY_ATTEMPTS = 5;
|
|
21
|
+
const DATADOG_AUTH_STATUS_RETRY_DELAY_MS = 250;
|
|
17
22
|
const REMOTE_SECRETS_FILE = "/tmp/groundcrew-build-secrets.env";
|
|
18
23
|
const REMOTE_CODEX_AUTH_UPLOAD_FILE = "/tmp/groundcrew-codex-auth.json";
|
|
19
24
|
const REMOTE_CODEX_AUTH_FILE = "/home/sprite/.codex/auth.json";
|
|
@@ -31,6 +36,7 @@ function usage() {
|
|
|
31
36
|
" --claude Authenticate Claude Code with a Claude subscription",
|
|
32
37
|
" --codex Authenticate Codex CLI",
|
|
33
38
|
" --copy-local-codex-auth With --codex, copy local CODEX_HOME auth.json into the remote runner",
|
|
39
|
+
" --datadog Install pup, add dd-pup skills, and authenticate Datadog",
|
|
34
40
|
" --github Authenticate gh for GitHub PRs",
|
|
35
41
|
" --mcp <alias|name=url> Add/authenticate one MCP server; repeat for multiple",
|
|
36
42
|
" Known aliases: linear, slack, notion",
|
|
@@ -134,6 +140,7 @@ function parseArguments(argv) {
|
|
|
134
140
|
let shouldAuthenticateClaude = false;
|
|
135
141
|
let shouldAuthenticateCodex = false;
|
|
136
142
|
let shouldCopyLocalCodexAuth = false;
|
|
143
|
+
let shouldSetupDatadog = false;
|
|
137
144
|
let shouldAuthenticateGithub = false;
|
|
138
145
|
let shouldAuthenticateMcp = true;
|
|
139
146
|
let shouldCheckpoint = false;
|
|
@@ -156,6 +163,10 @@ function parseArguments(argv) {
|
|
|
156
163
|
shouldCopyLocalCodexAuth = true;
|
|
157
164
|
continue;
|
|
158
165
|
}
|
|
166
|
+
if (argument === "--datadog") {
|
|
167
|
+
shouldSetupDatadog = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
159
170
|
if (argument === "--github") {
|
|
160
171
|
shouldAuthenticateGithub = true;
|
|
161
172
|
continue;
|
|
@@ -202,6 +213,7 @@ function parseArguments(argv) {
|
|
|
202
213
|
shouldAuthenticateClaude,
|
|
203
214
|
shouldAuthenticateCodex,
|
|
204
215
|
shouldCopyLocalCodexAuth,
|
|
216
|
+
shouldSetupDatadog,
|
|
205
217
|
shouldAuthenticateGithub,
|
|
206
218
|
shouldAuthenticateMcp,
|
|
207
219
|
shouldCheckpoint,
|
|
@@ -457,6 +469,105 @@ async function authenticateCodex(arguments_) {
|
|
|
457
469
|
}
|
|
458
470
|
throw new Error("Codex login finished, but `codex login status` still reports not logged in inside the remote runner. Try `crew remote setup <runner-name> --codex --copy-local-codex-auth`.");
|
|
459
471
|
}
|
|
472
|
+
function datadogPupInstallCommand() {
|
|
473
|
+
const shellVariable = "$";
|
|
474
|
+
return [
|
|
475
|
+
"set -euo pipefail",
|
|
476
|
+
`version=${shellSingleQuote(DATADOG_PUP_VERSION)}`,
|
|
477
|
+
'case "$(uname -m)" in',
|
|
478
|
+
" x86_64|amd64) arch=x86_64 ;;",
|
|
479
|
+
" aarch64|arm64) arch=arm64 ;;",
|
|
480
|
+
' *) echo "Unsupported Linux architecture: $(uname -m)" >&2; exit 1 ;;',
|
|
481
|
+
"esac",
|
|
482
|
+
`asset="pup_${shellVariable}{version}_Linux_${shellVariable}{arch}.tar.gz"`,
|
|
483
|
+
`base_url="https://github.com/DataDog/pup/releases/download/v${shellVariable}{version}"`,
|
|
484
|
+
'binary="$HOME/.local/bin/pup"',
|
|
485
|
+
'mkdir -p "$HOME/.local/bin"',
|
|
486
|
+
`if [ -x "$binary" ] && "$binary" version | grep -q "Pup ${shellVariable}{version} "; then "$binary" version; exit 0; fi`,
|
|
487
|
+
'install_dir="$(mktemp -d)"',
|
|
488
|
+
"trap 'rm -rf \"$install_dir\"' EXIT",
|
|
489
|
+
`archive="${shellVariable}{install_dir}/${shellVariable}{asset}"`,
|
|
490
|
+
`checksums="${shellVariable}{install_dir}/pup_${shellVariable}{version}_checksums.txt"`,
|
|
491
|
+
'curl -fsSL -o "$archive" "$base_url/$asset"',
|
|
492
|
+
`curl -fsSL -o "$checksums" "$base_url/pup_${shellVariable}{version}_checksums.txt"`,
|
|
493
|
+
`(cd "$install_dir" && grep " ${shellVariable}{asset}$" "$checksums" | sha256sum -c -)`,
|
|
494
|
+
'tar -xzf "$archive" -C "$HOME/.local/bin" pup',
|
|
495
|
+
'chmod 755 "$binary"',
|
|
496
|
+
'"$binary" version',
|
|
497
|
+
].join("\n");
|
|
498
|
+
}
|
|
499
|
+
async function installDatadogPup(provider, config) {
|
|
500
|
+
log(`Installing Datadog pup ${DATADOG_PUP_VERSION} inside the remote runner`);
|
|
501
|
+
await provider.runCommand({
|
|
502
|
+
config,
|
|
503
|
+
remoteArguments: ["bash", "-lc", datadogPupInstallCommand()],
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
async function installDatadogPupSkills(provider, config) {
|
|
507
|
+
for (const platform of ["claude-code", "codex"]) {
|
|
508
|
+
// oxlint-disable-next-line no-await-in-loop -- installs are quick and ordered so logs stay readable.
|
|
509
|
+
await provider.runCommand({
|
|
510
|
+
config,
|
|
511
|
+
remoteArguments: ["pup", "skills", "install", platform, "--name", "dd-pup", "--yes"],
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
async function validateDatadogLogin(provider, config) {
|
|
516
|
+
return await commandSucceeds({
|
|
517
|
+
provider,
|
|
518
|
+
config,
|
|
519
|
+
remoteArguments: ["pup", "auth", "status"],
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
async function waitForDatadogLogin(provider, config) {
|
|
523
|
+
for (let attempt = 1; attempt <= DATADOG_AUTH_STATUS_RETRY_ATTEMPTS; attempt += 1) {
|
|
524
|
+
// oxlint-disable-next-line no-await-in-loop -- bounded polling smooths over Datadog OAuth propagation delay.
|
|
525
|
+
if (await validateDatadogLogin(provider, config)) {
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
if (attempt < DATADOG_AUTH_STATUS_RETRY_ATTEMPTS) {
|
|
529
|
+
// oxlint-disable-next-line no-await-in-loop -- retries are intentionally spaced.
|
|
530
|
+
await sleep(DATADOG_AUTH_STATUS_RETRY_DELAY_MS);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
async function authenticateDatadog(provider, config) {
|
|
536
|
+
if (await validateDatadogLogin(provider, config)) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
writeOutput();
|
|
540
|
+
writeOutput("Datadog OAuth will run inside the remote runner.");
|
|
541
|
+
writeOutput(`Groundcrew is forwarding local port ${DATADOG_OAUTH_CALLBACK_PORT} to the remote callback server while login runs.`);
|
|
542
|
+
writeOutput("Open the Datadog URL printed by pup if your terminal does not open it for you.");
|
|
543
|
+
writeOutput();
|
|
544
|
+
const proxy = await provider.startPortProxy(config, DATADOG_OAUTH_CALLBACK_PORT);
|
|
545
|
+
try {
|
|
546
|
+
await provider.runTtyCommand({
|
|
547
|
+
config,
|
|
548
|
+
remoteArguments: [
|
|
549
|
+
"pup",
|
|
550
|
+
"auth",
|
|
551
|
+
"login",
|
|
552
|
+
"--read-only",
|
|
553
|
+
"--callback-port",
|
|
554
|
+
String(DATADOG_OAUTH_CALLBACK_PORT),
|
|
555
|
+
],
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
await proxy.close();
|
|
560
|
+
}
|
|
561
|
+
if (await waitForDatadogLogin(provider, config)) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
throw new Error("Datadog auth finished, but `pup auth status` still reports not logged in inside the remote runner.");
|
|
565
|
+
}
|
|
566
|
+
async function setupDatadog(provider, config) {
|
|
567
|
+
await installDatadogPup(provider, config);
|
|
568
|
+
await installDatadogPupSkills(provider, config);
|
|
569
|
+
await authenticateDatadog(provider, config);
|
|
570
|
+
}
|
|
460
571
|
async function addMcpServer(arguments_) {
|
|
461
572
|
const { provider, config, server } = arguments_;
|
|
462
573
|
const exists = await commandSucceeds({
|
|
@@ -661,6 +772,9 @@ export async function setupRemoteRunner(options) {
|
|
|
661
772
|
shouldCopyLocalAuth: options.shouldCopyLocalCodexAuth === true,
|
|
662
773
|
});
|
|
663
774
|
}
|
|
775
|
+
if (options.shouldSetupDatadog === true) {
|
|
776
|
+
await setupDatadog(provider, config);
|
|
777
|
+
}
|
|
664
778
|
for (const server of options.mcpServers) {
|
|
665
779
|
// oxlint-disable-next-line no-await-in-loop -- MCP additions are sequential so auth instructions stay ordered.
|
|
666
780
|
await addMcpServer({ provider, config, server });
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crew setup repos` — clone every entry of `workspace.knownRepositories`
|
|
3
|
+
* that does not already exist under `workspace.projectDir`. Entries
|
|
4
|
+
* shaped `<owner>/<repo>` are cloned via `gh repo clone`; bare-name
|
|
5
|
+
* entries are skipped with a hint, because they have no canonical URL
|
|
6
|
+
* we can guess at without involving the user's gh login. Idempotent.
|
|
7
|
+
*/
|
|
8
|
+
import { type ResolvedConfig } from "../lib/config.ts";
|
|
9
|
+
export interface SetupReposOptions {
|
|
10
|
+
/** Print the plan without running any clone. */
|
|
11
|
+
dryRun?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Restrict the action to this subset of `knownRepositories`. Each entry
|
|
14
|
+
* must match an entry in the config or the call rejects before any side
|
|
15
|
+
* effect.
|
|
16
|
+
*/
|
|
17
|
+
only?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export type SetupReposSkipKind = "bare-name" | "invalid-repository" | "invalid-target";
|
|
20
|
+
export interface SetupReposSkip {
|
|
21
|
+
repo: string;
|
|
22
|
+
kind: SetupReposSkipKind;
|
|
23
|
+
reason: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SetupReposResult {
|
|
26
|
+
/** Entries already present under `projectDir`. */
|
|
27
|
+
existing: string[];
|
|
28
|
+
/** Entries that would be cloned in dry-run mode. */
|
|
29
|
+
planned: string[];
|
|
30
|
+
/** Entries successfully cloned this run. */
|
|
31
|
+
cloned: string[];
|
|
32
|
+
/** Entries skipped with a reason (e.g. bare names, invalid targets). */
|
|
33
|
+
skipped: SetupReposSkip[];
|
|
34
|
+
/** Entries that failed during clone. */
|
|
35
|
+
failed: {
|
|
36
|
+
repo: string;
|
|
37
|
+
error: Error;
|
|
38
|
+
}[];
|
|
39
|
+
/** True when `gh` is missing and at least one clone was needed. */
|
|
40
|
+
ghMissing: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function setupRepos(config: ResolvedConfig, options: SetupReposOptions): Promise<SetupReposResult>;
|
|
43
|
+
export declare function setupReposCli(argv: string[]): Promise<void>;
|
|
44
|
+
//# sourceMappingURL=setupRepos.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setupRepos.d.ts","sourceRoot":"","sources":["../../src/commands/setupRepos.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAInE,MAAM,WAAW,iBAAiB;IAChC,gDAAgD;IAChD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B;AAED,MAAM,MAAM,kBAAkB,GAAG,WAAW,GAAG,oBAAoB,GAAG,gBAAgB,CAAC;AAEvF,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,kBAAkB,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,kDAAkD;IAClD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,oDAAoD;IACpD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,4CAA4C;IAC5C,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,wEAAwE;IACxE,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,wCAAwC;IACxC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,EAAE,CAAC;IACzC,mEAAmE;IACnE,SAAS,EAAE,OAAO,CAAC;CACpB;AA6JD,wBAAsB,UAAU,CAC9B,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,gBAAgB,CAAC,CAyD3B;AAwBD,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAajE"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crew setup repos` — clone every entry of `workspace.knownRepositories`
|
|
3
|
+
* that does not already exist under `workspace.projectDir`. Entries
|
|
4
|
+
* shaped `<owner>/<repo>` are cloned via `gh repo clone`; bare-name
|
|
5
|
+
* entries are skipped with a hint, because they have no canonical URL
|
|
6
|
+
* we can guess at without involving the user's gh login. Idempotent.
|
|
7
|
+
*/
|
|
8
|
+
import { opendirSync, statSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
10
|
+
import { runCommandAsync } from "../lib/commandRunner.js";
|
|
11
|
+
import { loadConfig } from "../lib/config.js";
|
|
12
|
+
import { which } from "../lib/host.js";
|
|
13
|
+
import { errorMessage, log, writeOutput } from "../lib/util.js";
|
|
14
|
+
function emptyResult() {
|
|
15
|
+
return {
|
|
16
|
+
existing: [],
|
|
17
|
+
planned: [],
|
|
18
|
+
cloned: [],
|
|
19
|
+
skipped: [],
|
|
20
|
+
failed: [],
|
|
21
|
+
ghMissing: false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function selectRepositories(config, only) {
|
|
25
|
+
if (only === undefined) {
|
|
26
|
+
return config.workspace.knownRepositories;
|
|
27
|
+
}
|
|
28
|
+
const known = new Set(config.workspace.knownRepositories);
|
|
29
|
+
const unknown = only.filter((entry) => !known.has(entry));
|
|
30
|
+
if (unknown.length > 0) {
|
|
31
|
+
throw new Error(`Repositories not in workspace.knownRepositories: ${unknown.join(", ")}. Known: ${config.workspace.knownRepositories.join(", ")}`);
|
|
32
|
+
}
|
|
33
|
+
return only;
|
|
34
|
+
}
|
|
35
|
+
function pathExists(path) {
|
|
36
|
+
return statSync(path, { throwIfNoEntry: false }) !== undefined;
|
|
37
|
+
}
|
|
38
|
+
function isDirectoryEmpty(path) {
|
|
39
|
+
const directory = opendirSync(path);
|
|
40
|
+
try {
|
|
41
|
+
return directory.readSync() === null;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
directory.closeSync();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function existingTargetPlan(target) {
|
|
48
|
+
const stats = statSync(target, { throwIfNoEntry: false });
|
|
49
|
+
if (stats === undefined) {
|
|
50
|
+
return "clone";
|
|
51
|
+
}
|
|
52
|
+
if (!stats.isDirectory()) {
|
|
53
|
+
return "skip-invalid";
|
|
54
|
+
}
|
|
55
|
+
if (pathExists(resolve(target, ".git"))) {
|
|
56
|
+
return "existing";
|
|
57
|
+
}
|
|
58
|
+
return isDirectoryEmpty(target) ? "clone" : "skip-invalid";
|
|
59
|
+
}
|
|
60
|
+
function isInsideProjectDir(projectDir, target) {
|
|
61
|
+
const relativeTarget = relative(projectDir, target);
|
|
62
|
+
return (relativeTarget.length > 0 && !relativeTarget.startsWith("..") && !isAbsolute(relativeTarget));
|
|
63
|
+
}
|
|
64
|
+
function repositoryEntryPlan(repo) {
|
|
65
|
+
const parts = repo.split("/");
|
|
66
|
+
if (parts.length === 1) {
|
|
67
|
+
return "bare-name";
|
|
68
|
+
}
|
|
69
|
+
if (parts.length === 2 && parts.every((part) => part.length > 0)) {
|
|
70
|
+
return "clone";
|
|
71
|
+
}
|
|
72
|
+
return "invalid-repository";
|
|
73
|
+
}
|
|
74
|
+
function bareNameSkip(repo, target) {
|
|
75
|
+
return {
|
|
76
|
+
repo,
|
|
77
|
+
kind: "bare-name",
|
|
78
|
+
reason: `bare name needs owner/ prefix to auto-clone; clone manually into ${target}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function invalidTargetSkip(repo, target) {
|
|
82
|
+
return {
|
|
83
|
+
repo,
|
|
84
|
+
kind: "invalid-target",
|
|
85
|
+
reason: `target exists but is not a git repository or empty directory: ${target}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function invalidRepositorySkip(repo, target) {
|
|
89
|
+
return {
|
|
90
|
+
repo,
|
|
91
|
+
kind: "invalid-repository",
|
|
92
|
+
reason: `repository must be owner/repo to auto-clone; clone manually into ${target}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function escapingTargetSkip(repo, projectDir, target) {
|
|
96
|
+
return {
|
|
97
|
+
repo,
|
|
98
|
+
kind: "invalid-repository",
|
|
99
|
+
reason: `repository resolves outside workspace.projectDir (${projectDir}): ${target}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function planClones(config, repositories) {
|
|
103
|
+
const projectDir = resolve(config.workspace.projectDir);
|
|
104
|
+
const toClone = [];
|
|
105
|
+
const existing = [];
|
|
106
|
+
const skipped = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const entry of repositories) {
|
|
109
|
+
if (seen.has(entry)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
seen.add(entry);
|
|
113
|
+
const target = resolve(projectDir, entry);
|
|
114
|
+
if (!isInsideProjectDir(projectDir, target)) {
|
|
115
|
+
skipped.push(escapingTargetSkip(entry, projectDir, target));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const targetPlan = existingTargetPlan(target);
|
|
119
|
+
if (targetPlan === "existing") {
|
|
120
|
+
existing.push(entry);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (targetPlan === "skip-invalid") {
|
|
124
|
+
skipped.push(invalidTargetSkip(entry, target));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const repositoryPlan = repositoryEntryPlan(entry);
|
|
128
|
+
if (repositoryPlan === "bare-name") {
|
|
129
|
+
skipped.push(bareNameSkip(entry, target));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (repositoryPlan === "invalid-repository") {
|
|
133
|
+
skipped.push(invalidRepositorySkip(entry, target));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
toClone.push(entry);
|
|
137
|
+
}
|
|
138
|
+
return { toClone, existing, skipped };
|
|
139
|
+
}
|
|
140
|
+
export async function setupRepos(config, options) {
|
|
141
|
+
const repositories = selectRepositories(config, options.only);
|
|
142
|
+
const plan = planClones(config, repositories);
|
|
143
|
+
const result = emptyResult();
|
|
144
|
+
result.existing = plan.existing;
|
|
145
|
+
result.skipped = plan.skipped;
|
|
146
|
+
for (const entry of plan.existing) {
|
|
147
|
+
log(`[exists] ${entry}`);
|
|
148
|
+
}
|
|
149
|
+
for (const { repo, reason } of plan.skipped) {
|
|
150
|
+
log(`[skip] ${repo} — ${reason}`);
|
|
151
|
+
}
|
|
152
|
+
if (options.dryRun === true) {
|
|
153
|
+
result.planned = plan.toClone;
|
|
154
|
+
for (const entry of plan.toClone) {
|
|
155
|
+
log(`[dry-run] would clone ${entry}`);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
if (plan.toClone.length === 0) {
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
const ghPath = await which("gh");
|
|
163
|
+
if (ghPath === undefined) {
|
|
164
|
+
result.ghMissing = true;
|
|
165
|
+
writeOutput("gh CLI not found - install GitHub CLI from https://cli.github.com/ (or clone the missing repos manually).");
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
const projectDir = resolve(config.workspace.projectDir);
|
|
169
|
+
// Sequential on purpose: each `gh repo clone` inherits stdio for progress
|
|
170
|
+
// bars and auth prompts. Parallel clones would interleave output and make
|
|
171
|
+
// any interactive 2FA prompt unanswerable.
|
|
172
|
+
for (const entry of plan.toClone) {
|
|
173
|
+
const target = resolve(projectDir, entry);
|
|
174
|
+
log(`[clone] ${entry} → ${target}`);
|
|
175
|
+
try {
|
|
176
|
+
// oxlint-disable-next-line no-await-in-loop -- see comment above
|
|
177
|
+
await runCommandAsync("gh", ["repo", "clone", entry, target], {
|
|
178
|
+
stdio: "inherit",
|
|
179
|
+
timeoutMs: 0,
|
|
180
|
+
});
|
|
181
|
+
result.cloned.push(entry);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
const wrapped = error instanceof Error ? error : new Error(errorMessage(error));
|
|
185
|
+
log(`[fail] ${entry}: ${wrapped.message}`);
|
|
186
|
+
result.failed.push({ repo: entry, error: wrapped });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
function parseArguments(argv) {
|
|
192
|
+
let dryRun = false;
|
|
193
|
+
const positionals = [];
|
|
194
|
+
for (const argument of argv) {
|
|
195
|
+
if (argument === "--dry-run") {
|
|
196
|
+
dryRun = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (argument.startsWith("-")) {
|
|
200
|
+
throw new Error(`Unknown option: ${argument}\nUsage: crew setup repos [--dry-run] [<repo>...]`);
|
|
201
|
+
}
|
|
202
|
+
positionals.push(argument);
|
|
203
|
+
}
|
|
204
|
+
const options = { dryRun };
|
|
205
|
+
if (positionals.length > 0) {
|
|
206
|
+
options.only = positionals;
|
|
207
|
+
}
|
|
208
|
+
return options;
|
|
209
|
+
}
|
|
210
|
+
export async function setupReposCli(argv) {
|
|
211
|
+
const options = parseArguments(argv);
|
|
212
|
+
const config = await loadConfig();
|
|
213
|
+
const result = await setupRepos(config, options);
|
|
214
|
+
if (result.ghMissing || result.failed.length > 0) {
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Remaining skips mean setup is incomplete — signal that to CI gates.
|
|
219
|
+
if (result.skipped.length > 0) {
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -54,6 +54,9 @@ export interface RemoteRunnerProvider {
|
|
|
54
54
|
runCommand(arguments_: RemoteRunArguments): Promise<string | undefined>;
|
|
55
55
|
runTtyCommand(arguments_: RemoteRunArguments): Promise<void>;
|
|
56
56
|
buildTtyCommand(arguments_: RemoteTtyCommandArguments): string;
|
|
57
|
+
startPortProxy(config: RemoteRunnerConfig, port: number): Promise<{
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
}>;
|
|
57
60
|
listSessions(config: RemoteRunnerConfig): Promise<string>;
|
|
58
61
|
attachSession(config: RemoteRunnerConfig, target: string): Promise<void>;
|
|
59
62
|
listProcesses(config: RemoteRunnerConfig): Promise<string>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"spriteRemoteRunnerProvider.d.ts","sourceRoot":"","sources":["../../src/lib/spriteRemoteRunnerProvider.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"spriteRemoteRunnerProvider.d.ts","sourceRoot":"","sources":["../../src/lib/spriteRemoteRunnerProvider.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
1
3
|
import { runCommandAsync } from "./commandRunner.js";
|
|
2
4
|
import { shellSingleQuote } from "./shell.js";
|
|
3
5
|
const LONG_RUNNING_COMMAND_OPTIONS = { stdio: "inherit", timeoutMs: 0 };
|
|
6
|
+
const PROXY_CLOSE_SIGNAL = "SIGINT";
|
|
7
|
+
const PROXY_READY_HOST = "127.0.0.1";
|
|
8
|
+
const PROXY_READY_TIMEOUT_MS = 5000;
|
|
9
|
+
const PROXY_READY_RETRY_DELAY_MS = 25;
|
|
4
10
|
export const SPRITE_REMOTE_PROVIDER_DEFAULTS = {
|
|
5
11
|
provider: "sprite",
|
|
6
12
|
runnerName: "crew-claude-1",
|
|
@@ -155,6 +161,96 @@ async function createSpriteRunner(config) {
|
|
|
155
161
|
timeoutMs: 0,
|
|
156
162
|
});
|
|
157
163
|
}
|
|
164
|
+
async function startSpritePortProxy(config, port) {
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
let closeWasRequested = false;
|
|
167
|
+
let proxyError;
|
|
168
|
+
const proxy = (async () => {
|
|
169
|
+
try {
|
|
170
|
+
await runCommandAsync("sprite", ["proxy", "-s", config.runnerName, String(port)], {
|
|
171
|
+
signal: controller.signal,
|
|
172
|
+
stdio: "inherit",
|
|
173
|
+
timeoutMs: 0,
|
|
174
|
+
});
|
|
175
|
+
if (!closeWasRequested) {
|
|
176
|
+
proxyError = new Error("Sprite proxy exited before it was closed.");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (closeWasRequested && errorHasSignal(error, PROXY_CLOSE_SIGNAL)) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
proxyError = new Error(`Sprite proxy exited before it was closed: ${String(error)}`, {
|
|
184
|
+
cause: error,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
})();
|
|
188
|
+
try {
|
|
189
|
+
await waitForSpritePortProxy({ port, proxy, proxyError: () => proxyError });
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
closeWasRequested = true;
|
|
193
|
+
controller.abort();
|
|
194
|
+
await proxy;
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
async close() {
|
|
199
|
+
closeWasRequested = true;
|
|
200
|
+
controller.abort();
|
|
201
|
+
await proxy;
|
|
202
|
+
if (proxyError !== undefined) {
|
|
203
|
+
throw new Error(proxyError.message, { cause: proxyError });
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
async function waitForSpritePortProxy(arguments_) {
|
|
209
|
+
const deadline = Date.now() + PROXY_READY_TIMEOUT_MS;
|
|
210
|
+
while (Date.now() < deadline) {
|
|
211
|
+
throwIfProxyExited(arguments_.proxyError());
|
|
212
|
+
// eslint-disable-next-line no-await-in-loop -- readiness polling must observe attempts sequentially.
|
|
213
|
+
if (await canConnectToLocalPort(arguments_.port)) {
|
|
214
|
+
throwIfProxyExited(arguments_.proxyError());
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
throwIfProxyExited(arguments_.proxyError());
|
|
218
|
+
// eslint-disable-next-line no-await-in-loop -- retry delay is bounded and stops early if the proxy exits.
|
|
219
|
+
await Promise.race([sleep(PROXY_READY_RETRY_DELAY_MS), arguments_.proxy]);
|
|
220
|
+
}
|
|
221
|
+
throwIfProxyExited(arguments_.proxyError());
|
|
222
|
+
throw new Error(`Timed out waiting for Sprite proxy on ${PROXY_READY_HOST}:${arguments_.port} to accept connections.`);
|
|
223
|
+
}
|
|
224
|
+
function throwIfProxyExited(error) {
|
|
225
|
+
if (error !== undefined) {
|
|
226
|
+
throw new Error(error.message, { cause: error });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function canConnectToLocalPort(port) {
|
|
230
|
+
return await new Promise((resolve) => {
|
|
231
|
+
const socket = connect({ host: PROXY_READY_HOST, port });
|
|
232
|
+
socket.once("connect", () => {
|
|
233
|
+
socket.destroy();
|
|
234
|
+
resolve(true);
|
|
235
|
+
});
|
|
236
|
+
socket.once("error", () => {
|
|
237
|
+
socket.destroy();
|
|
238
|
+
resolve(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function errorHasSignal(error, signal) {
|
|
243
|
+
if (typeof error !== "object" || error === null) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
if ("signal" in error && error.signal === signal) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (error instanceof Error && error.cause !== undefined) {
|
|
250
|
+
return errorHasSignal(error.cause, signal);
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
158
254
|
export const spriteRemoteRunnerProvider = {
|
|
159
255
|
name: "sprite",
|
|
160
256
|
async runnerExists(config) {
|
|
@@ -174,6 +270,9 @@ export const spriteRemoteRunnerProvider = {
|
|
|
174
270
|
});
|
|
175
271
|
},
|
|
176
272
|
buildTtyCommand: buildSpriteTtyCommand,
|
|
273
|
+
async startPortProxy(config, port) {
|
|
274
|
+
return await startSpritePortProxy(config, port);
|
|
275
|
+
},
|
|
177
276
|
async listSessions(config) {
|
|
178
277
|
const output = await runCommandAsync("sprite", ["sessions", "list", "-s", config.runnerName], {
|
|
179
278
|
trim: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clipboard-health/groundcrew",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle, remote runners, and usage tracking.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|