@clipboard-health/groundcrew 3.1.9 → 3.2.1
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 +10 -6
- package/crew.config.example.ts +19 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -0
- package/dist/commands/dispatcher.d.ts.map +1 -1
- package/dist/commands/dispatcher.js +10 -10
- package/dist/commands/init.d.ts +26 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +82 -0
- package/dist/commands/sandbox/auth.d.ts +3 -0
- package/dist/commands/sandbox/auth.d.ts.map +1 -0
- package/dist/commands/sandbox/auth.js +227 -0
- package/dist/commands/sandbox/index.d.ts +2 -0
- package/dist/commands/sandbox/index.d.ts.map +1 -0
- package/dist/commands/sandbox/index.js +47 -0
- package/dist/commands/sandbox/inspect.d.ts +2 -0
- package/dist/commands/sandbox/inspect.d.ts.map +1 -0
- package/dist/commands/sandbox/inspect.js +18 -0
- package/dist/commands/sandbox/lifecycle.d.ts +7 -0
- package/dist/commands/sandbox/lifecycle.d.ts.map +1 -0
- package/dist/commands/sandbox/lifecycle.js +68 -0
- package/dist/commands/sandbox/model.d.ts +10 -0
- package/dist/commands/sandbox/model.d.ts.map +1 -0
- package/dist/commands/sandbox/model.js +37 -0
- package/dist/commands/sandbox/picker.d.ts +20 -0
- package/dist/commands/sandbox/picker.d.ts.map +1 -0
- package/dist/commands/sandbox/picker.js +23 -0
- package/dist/lib/agentLaunch.d.ts.map +1 -1
- package/dist/lib/agentLaunch.js +1 -0
- package/dist/lib/config.d.ts +70 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +79 -13
- package/dist/lib/dockerSandbox.d.ts +12 -8
- package/dist/lib/dockerSandbox.d.ts.map +1 -1
- package/dist/lib/dockerSandbox.js +33 -22
- package/dist/lib/sandboxGitDefaults.d.ts +10 -0
- package/dist/lib/sandboxGitDefaults.d.ts.map +1 -0
- package/dist/lib/sandboxGitDefaults.js +31 -0
- package/dist/lib/xdg.d.ts +3 -0
- package/dist/lib/xdg.d.ts.map +1 -0
- package/dist/lib/xdg.js +19 -0
- package/package.json +12 -11
package/README.md
CHANGED
|
@@ -60,16 +60,19 @@ Installs the `crew` binary. `@clipboard-health/clearance` is pulled in transitiv
|
|
|
60
60
|
|
|
61
61
|
3. **Create a Linear project to scope your work.** Any team works — make a project inside it and drop tickets in. The orchestrator polls by project, not by team.
|
|
62
62
|
|
|
63
|
-
4. **Configure.**
|
|
63
|
+
4. **Configure.** Create a `crew.config.ts` you can edit:
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
# Write into the current folder:
|
|
67
|
+
crew init && $EDITOR crew.config.ts
|
|
68
|
+
|
|
69
|
+
# ...or into the XDG config dir:
|
|
70
|
+
crew init --global && $EDITOR "${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/crew.config.ts"
|
|
70
71
|
```
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
`crew init` refuses to overwrite an existing config; pass `--force` to replace it, or `--dry-run` to preview the destination path.
|
|
74
|
+
|
|
75
|
+
`crew` discovers the config via cosmiconfig project-walk, so dropping it at the root of any repo you run `crew` from works too. Any of `crew.config.{ts,mjs,js,json}`, `.crewrc{,.json,.ts}`, `.config/crew.config.{ts,json}`, or `.config/crewrc{,.json}` are recognized.
|
|
73
76
|
|
|
74
77
|
Set `linear.projects[].projectSlug` (paste the trailing slug of your Linear project URL, e.g. `ai-strategy-5152195762f3`), `workspace.projectDir`, and `workspace.knownRepositories`. Defaults cover everything else. To watch multiple projects from one `crew` instance, add more entries to `linear.projects`; they all share the same `orchestrator.maximumInProgress` budget.
|
|
75
78
|
|
|
@@ -359,6 +362,7 @@ To have a coding agent (Claude Code, Cursor, etc.) scaffold `.groundcrew/setup.s
|
|
|
359
362
|
## Commands
|
|
360
363
|
|
|
361
364
|
```bash
|
|
365
|
+
crew init [--global | --local] [--force] [--dry-run] # create a crew.config.ts (cwd by default)
|
|
362
366
|
crew doctor # full setup check
|
|
363
367
|
crew doctor --ticket <TICKET> [--no-linear] [--no-fetch] # full ticket lifecycle (dispatch + recovery)
|
|
364
368
|
crew run # one-shot dispatch
|
package/crew.config.example.ts
CHANGED
|
@@ -92,6 +92,25 @@ export default {
|
|
|
92
92
|
// // macOS when you need an agent to use Docker safely.
|
|
93
93
|
// local: { runner: "auto" },
|
|
94
94
|
//
|
|
95
|
+
// // Additional auth recipes for `crew sandbox auth <model> <tool>`. The
|
|
96
|
+
// // shipped recipes (claude/codex/cursor agents + github tool) are merged
|
|
97
|
+
// // with whatever you declare here; your recipe wins on key collision.
|
|
98
|
+
// // Describe each tool's in-sandbox login + status commands and a regex
|
|
99
|
+
// // that matches its logged-in output. Omit `kind` for cross-cutting
|
|
100
|
+
// // tools that should appear in every sandbox's picker; set
|
|
101
|
+
// // `kind: "agent"` to scope a recipe to a single sbx agent.
|
|
102
|
+
// sandbox: {
|
|
103
|
+
// authRecipes: {
|
|
104
|
+
// gcloud: {
|
|
105
|
+
// displayName: "gcloud",
|
|
106
|
+
// binary: "gcloud",
|
|
107
|
+
// loginArgs: ["auth", "login", "--no-launch-browser"],
|
|
108
|
+
// statusArgs: ["auth", "list", "--filter=status:ACTIVE", "--format=value(account)"],
|
|
109
|
+
// authenticatedPattern: /@/,
|
|
110
|
+
// },
|
|
111
|
+
// },
|
|
112
|
+
// },
|
|
113
|
+
//
|
|
95
114
|
// prompts: {
|
|
96
115
|
// // Keep personal workflow instructions next to this config, for example
|
|
97
116
|
// // `${XDG_CONFIG_HOME:-$HOME/.config}/groundcrew/initial-prompt.md`.
|
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":"AAyKA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { cleanupWorkspaceCli } from "./commands/cleanupWorkspace.js";
|
|
3
3
|
import { doctor } from "./commands/doctor.js";
|
|
4
|
+
import { initConfigCli } from "./commands/init.js";
|
|
4
5
|
import { interruptWorkspaceCli } from "./commands/interruptWorkspace.js";
|
|
5
6
|
import { orchestrate } from "./commands/orchestrator.js";
|
|
6
7
|
import { resumeWorkspaceCli } from "./commands/resumeWorkspace.js";
|
|
8
|
+
import { sandboxCli } from "./commands/sandbox/index.js";
|
|
7
9
|
import { setupReposCli } from "./commands/setupRepos.js";
|
|
8
10
|
import { setupWorkspaceCli } from "./commands/setupWorkspace.js";
|
|
9
11
|
import { errorMessage, readTicketArgument, writeError, writeOutput } from "./lib/util.js";
|
|
@@ -77,6 +79,11 @@ async function doctorCli(argv) {
|
|
|
77
79
|
process.exitCode = ok ? process.exitCode : 1;
|
|
78
80
|
}
|
|
79
81
|
const SUBCOMMANDS = {
|
|
82
|
+
init: {
|
|
83
|
+
summary: "Create a crew.config.ts in the cwd (or --global into the XDG config dir)",
|
|
84
|
+
usage: "[--global | --local] [--force] [--dry-run]",
|
|
85
|
+
invoke: initConfigCli,
|
|
86
|
+
},
|
|
80
87
|
run: {
|
|
81
88
|
summary: "Run the orchestrator (one-shot by default), or provision one ticket with --ticket",
|
|
82
89
|
usage: "[--watch] [--dry-run] [--ticket <ticket>]",
|
|
@@ -102,6 +109,11 @@ const SUBCOMMANDS = {
|
|
|
102
109
|
usage: "<ticket>",
|
|
103
110
|
invoke: resumeWorkspaceCli,
|
|
104
111
|
},
|
|
112
|
+
sandbox: {
|
|
113
|
+
summary: "Manage Docker Sandboxes (sbx) for configured models",
|
|
114
|
+
usage: "<list|ensure|regenerate|auth|rm> [...args]",
|
|
115
|
+
invoke: sandboxCli,
|
|
116
|
+
},
|
|
105
117
|
setup: {
|
|
106
118
|
summary: "Project-level setup commands (currently: repos)",
|
|
107
119
|
usage: "repos [--dry-run] [<repo>...]",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/commands/dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAGpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAWzD,UAAU,cAAc;IACtB,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,UAAU,EAAE;QAClB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,+FAA+F;QAC/F,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;QACvD,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB;;;;WAIG;QACH,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;
|
|
1
|
+
{"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/commands/dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAGpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAWzD,UAAU,cAAc;IACtB,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,UAAU,EAAE;QAClB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,+FAA+F;QAC/F,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;QACvD,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB;;;;WAIG;QACH,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAaD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,UAAU,CA4LjE"}
|
|
@@ -12,6 +12,16 @@ import { errorMessage, log, logEvent } from "../lib/util.js";
|
|
|
12
12
|
import { workspaces } from "../lib/workspaces.js";
|
|
13
13
|
import { classifyBlockers, classifyEligibility, classifyUsageExhaustion, } from "./eligibility.js";
|
|
14
14
|
import { setupWorkspace } from "./setupWorkspace.js";
|
|
15
|
+
function logSkip(verdict) {
|
|
16
|
+
log(verdict.message);
|
|
17
|
+
logEvent("dispatch", {
|
|
18
|
+
outcome: "skipped",
|
|
19
|
+
reason: verdict.eventReason,
|
|
20
|
+
ticket: verdict.issue.id,
|
|
21
|
+
blockers: verdict.blockers,
|
|
22
|
+
model: verdict.model,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
15
25
|
export function createDispatcher(deps) {
|
|
16
26
|
const { config, client } = deps;
|
|
17
27
|
const issueStatusUpdater = createLinearIssueStatusUpdater({ config, client });
|
|
@@ -23,16 +33,6 @@ export function createDispatcher(deps) {
|
|
|
23
33
|
}
|
|
24
34
|
return exhausted;
|
|
25
35
|
}
|
|
26
|
-
function logSkip(verdict) {
|
|
27
|
-
log(verdict.message);
|
|
28
|
-
logEvent("dispatch", {
|
|
29
|
-
outcome: "skipped",
|
|
30
|
-
reason: verdict.eventReason,
|
|
31
|
-
ticket: verdict.issue.id,
|
|
32
|
-
blockers: verdict.blockers,
|
|
33
|
-
model: verdict.model,
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
36
|
async function startEligibleIssue(start, dryRun, signal) {
|
|
37
37
|
const { issue, recovery } = start;
|
|
38
38
|
if (start.resolvedFromAny) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crew init` — create a `crew.config.ts` in the current working directory or,
|
|
3
|
+
* with `--global`, in the XDG groundcrew config dir. The contents come from
|
|
4
|
+
* the shipped `crew.config.example.ts` so a fresh install skips the manual
|
|
5
|
+
* `cp` dance documented in the README.
|
|
6
|
+
*/
|
|
7
|
+
type InitConfigScope = "global" | "local";
|
|
8
|
+
interface InitConfigOptions {
|
|
9
|
+
/** Where to write the config. Defaults to "local" (cwd). */
|
|
10
|
+
scope?: InitConfigScope;
|
|
11
|
+
/** Overwrite an existing destination. */
|
|
12
|
+
force?: boolean;
|
|
13
|
+
/** Report the planned action without touching the filesystem. */
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
/** Override for the working directory; defaults to `process.cwd()`. */
|
|
16
|
+
cwd?: string;
|
|
17
|
+
}
|
|
18
|
+
type InitConfigOutcome = "dry-run-would-write" | "exists" | "wrote";
|
|
19
|
+
interface InitConfigResult {
|
|
20
|
+
destination: string;
|
|
21
|
+
outcome: InitConfigOutcome;
|
|
22
|
+
}
|
|
23
|
+
export declare function initConfig(options?: InitConfigOptions): InitConfigResult;
|
|
24
|
+
export declare function initConfigCli(argv: string[]): Promise<void>;
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=init.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAWH,KAAK,eAAe,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE1C,UAAU,iBAAiB;IACzB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iEAAiE;IACjE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,KAAK,iBAAiB,GAAG,qBAAqB,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEpE,UAAU,gBAAgB;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,iBAAiB,CAAC;CAC5B;AAED,wBAAgB,UAAU,CAAC,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAoB5E;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBjE"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crew init` — create a `crew.config.ts` in the current working directory or,
|
|
3
|
+
* with `--global`, in the XDG groundcrew config dir. The contents come from
|
|
4
|
+
* the shipped `crew.config.example.ts` so a fresh install skips the manual
|
|
5
|
+
* `cp` dance documented in the README.
|
|
6
|
+
*/
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { dirname, resolve } from "node:path";
|
|
9
|
+
import { log, writeOutput } from "../lib/util.js";
|
|
10
|
+
import { xdgConfigPath } from "../lib/xdg.js";
|
|
11
|
+
const CONFIG_FILE_NAME = "crew.config.ts";
|
|
12
|
+
const EXAMPLE_FILE_NAME = "crew.config.example.ts";
|
|
13
|
+
export function initConfig(options = {}) {
|
|
14
|
+
const scope = options.scope ?? "local";
|
|
15
|
+
const cwd = options.cwd ?? process.cwd();
|
|
16
|
+
const source = resolveExamplePath();
|
|
17
|
+
const destination = destinationFor({ scope, cwd });
|
|
18
|
+
if (existsSync(destination) && options.force !== true) {
|
|
19
|
+
log(`[exists] ${destination} — pass --force to overwrite`);
|
|
20
|
+
return { destination, outcome: "exists" };
|
|
21
|
+
}
|
|
22
|
+
if (options.dryRun === true) {
|
|
23
|
+
log(`[dry-run] would write ${destination}`);
|
|
24
|
+
return { destination, outcome: "dry-run-would-write" };
|
|
25
|
+
}
|
|
26
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
27
|
+
copyFileSync(source, destination);
|
|
28
|
+
log(`[wrote] ${destination}`);
|
|
29
|
+
return { destination, outcome: "wrote" };
|
|
30
|
+
}
|
|
31
|
+
export async function initConfigCli(argv) {
|
|
32
|
+
const options = parseArguments(argv);
|
|
33
|
+
const result = initConfig(options);
|
|
34
|
+
if (result.outcome === "exists") {
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (result.outcome === "wrote") {
|
|
39
|
+
writeOutput("");
|
|
40
|
+
writeOutput("Next steps:");
|
|
41
|
+
writeOutput(` - Edit ${result.destination}`);
|
|
42
|
+
writeOutput(" - Set linear.projects[].projectSlug, workspace.projectDir, workspace.knownRepositories");
|
|
43
|
+
writeOutput(" - Export GROUNDCREW_LINEAR_API_KEY (or LINEAR_API_KEY)");
|
|
44
|
+
writeOutput(" - Verify with `crew doctor`");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function parseArguments(argv) {
|
|
48
|
+
let scope;
|
|
49
|
+
let force = false;
|
|
50
|
+
let dryRun = false;
|
|
51
|
+
for (const argument of argv) {
|
|
52
|
+
if (argument === "--global" || argument === "--local") {
|
|
53
|
+
const next = argument === "--global" ? "global" : "local";
|
|
54
|
+
if (scope !== undefined && scope !== next) {
|
|
55
|
+
throw new Error("crew init: --global and --local are mutually exclusive.\nUsage: crew init [--global | --local] [--force] [--dry-run]");
|
|
56
|
+
}
|
|
57
|
+
scope = next;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (argument === "--force") {
|
|
61
|
+
force = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (argument === "--dry-run") {
|
|
65
|
+
dryRun = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Unknown option: ${argument}\nUsage: crew init [--global | --local] [--force] [--dry-run]`);
|
|
69
|
+
}
|
|
70
|
+
return { scope: scope ?? "local", force, dryRun };
|
|
71
|
+
}
|
|
72
|
+
function destinationFor(args) {
|
|
73
|
+
if (args.scope === "global") {
|
|
74
|
+
return xdgConfigPath("groundcrew", CONFIG_FILE_NAME);
|
|
75
|
+
}
|
|
76
|
+
return resolve(args.cwd, CONFIG_FILE_NAME);
|
|
77
|
+
}
|
|
78
|
+
function resolveExamplePath() {
|
|
79
|
+
// `init.ts` lives at src/commands/init.ts in source and dist/commands/init.js
|
|
80
|
+
// after build; the example ships at the package root in both cases.
|
|
81
|
+
return resolve(import.meta.dirname, "..", "..", EXAMPLE_FILE_NAME);
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { loadConfig } from "../../lib/config.js";
|
|
2
|
+
import { runAuth } from "./auth.js";
|
|
3
|
+
import { runList } from "./inspect.js";
|
|
4
|
+
import { runEnsure, runRegenerate, runRemove } from "./lifecycle.js";
|
|
5
|
+
const USAGE = [
|
|
6
|
+
"Usage: crew sandbox <verb> [...args]",
|
|
7
|
+
"",
|
|
8
|
+
"Verbs:",
|
|
9
|
+
" list Show every groundcrew-owned sandbox known to sbx",
|
|
10
|
+
" ensure [<model>] Provision the sandbox for one model, or all when omitted",
|
|
11
|
+
" regenerate <model>|--all Tear down and recreate from current template/kits",
|
|
12
|
+
" auth <model>|--all Open a checkbox picker of every tool available in <model>'s",
|
|
13
|
+
" sandbox and run the login flow for each one you select;",
|
|
14
|
+
" --all loops through every configured sandbox in turn",
|
|
15
|
+
" rm <model> Remove the sandbox for a model",
|
|
16
|
+
].join("\n");
|
|
17
|
+
export async function sandboxCli(argv) {
|
|
18
|
+
const [verb, ...rest] = argv;
|
|
19
|
+
if (verb === undefined) {
|
|
20
|
+
throw new Error(USAGE);
|
|
21
|
+
}
|
|
22
|
+
switch (verb) {
|
|
23
|
+
case "list": {
|
|
24
|
+
await runList();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
case "ensure": {
|
|
28
|
+
await runEnsure(await loadConfig(), rest);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
case "regenerate": {
|
|
32
|
+
await runRegenerate(await loadConfig(), rest);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
case "auth": {
|
|
36
|
+
await runAuth(await loadConfig(), rest);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
case "rm": {
|
|
40
|
+
await runRemove(await loadConfig(), rest);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
default: {
|
|
44
|
+
throw new Error(`Unknown sandbox sub-verb: ${verb}\n${USAGE}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../../../src/commands/sandbox/inspect.ts"],"names":[],"mappings":"AAKA,wBAAsB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAc7C"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runCommandAsync } from "../../lib/commandRunner.js";
|
|
2
|
+
import { writeOutput } from "../../lib/util.js";
|
|
3
|
+
const SANDBOX_NAME_PREFIX = "groundcrew-";
|
|
4
|
+
export async function runList() {
|
|
5
|
+
const output = await runCommandAsync("sbx", ["ls"]);
|
|
6
|
+
const names = output
|
|
7
|
+
.split("\n")
|
|
8
|
+
.map((line) => line.trim().split(/\s+/)[0])
|
|
9
|
+
.filter((name) => name !== undefined && name.startsWith(SANDBOX_NAME_PREFIX))
|
|
10
|
+
.map((name) => name.slice(SANDBOX_NAME_PREFIX.length));
|
|
11
|
+
if (names.length === 0) {
|
|
12
|
+
writeOutput("(none)");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
for (const name of names) {
|
|
16
|
+
writeOutput(name);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../../lib/config.ts";
|
|
2
|
+
import { type SandboxModel } from "./model.ts";
|
|
3
|
+
export declare function ensureOne(config: ResolvedConfig, model: SandboxModel, alreadyExists?: boolean): Promise<void>;
|
|
4
|
+
export declare function runEnsure(config: ResolvedConfig, argv: string[]): Promise<void>;
|
|
5
|
+
export declare function runRegenerate(config: ResolvedConfig, argv: string[]): Promise<void>;
|
|
6
|
+
export declare function runRemove(config: ResolvedConfig, argv: string[]): Promise<void>;
|
|
7
|
+
//# sourceMappingURL=lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/commands/sandbox/lifecycle.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,EAAsC,KAAK,YAAY,EAAiB,MAAM,YAAY,CAAC;AAElG,wBAAsB,SAAS,CAC7B,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,YAAY,EACnB,aAAa,CAAC,EAAE,OAAO,GACtB,OAAO,CAAC,IAAI,CAAC,CAQf;AAMD,wBAAsB,SAAS,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBrF;AAUD,wBAAsB,aAAa,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBzF;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAMrF"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { runCommandAsync } from "../../lib/commandRunner.js";
|
|
3
|
+
import { ensureSandbox, sandboxExists } from "../../lib/dockerSandbox.js";
|
|
4
|
+
import { writeOutput } from "../../lib/util.js";
|
|
5
|
+
import { requireOnePositional, resolveModel, sandboxModels } from "./model.js";
|
|
6
|
+
export async function ensureOne(config, model, alreadyExists) {
|
|
7
|
+
await ensureSandbox({
|
|
8
|
+
sandboxName: model.sandboxName,
|
|
9
|
+
sandbox: model.sandbox,
|
|
10
|
+
mountPath: resolve(config.workspace.projectDir),
|
|
11
|
+
gitDefaults: config.sandbox.gitDefaults,
|
|
12
|
+
...(alreadyExists === undefined ? {} : { alreadyExists }),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
async function removeOne(model) {
|
|
16
|
+
await runCommandAsync("sbx", ["rm", "--force", model.sandboxName]);
|
|
17
|
+
}
|
|
18
|
+
export async function runEnsure(config, argv) {
|
|
19
|
+
const targets = argv.length === 0
|
|
20
|
+
? sandboxModels(config)
|
|
21
|
+
: [resolveModel(config, requireOnePositional(argv, "Usage: crew sandbox ensure [<model>]"))];
|
|
22
|
+
if (targets.length === 0) {
|
|
23
|
+
writeOutput("No sandbox models configured.");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
for (const model of targets) {
|
|
27
|
+
// oxlint-disable-next-line no-await-in-loop -- one sandbox at a time; probe then ensure
|
|
28
|
+
const existed = await sandboxExists(model.sandboxName);
|
|
29
|
+
writeOutput(existed
|
|
30
|
+
? `${model.sandboxName}: already exists`
|
|
31
|
+
: `${model.sandboxName}: creating (agent=${model.sandbox.agent}, template=${model.sandbox.template ?? "default"})`);
|
|
32
|
+
// oxlint-disable-next-line no-await-in-loop -- sbx create is intentionally sequential
|
|
33
|
+
await ensureOne(config, model, existed);
|
|
34
|
+
if (!existed) {
|
|
35
|
+
writeOutput(`${model.sandboxName}: created`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function regenerateTargets(config, argv) {
|
|
40
|
+
const target = requireOnePositional(argv, "Usage: crew sandbox regenerate <model>|--all");
|
|
41
|
+
if (target === "--all") {
|
|
42
|
+
return sandboxModels(config);
|
|
43
|
+
}
|
|
44
|
+
return [resolveModel(config, target)];
|
|
45
|
+
}
|
|
46
|
+
export async function runRegenerate(config, argv) {
|
|
47
|
+
const targets = regenerateTargets(config, argv);
|
|
48
|
+
if (targets.length === 0) {
|
|
49
|
+
writeOutput("No sandbox models configured.");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const model of targets) {
|
|
53
|
+
writeOutput(`${model.sandboxName}: removing existing sandbox...`);
|
|
54
|
+
// oxlint-disable-next-line no-await-in-loop -- sbx rm/create are intentionally sequential
|
|
55
|
+
await removeOne(model);
|
|
56
|
+
writeOutput(`${model.sandboxName}: creating (agent=${model.sandbox.agent}, template=${model.sandbox.template ?? "default"})`);
|
|
57
|
+
// oxlint-disable-next-line no-await-in-loop -- sbx rm/create are intentionally sequential
|
|
58
|
+
await ensureOne(config, model, false);
|
|
59
|
+
writeOutput(`${model.sandboxName}: regenerated`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function runRemove(config, argv) {
|
|
63
|
+
const modelName = requireOnePositional(argv, "Usage: crew sandbox rm <model>");
|
|
64
|
+
const model = resolveModel(config, modelName);
|
|
65
|
+
writeOutput(`${model.sandboxName}: removing...`);
|
|
66
|
+
await removeOne(model);
|
|
67
|
+
writeOutput(`${model.sandboxName}: removed`);
|
|
68
|
+
}
|