badgr-cli 1.0.40 → 1.0.42
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 +32 -15
- package/package.json +3 -3
- package/src/catalog.js +62 -0
- package/src/commands/comfyui.js +132 -0
- package/src/commands/run.js +201 -24
- package/src/commands/serve.js +39 -14
- package/src/commands/train.js +129 -0
- package/src/fallback.js +6 -4
- package/tests/launch-readiness.test.js +25 -0
- package/tests/productized-runners.test.js +230 -0
- package/tests/serve-lifecycle.test.js +114 -0
- package/tests/template.test.js +4 -4
- package/tests/upload.test.js +79 -0
package/README.md
CHANGED
|
@@ -17,11 +17,15 @@ badgr login
|
|
|
17
17
|
# 2. Verify the stack end-to-end
|
|
18
18
|
badgr test
|
|
19
19
|
|
|
20
|
-
# 3.
|
|
21
|
-
badgr
|
|
20
|
+
# 3. Run a project folder on a GPU
|
|
21
|
+
badgr run . --cmd "python train.py" --max-cost 5
|
|
22
22
|
|
|
23
|
-
# 4.
|
|
24
|
-
|
|
23
|
+
# 4. Serve an OpenAI-compatible inference endpoint
|
|
24
|
+
badgr serve meta-llama/Llama-3.1-8B-Instruct --max-cost 10
|
|
25
|
+
|
|
26
|
+
# 5. Use the endpoint with any OpenAI SDK client
|
|
27
|
+
# export BADGR_ENDPOINT=<URL printed by badgr serve>
|
|
28
|
+
# client = OpenAI(api_key=os.environ["BADGR_API_KEY"], base_url=os.environ["BADGR_ENDPOINT"])
|
|
25
29
|
|
|
26
30
|
# 5. View cost, route, and retry receipts
|
|
27
31
|
badgr receipts
|
|
@@ -92,25 +96,36 @@ badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S --region EU
|
|
|
92
96
|
|
|
93
97
|
## `badgr run` options
|
|
94
98
|
|
|
99
|
+
Three source patterns:
|
|
100
|
+
|
|
95
101
|
```bash
|
|
96
|
-
|
|
102
|
+
# Flow 1 — local project folder (primary)
|
|
103
|
+
badgr run . --cmd "python train.py" --max-cost 5
|
|
104
|
+
|
|
105
|
+
# Flow 2 — public GitHub repo
|
|
106
|
+
badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5
|
|
107
|
+
|
|
108
|
+
# Flow 3 — custom Docker image (advanced)
|
|
109
|
+
badgr run . --image mycompany/custom:latest --cmd "python train.py" --max-cost 5
|
|
97
110
|
```
|
|
98
111
|
|
|
112
|
+
Badgr zips and uploads the folder (Flow 1) or clones the repo (Flow 2), picks a generic runner, installs deps, runs the command, stores outputs for 48 hours, and tears down the GPU. `--max-cost` is required.
|
|
113
|
+
|
|
99
114
|
| Flag | Default | Description |
|
|
100
115
|
|------|---------|-------------|
|
|
116
|
+
| `--cmd <command>` | — | Command to run inside the uploaded project or cloned repo (required for folder/GitHub flows) |
|
|
101
117
|
| `--gpu <type>` | auto | GPU type override — Badgr Auto selects if omitted |
|
|
102
118
|
| `--min-vram <GB>` | — | Minimum VRAM in GB — optional constraint for Auto routing |
|
|
103
|
-
| `--image <img>` |
|
|
119
|
+
| `--image <img>` | — | Custom Docker image — bypasses the runner. Mutually exclusive with workspace auto-detection. |
|
|
104
120
|
| `--env KEY=VALUE` | — | Environment variable (repeatable) |
|
|
105
121
|
| `--tier 1\|2` | 1 | `1` = reliable execution (default); `2` = lower-cost burst capacity |
|
|
106
122
|
| `--count <n>` | 1 | Number of GPUs |
|
|
107
123
|
| `--region US\|EU\|AU` | — | Optional region preference. If omitted, Badgr chooses best available capacity. |
|
|
108
124
|
| `--max-price <$/hr>` | — | Hard spend cap per GPU-hour |
|
|
109
|
-
| `--max-runtime <min>` | — | Auto-stop after N minutes
|
|
110
|
-
| `--max-cost <$>` | — | Auto-stop when total spend reaches this amount |
|
|
125
|
+
| `--max-runtime <min>` | — | Auto-stop after N minutes |
|
|
126
|
+
| `--max-cost <$>` | — | Auto-stop when total spend reaches this amount (required) |
|
|
111
127
|
| `--detach` | — | Launch and return immediately, don't stream logs |
|
|
112
128
|
| `--save <name>` | — | Save this job as a named workload after it completes |
|
|
113
|
-
| `--workspace <name\|id>` | — | Link this job to a workspace tracker (name or `ws_…` ID) |
|
|
114
129
|
|
|
115
130
|
---
|
|
116
131
|
|
|
@@ -216,7 +231,7 @@ A workload is a saved job configuration. Once saved, you can rerun it by name in
|
|
|
216
231
|
**Save a workload** by adding `--save <name>` to any `badgr run` call:
|
|
217
232
|
|
|
218
233
|
```bash
|
|
219
|
-
badgr run python train.py --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-
|
|
234
|
+
badgr run . --cmd "python train.py" --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-cost 10 --save my-training-job
|
|
220
235
|
```
|
|
221
236
|
|
|
222
237
|
**Rerun a saved workload:**
|
|
@@ -245,11 +260,11 @@ badgr workload delete my-training-job
|
|
|
245
260
|
|
|
246
261
|
## Workspaces
|
|
247
262
|
|
|
248
|
-
|
|
263
|
+
Workspaces are an advanced infrastructure concept — most users never need to manage them directly. When you run `badgr run .`, Badgr handles code upload, dependency caching, and artifact storage automatically. Workspaces are only needed when you want to group jobs under a named context for cost tracking, or link jobs to persistent S3/GCS storage.
|
|
249
264
|
|
|
250
265
|
```bash
|
|
251
266
|
badgr workspace create my-project --storage s3://my-bucket/runs --desc "nightly evals"
|
|
252
|
-
badgr run python eval.py --workspace my-project --max-cost 5
|
|
267
|
+
badgr run . --cmd "python eval.py" --workspace my-project --max-cost 5
|
|
253
268
|
badgr workspace info my-project # jobs, total cost, files
|
|
254
269
|
badgr workspace list
|
|
255
270
|
badgr workspace delete my-project
|
|
@@ -298,11 +313,13 @@ Each receipt includes: receipt ID, GPU type, provisioning latency, price/hr, ret
|
|
|
298
313
|
`badgr serve` provisions a vLLM endpoint that is fully OpenAI-compatible:
|
|
299
314
|
|
|
300
315
|
```python
|
|
316
|
+
import os
|
|
301
317
|
from openai import OpenAI
|
|
302
318
|
|
|
319
|
+
# Export BADGR_ENDPOINT from the URL printed by `badgr serve`
|
|
303
320
|
client = OpenAI(
|
|
304
|
-
api_key="
|
|
305
|
-
base_url=
|
|
321
|
+
api_key=os.environ["BADGR_API_KEY"],
|
|
322
|
+
base_url=os.environ["BADGR_ENDPOINT"],
|
|
306
323
|
)
|
|
307
324
|
resp = client.chat.completions.create(
|
|
308
325
|
model="meta-llama/Llama-3.1-8B-Instruct",
|
|
@@ -314,7 +331,7 @@ resp = client.chat.completions.create(
|
|
|
314
331
|
import OpenAI from "openai";
|
|
315
332
|
const client = new OpenAI({
|
|
316
333
|
apiKey: process.env.BADGR_API_KEY,
|
|
317
|
-
baseURL:
|
|
334
|
+
baseURL: process.env.BADGR_ENDPOINT, // URL printed by `badgr serve`
|
|
318
335
|
});
|
|
319
336
|
```
|
|
320
337
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "badgr-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.42",
|
|
4
4
|
"description": "Badgr — run or serve GPU workloads from one command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"test:watch": "vitest"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"
|
|
16
|
-
"
|
|
15
|
+
"@inquirer/prompts": "^8.5.2",
|
|
16
|
+
"chalk": "^5.3.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"vitest": "^4.1.8"
|
package/src/catalog.js
CHANGED
|
@@ -40,6 +40,30 @@ export const TEMPLATES = [
|
|
|
40
40
|
'Pass HF_TOKEN via --env or BADGR_HF_TOKEN env var.',
|
|
41
41
|
],
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
name: 'batch-inference',
|
|
45
|
+
title: 'Batch Inference',
|
|
46
|
+
description: 'Run a Python script against many inputs on GPU — eval, scoring, embedding, generation',
|
|
47
|
+
type: 'job',
|
|
48
|
+
image: 'pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime',
|
|
49
|
+
gpu: 'RTX_4090',
|
|
50
|
+
gpu_count: 1,
|
|
51
|
+
min_vram_gb: 16,
|
|
52
|
+
env: {
|
|
53
|
+
HF_TOKEN: '<your-hf-token>',
|
|
54
|
+
INPUT_FILE: 'inputs.jsonl',
|
|
55
|
+
OUTPUT_FILE: 'outputs.jsonl',
|
|
56
|
+
MODEL: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
57
|
+
HF_HUB_CACHE: '/workspace/hf-cache',
|
|
58
|
+
BADGR_OUTPUT_DIR: '/workspace/outputs',
|
|
59
|
+
},
|
|
60
|
+
notes: [
|
|
61
|
+
'Put your script and inputs.jsonl in the project folder.',
|
|
62
|
+
'Write outputs to $BADGR_OUTPUT_DIR — they are uploaded as artifacts (48h TTL).',
|
|
63
|
+
'Download results: `badgr artifacts <job-id>`',
|
|
64
|
+
'Example: `badgr run . --cmd "python batch.py" --max-cost 5 --save my-batch`',
|
|
65
|
+
],
|
|
66
|
+
},
|
|
43
67
|
{
|
|
44
68
|
name: 'unsloth',
|
|
45
69
|
title: 'Unsloth Fine-Tuning',
|
|
@@ -418,6 +442,44 @@ export const TEMPLATES = [
|
|
|
418
442
|
|
|
419
443
|
export const TEMPLATE_MAP = Object.fromEntries(TEMPLATES.map(t => [t.name, t]));
|
|
420
444
|
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Productized runner catalogs
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
/** Blessed vLLM model aliases. `badgr serve qwen-7b` expands to the full model ID. */
|
|
450
|
+
export const BLESSED_VLLM_MODELS = {
|
|
451
|
+
'qwen-7b': {
|
|
452
|
+
model_id: 'Qwen/Qwen2.5-7B-Instruct',
|
|
453
|
+
gpu_type: 'RTX_4090',
|
|
454
|
+
image: 'vllm/vllm-openai:latest',
|
|
455
|
+
health_path: '/v1/models',
|
|
456
|
+
description: 'Qwen 2.5 7B Instruct — fast, multilingual',
|
|
457
|
+
},
|
|
458
|
+
'llama-8b': {
|
|
459
|
+
model_id: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
460
|
+
gpu_type: 'RTX_4090',
|
|
461
|
+
image: 'vllm/vllm-openai:latest',
|
|
462
|
+
health_path: '/v1/models',
|
|
463
|
+
description: 'Llama 3.1 8B Instruct — Meta flagship 8B',
|
|
464
|
+
},
|
|
465
|
+
'qwen-coder-7b': {
|
|
466
|
+
model_id: 'Qwen/Qwen2.5-Coder-7B-Instruct',
|
|
467
|
+
gpu_type: 'RTX_4090',
|
|
468
|
+
image: 'vllm/vllm-openai:latest',
|
|
469
|
+
health_path: '/v1/models',
|
|
470
|
+
description: 'Qwen 2.5 Coder 7B — code generation specialist',
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
/** Blessed ComfyUI workflows accepted by `POST /v1/jobs` comfy.batch. */
|
|
475
|
+
export const BLESSED_COMFY_WORKFLOWS = {
|
|
476
|
+
'sdxl-basic': {
|
|
477
|
+
description: 'SDXL text-to-image with default sampler settings',
|
|
478
|
+
gpu_type: 'RTX_4090',
|
|
479
|
+
output_type: 'images',
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
|
|
421
483
|
/**
|
|
422
484
|
* Build the args array passed to serveCommand / runCommand.
|
|
423
485
|
* Template defaults are applied first; CLI overrides win.
|
package/src/commands/comfyui.js
CHANGED
|
@@ -110,11 +110,143 @@ async function validateComfyNodes(endpointUrl, nodeList, chalk) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// badgr comfyui batch — productized batch via POST /v1/jobs comfy.batch
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export function parseComfyBatchArgs(args) {
|
|
118
|
+
const flags = {};
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < args.length) {
|
|
121
|
+
const a = args[i];
|
|
122
|
+
if (a === '--workflow') { flags.workflow = args[++i]; i++; continue; }
|
|
123
|
+
if (a === '--prompts') { flags.prompts = args[++i]; i++; continue; }
|
|
124
|
+
if (a === '--prompt') { if (!flags.inlinePrompts) flags.inlinePrompts = []; flags.inlinePrompts.push(args[++i]); i++; continue; }
|
|
125
|
+
if (a === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
|
|
126
|
+
if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
|
|
127
|
+
if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
128
|
+
if (a === '--gpu-type') { flags.gpuType = args[++i]; i++; continue; }
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
return flags;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function comfyBatchCommand(config, args, chalk) {
|
|
135
|
+
const { readFileSync, existsSync } = await import('fs');
|
|
136
|
+
const { callApi } = await import('../api.js');
|
|
137
|
+
const { addReceipt, generateReceiptId } = await import('../store.js');
|
|
138
|
+
const flags = parseComfyBatchArgs(args);
|
|
139
|
+
|
|
140
|
+
if (!flags.workflow) {
|
|
141
|
+
console.error(chalk.red('\n Usage: badgr comfyui batch --workflow sdxl-basic --prompts prompts.txt --max-cost 10\n'));
|
|
142
|
+
console.error(chalk.dim(' Runs a batch of prompts through a blessed ComfyUI workflow and returns image URLs.\n'));
|
|
143
|
+
console.error(chalk.dim(' Blessed workflows: sdxl-basic\n'));
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!flags.maxCost) {
|
|
149
|
+
console.error(chalk.red('\n ✗ --max-cost is required.\n'));
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
requireApiKey(config);
|
|
155
|
+
|
|
156
|
+
// Collect prompts from file or --prompt flags
|
|
157
|
+
let prompts = flags.inlinePrompts || [];
|
|
158
|
+
if (flags.prompts) {
|
|
159
|
+
if (!existsSync(flags.prompts)) {
|
|
160
|
+
console.error(chalk.red(`\n ✗ Prompts file not found: ${flags.prompts}\n`));
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const lines = readFileSync(flags.prompts, 'utf8')
|
|
165
|
+
.split('\n')
|
|
166
|
+
.map(l => l.trim())
|
|
167
|
+
.filter(Boolean);
|
|
168
|
+
prompts = prompts.concat(lines);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (prompts.length === 0) {
|
|
172
|
+
console.error(chalk.red('\n ✗ No prompts provided. Use --prompts file.txt or --prompt "text"\n'));
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (prompts.length > 20) {
|
|
178
|
+
console.error(chalk.red(`\n ✗ Max 20 prompts per batch (got ${prompts.length})\n`));
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const input = { workflow_id: flags.workflow, prompts };
|
|
184
|
+
if (flags.gpuType) input.gpu_type = flags.gpuType;
|
|
185
|
+
|
|
186
|
+
const rcptId = generateReceiptId();
|
|
187
|
+
const maxRuntime = flags.maxRuntime ?? 60;
|
|
188
|
+
|
|
189
|
+
console.log(chalk.bold('\n⚡ Starting ComfyUI batch\n'));
|
|
190
|
+
console.log(` ${chalk.bold('Workflow:')} ${flags.workflow}`);
|
|
191
|
+
console.log(` ${chalk.bold('Prompts:')} ${prompts.length}`);
|
|
192
|
+
console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
|
|
193
|
+
console.log(` ${chalk.bold('Max runtime:')} ${maxRuntime} min\n`);
|
|
194
|
+
|
|
195
|
+
let job;
|
|
196
|
+
try {
|
|
197
|
+
job = await callApi(config, 'POST', '/v1/jobs', {
|
|
198
|
+
type: 'comfy.batch',
|
|
199
|
+
input,
|
|
200
|
+
policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error(chalk.red(`\n ✗ Failed to submit job: ${err.message}\n`));
|
|
204
|
+
process.exitCode = 1;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
addReceipt({ id: rcptId, type: 'comfy.batch', job_id: job.job_id, started_at: Date.now() });
|
|
209
|
+
console.log(` ${chalk.bold('Job ID:')} ${job.job_id}`);
|
|
210
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
211
|
+
console.log(chalk.dim('\n Polling for completion…\n'));
|
|
212
|
+
|
|
213
|
+
const startMs = Date.now();
|
|
214
|
+
const maxMs = maxRuntime * 60 * 1000;
|
|
215
|
+
while (Date.now() - startMs < maxMs) {
|
|
216
|
+
await new Promise(r => setTimeout(r, 15_000));
|
|
217
|
+
let detail;
|
|
218
|
+
try { detail = await callApi(config, 'GET', `/v1/jobs/${job.job_id}`); } catch { continue; }
|
|
219
|
+
process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
|
|
220
|
+
if (detail.status === 'completed') {
|
|
221
|
+
const out = detail.output || {};
|
|
222
|
+
console.log(chalk.green('\n\n ✓ Batch complete\n'));
|
|
223
|
+
if (out.image_urls && out.image_urls.length > 0) {
|
|
224
|
+
console.log(` ${chalk.bold('Images (${out.image_urls.length}):')}`);
|
|
225
|
+
out.image_urls.forEach((url, i) => console.log(` ${i + 1}. ${url}`));
|
|
226
|
+
}
|
|
227
|
+
console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (detail.status === 'failed') {
|
|
231
|
+
console.error(chalk.red(`\n\n ✗ Batch failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
console.error(chalk.yellow('\n\n Batch still running — detached. Check status:\n'));
|
|
237
|
+
console.error(chalk.dim(` badgr status\n`));
|
|
238
|
+
}
|
|
239
|
+
|
|
113
240
|
export async function comfyuiCommand(config, args, chalk) {
|
|
241
|
+
// Route subcommands
|
|
242
|
+
const sub = args[0];
|
|
243
|
+
if (sub === 'batch') return comfyBatchCommand(config, args.slice(1), chalk);
|
|
244
|
+
|
|
114
245
|
const { workflow, flags } = parseComfyuiArgs(args);
|
|
115
246
|
|
|
116
247
|
if (!workflow) {
|
|
117
248
|
console.error(chalk.red('\n Usage: badgr comfyui run workflow.json\n'));
|
|
249
|
+
console.error(chalk.dim(' Batch mode: badgr comfyui batch --workflow sdxl-basic --prompts prompts.txt --max-cost 10\n'));
|
|
118
250
|
console.error(chalk.dim(' Launches ComfyUI, queues your workflow, and returns the URL.\n'));
|
|
119
251
|
process.exitCode = 1;
|
|
120
252
|
return;
|
package/src/commands/run.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { createWriteStream } from 'fs';
|
|
1
5
|
import { requireApiKey } from '../config.js';
|
|
2
6
|
import { callApi, terminateDeployment } from '../api.js';
|
|
3
7
|
import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
@@ -6,10 +10,19 @@ import { formatCliError } from '../errors.js';
|
|
|
6
10
|
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* Flow 1 — local project (primary):
|
|
14
|
+
* badgr run . --cmd "python train.py" --max-cost 5 --save my-training-job
|
|
15
|
+
* badgr run ./my-project --cmd "python train.py" --max-cost 5
|
|
16
|
+
*
|
|
17
|
+
* Flow 2 — public GitHub repo:
|
|
18
|
+
* badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5
|
|
19
|
+
*
|
|
20
|
+
* Flow 3 — custom image (advanced):
|
|
21
|
+
* badgr run . --image mycompany/custom:latest --cmd "python train.py" --max-cost 5
|
|
22
|
+
*
|
|
23
|
+
* Legacy / direct command:
|
|
24
|
+
* badgr run python train.py --gpu A100
|
|
25
|
+
* badgr run --image my/image:latest --gpu L40S --detach
|
|
13
26
|
*/
|
|
14
27
|
export function parseRunArgs(args) {
|
|
15
28
|
const flags = {};
|
|
@@ -41,6 +54,7 @@ export function parseRunArgs(args) {
|
|
|
41
54
|
if (flagArgs[i] === '--dry-run') { flags.dryRun = true; i++; continue; }
|
|
42
55
|
if (flagArgs[i] === '--save') { flags.save = flagArgs[++i]; i++; continue; }
|
|
43
56
|
if (flagArgs[i] === '--workspace') { flags.workspace = flagArgs[++i]; i++; continue; }
|
|
57
|
+
if (flagArgs[i] === '--cmd') { flags.cmd = flagArgs[++i]; i++; continue; }
|
|
44
58
|
if (flagArgs[i] === '--env') {
|
|
45
59
|
const kv = flagArgs[++i]; i++;
|
|
46
60
|
if (!flags.env) flags.env = [];
|
|
@@ -342,9 +356,117 @@ const _KNOWN_RUN_FLAGS = new Set([
|
|
|
342
356
|
'--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
|
|
343
357
|
'--detach', '--fallback', '--no-fallback', '--strict-capacity',
|
|
344
358
|
'--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
|
|
345
|
-
'--dry-run',
|
|
359
|
+
'--dry-run', '--cmd', '--save', '--workspace',
|
|
346
360
|
]);
|
|
347
361
|
|
|
362
|
+
// Directories and files always excluded from project zip uploads.
|
|
363
|
+
const _ZIP_EXCLUDES = new Set([
|
|
364
|
+
'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.env',
|
|
365
|
+
'dist', 'build', '.next', '.nuxt', 'coverage', '.pytest_cache',
|
|
366
|
+
'.mypy_cache', '.ruff_cache', '.DS_Store',
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
function _shouldExclude(name) {
|
|
370
|
+
return _ZIP_EXCLUDES.has(name) || name.startsWith('.') && name !== '.gitignore';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function _isLocalPath(arg) {
|
|
374
|
+
// '.' or './' or relative/absolute paths that exist on disk
|
|
375
|
+
if (arg === '.') return true;
|
|
376
|
+
if (arg.startsWith('./') || arg.startsWith('../') || arg.startsWith('/')) return true;
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function _isGitHubUrl(arg) {
|
|
381
|
+
return /^https?:\/\/(www\.)?github\.com\//.test(arg);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Zip a local directory into a temp file, returning the temp file path.
|
|
386
|
+
* Uses Node's built-in APIs — no external zip library required.
|
|
387
|
+
*/
|
|
388
|
+
async function _zipDirectory(dirPath, chalk) {
|
|
389
|
+
// Use archiver if available, otherwise fall back to a manual approach via
|
|
390
|
+
// a child process calling `zip` (available on Linux/macOS) or PowerShell on Windows.
|
|
391
|
+
const absDir = path.resolve(dirPath);
|
|
392
|
+
if (!fs.existsSync(absDir)) {
|
|
393
|
+
throw new Error(`Directory not found: ${absDir}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const tmpFile = path.join(os.tmpdir(), `badgr-upload-${Date.now()}.zip`);
|
|
397
|
+
|
|
398
|
+
// Attempt to use archiver (optional peer dep) first; fall back to system zip.
|
|
399
|
+
try {
|
|
400
|
+
const { default: archiver } = await import('archiver');
|
|
401
|
+
await new Promise((resolve, reject) => {
|
|
402
|
+
const output = createWriteStream(tmpFile);
|
|
403
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
404
|
+
output.on('close', resolve);
|
|
405
|
+
archive.on('error', reject);
|
|
406
|
+
archive.pipe(output);
|
|
407
|
+
archive.glob('**/*', {
|
|
408
|
+
cwd: absDir,
|
|
409
|
+
dot: false,
|
|
410
|
+
ignore: [..._ZIP_EXCLUDES].map(e => `**/${e}/**`).concat([..._ZIP_EXCLUDES].map(e => e)),
|
|
411
|
+
});
|
|
412
|
+
archive.finalize();
|
|
413
|
+
});
|
|
414
|
+
return tmpFile;
|
|
415
|
+
} catch {
|
|
416
|
+
// archiver not installed — fall back to system zip command
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const { spawn } = await import('child_process');
|
|
420
|
+
await new Promise((resolve, reject) => {
|
|
421
|
+
// Build exclude args — zip uses -x patterns
|
|
422
|
+
const excludeArgs = [];
|
|
423
|
+
for (const ex of _ZIP_EXCLUDES) {
|
|
424
|
+
excludeArgs.push('-x', `*/${ex}/*`, '-x', `${ex}/*`, '-x', `${ex}`);
|
|
425
|
+
}
|
|
426
|
+
const child = spawn('zip', ['-r', '-q', tmpFile, '.', ...excludeArgs], { cwd: absDir });
|
|
427
|
+
child.on('close', code => code === 0 ? resolve() : reject(new Error(`zip exited ${code}`)));
|
|
428
|
+
child.on('error', reject);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
return tmpFile;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function _uploadCodeZip(config, dirPath, chalk) {
|
|
435
|
+
process.stdout.write(chalk.dim(' Packing project...'));
|
|
436
|
+
const tmpFile = await _zipDirectory(dirPath, chalk);
|
|
437
|
+
const stat = fs.statSync(tmpFile);
|
|
438
|
+
const sizeMb = (stat.size / 1024 / 1024).toFixed(1);
|
|
439
|
+
process.stdout.write(chalk.dim(` ${sizeMb} MB\n`));
|
|
440
|
+
|
|
441
|
+
process.stdout.write(chalk.dim(' Uploading project...'));
|
|
442
|
+
// POST zip directly to the Badgr backend — no S3, no presigned URLs.
|
|
443
|
+
// Built-in FormData/Blob/fetch (Node >=18) keep the CLI dependency-free for
|
|
444
|
+
// multipart uploads; fetch sets the multipart boundary header automatically.
|
|
445
|
+
const fileData = fs.readFileSync(tmpFile);
|
|
446
|
+
const form = new FormData();
|
|
447
|
+
form.append('file', new Blob([fileData], { type: 'application/zip' }), 'project.zip');
|
|
448
|
+
|
|
449
|
+
const baseUrl = config.baseUrl.replace(/\/v1\/?$/, '');
|
|
450
|
+
let uploadResp;
|
|
451
|
+
try {
|
|
452
|
+
uploadResp = await fetch(`${baseUrl}/v1/uploads`, {
|
|
453
|
+
method: 'POST',
|
|
454
|
+
body: form,
|
|
455
|
+
headers: { 'Authorization': `Bearer ${config.apiKey}` },
|
|
456
|
+
});
|
|
457
|
+
} finally {
|
|
458
|
+
fs.unlinkSync(tmpFile);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!uploadResp.ok) {
|
|
462
|
+
const text = await uploadResp.text().catch(() => '');
|
|
463
|
+
throw new Error(`Upload failed: ${uploadResp.status} ${uploadResp.statusText}${text ? ` — ${text}` : ''}`);
|
|
464
|
+
}
|
|
465
|
+
const { code_uri: codeUri } = await uploadResp.json();
|
|
466
|
+
process.stdout.write(chalk.dim(' done\n'));
|
|
467
|
+
return codeUri;
|
|
468
|
+
}
|
|
469
|
+
|
|
348
470
|
export async function runCommand(config, args, chalk) {
|
|
349
471
|
// `badgr run template <name> [flags]` — expand template defaults then re-dispatch
|
|
350
472
|
if (args[0] === 'template') {
|
|
@@ -387,12 +509,37 @@ export async function runCommand(config, args, chalk) {
|
|
|
387
509
|
return;
|
|
388
510
|
}
|
|
389
511
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
512
|
+
// ── Detect source type ─────────────────────────────────────────────────────
|
|
513
|
+
// positional[0] may be: '.' | './dir' | 'https://github.com/...' | 'python' | nothing
|
|
514
|
+
const firstArg = positional[0] ?? null;
|
|
515
|
+
const isLocalPath = firstArg && _isLocalPath(firstArg);
|
|
516
|
+
const isGitHubUrl = firstArg && _isGitHubUrl(firstArg);
|
|
517
|
+
const isCodeSource = isLocalPath || isGitHubUrl;
|
|
518
|
+
|
|
519
|
+
if (isCodeSource && !flags.cmd) {
|
|
520
|
+
console.error(chalk.red(`\n ✗ --cmd is required when running from a ${isLocalPath ? 'local path' : 'GitHub URL'}.\n`));
|
|
521
|
+
if (isLocalPath) {
|
|
522
|
+
console.error(chalk.dim(' Example: badgr run . --cmd "python train.py" --max-cost 5\n'));
|
|
523
|
+
} else {
|
|
524
|
+
console.error(chalk.dim(' Example: badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5\n'));
|
|
525
|
+
}
|
|
526
|
+
process.exitCode = 1;
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Legacy direct-command mode: positional args form the command
|
|
531
|
+
const isDirectCommand = !isCodeSource && (positional.length > 0 || commandArgv !== null || flags.image);
|
|
532
|
+
|
|
533
|
+
if (!isCodeSource && positional.length === 0 && commandArgv === null && !flags.image) {
|
|
534
|
+
console.error(chalk.red('\nUsage:'));
|
|
535
|
+
console.error(chalk.dim(' badgr run . --cmd "python train.py" --max-cost 5'));
|
|
536
|
+
console.error(chalk.dim(' badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5'));
|
|
537
|
+
console.error(chalk.dim(' badgr run python train.py --max-cost 5'));
|
|
538
|
+
console.error(chalk.dim(' badgr run --image my/image:latest --max-cost 5'));
|
|
539
|
+
console.error('');
|
|
393
540
|
return;
|
|
394
541
|
}
|
|
395
|
-
if (commandArgv !== null && commandArgv.length === 0 && !flags.image) {
|
|
542
|
+
if (commandArgv !== null && commandArgv.length === 0 && !flags.image && !isCodeSource) {
|
|
396
543
|
console.error(chalk.red(' ✗ No command after --. Provide a command or --image.'));
|
|
397
544
|
console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --max-cost 1 -- node script.js'));
|
|
398
545
|
process.exitCode = 1;
|
|
@@ -430,13 +577,17 @@ export async function runCommand(config, args, chalk) {
|
|
|
430
577
|
}
|
|
431
578
|
|
|
432
579
|
// commandArgv is set when -- separator was used; fall back to positional for legacy syntax.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
580
|
+
// For code-source flows (local path / GitHub), the "command" field is empty — the runner
|
|
581
|
+
// receives it via BADGR_CMD env var instead.
|
|
582
|
+
const command = isCodeSource
|
|
583
|
+
? undefined
|
|
584
|
+
: (commandArgv !== null
|
|
585
|
+
? (commandArgv.length > 0 ? commandArgv : undefined)
|
|
586
|
+
: (positional.length > 0 ? positional : undefined));
|
|
587
|
+
const cmdStr = isCodeSource ? (flags.cmd || '') : (command ? command.join(' ') : '');
|
|
588
|
+
const isSmoke = !isCodeSource && cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
|
|
589
|
+
const inferredImage = isSmoke ? 'python:3.11-alpine' : undefined; // code-source flows use runner image
|
|
590
|
+
const image = flags.image || (command && !isCodeSource ? (isSmoke ? 'python:3.11-alpine' : undefined) : undefined);
|
|
440
591
|
const detach = flags.detach || false;
|
|
441
592
|
|
|
442
593
|
// Default max-runtime of 60 minutes — always applied unless overridden.
|
|
@@ -453,6 +604,9 @@ export async function runCommand(config, args, chalk) {
|
|
|
453
604
|
|
|
454
605
|
if (flags.dryRun) {
|
|
455
606
|
console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
|
|
607
|
+
if (isLocalPath) console.log(` ${chalk.bold('Source:')} ${path.resolve(firstArg)} (local project)`);
|
|
608
|
+
if (isGitHubUrl) console.log(` ${chalk.bold('Source:')} ${firstArg} (GitHub)`);
|
|
609
|
+
if (flags.cmd) console.log(` ${chalk.bold('Command:')} ${flags.cmd}`);
|
|
456
610
|
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
457
611
|
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
458
612
|
console.log(` ${chalk.bold('GPU:')} ${gpu || chalk.dim('auto')}`);
|
|
@@ -465,10 +619,13 @@ export async function runCommand(config, args, chalk) {
|
|
|
465
619
|
}
|
|
466
620
|
|
|
467
621
|
console.log(chalk.bold('\n⚡ Running GPU job\n'));
|
|
468
|
-
if (
|
|
469
|
-
if (
|
|
470
|
-
if (
|
|
471
|
-
|
|
622
|
+
if (isLocalPath) console.log(` ${chalk.bold('Source:')} ${path.resolve(firstArg)}`);
|
|
623
|
+
if (isGitHubUrl) console.log(` ${chalk.bold('Source:')} ${firstArg}`);
|
|
624
|
+
if (flags.cmd) console.log(` ${chalk.bold('Command:')} ${flags.cmd}`);
|
|
625
|
+
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
626
|
+
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
627
|
+
if (gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
628
|
+
else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
|
|
472
629
|
if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
|
|
473
630
|
const runtimeLabel = isDefaultRuntime
|
|
474
631
|
? `${effectiveMaxRuntime}min ${chalk.dim('(default — use --max-runtime N to override)')}`
|
|
@@ -498,6 +655,18 @@ export async function runCommand(config, args, chalk) {
|
|
|
498
655
|
}
|
|
499
656
|
}
|
|
500
657
|
|
|
658
|
+
// ── Upload local project zip (Flow 1) ─────────────────────────────────────
|
|
659
|
+
let codeUri = null;
|
|
660
|
+
if (isLocalPath) {
|
|
661
|
+
try {
|
|
662
|
+
codeUri = await _uploadCodeZip(config, firstArg, chalk);
|
|
663
|
+
} catch (err) {
|
|
664
|
+
console.error(chalk.red(`\n ✗ Failed to upload project: ${err.message}\n`));
|
|
665
|
+
process.exitCode = 1;
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
501
670
|
console.log(chalk.dim(' Finding suitable capacity...'));
|
|
502
671
|
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
503
672
|
console.log(chalk.dim(` API: ${config.baseUrl}`));
|
|
@@ -506,8 +675,8 @@ export async function runCommand(config, args, chalk) {
|
|
|
506
675
|
function buildBody(gpuOverride, tierOverride) {
|
|
507
676
|
const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
|
|
508
677
|
return {
|
|
509
|
-
command,
|
|
510
|
-
image,
|
|
678
|
+
...(command ? { command } : {}),
|
|
679
|
+
...(image ? { image } : {}),
|
|
511
680
|
gpu: gpuOverride || gpu || 'auto',
|
|
512
681
|
...(flags.minVram ? { min_vram: flags.minVram } : {}),
|
|
513
682
|
gpu_count: flags.count || 1,
|
|
@@ -519,6 +688,10 @@ export async function runCommand(config, args, chalk) {
|
|
|
519
688
|
max_runtime_seconds: effectiveMaxRuntime * 60,
|
|
520
689
|
...(maxCost ? { max_cost_usd: maxCost } : {}),
|
|
521
690
|
...(resolvedWorkspaceId ? { workspace_id: resolvedWorkspaceId } : {}),
|
|
691
|
+
// Code-source fields
|
|
692
|
+
...(codeUri ? { code_uri: codeUri } : {}),
|
|
693
|
+
...(isGitHubUrl ? { github_url: firstArg } : {}),
|
|
694
|
+
...(flags.cmd ? { cmd: flags.cmd } : {}),
|
|
522
695
|
};
|
|
523
696
|
}
|
|
524
697
|
|
|
@@ -720,8 +893,12 @@ export async function runCommand(config, args, chalk) {
|
|
|
720
893
|
name: flags.save,
|
|
721
894
|
job_type: 'custom.run',
|
|
722
895
|
config: {
|
|
723
|
-
command
|
|
724
|
-
image
|
|
896
|
+
...(command ? { command } : {}),
|
|
897
|
+
...(image ? { image } : {}),
|
|
898
|
+
...(flags.cmd ? { cmd: flags.cmd } : {}),
|
|
899
|
+
// For local-path workloads, code_uri is a snapshot — user can re-upload on next run.
|
|
900
|
+
// GitHub URL is stable and stored directly.
|
|
901
|
+
...(isGitHubUrl ? { github_url: firstArg } : {}),
|
|
725
902
|
gpu: gpu || 'auto',
|
|
726
903
|
...(flags.minVram ? { min_vram: flags.minVram } : {}),
|
|
727
904
|
gpu_count: flags.count || 1,
|