badgr-cli 1.0.40 → 1.0.41
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 +24 -0
- package/src/commands/run.js +201 -24
- package/src/commands/serve.js +20 -5
- package/src/fallback.js +6 -4
- package/tests/launch-readiness.test.js +25 -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.41",
|
|
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',
|
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,
|
package/src/commands/serve.js
CHANGED
|
@@ -104,8 +104,8 @@ function _serveStageLabel(elapsedSec, healthPath = '/models') {
|
|
|
104
104
|
return 'Waiting for /v1/models…';
|
|
105
105
|
}
|
|
106
106
|
if (healthPath === '/health') {
|
|
107
|
-
if (elapsedSec < 60) return 'Starting
|
|
108
|
-
if (elapsedSec < 180) return 'Downloading
|
|
107
|
+
if (elapsedSec < 60) return 'Starting server…';
|
|
108
|
+
if (elapsedSec < 180) return 'Downloading model…';
|
|
109
109
|
return 'Waiting for /health…';
|
|
110
110
|
}
|
|
111
111
|
if (elapsedSec < 30) return 'Starting container…';
|
|
@@ -461,14 +461,19 @@ export async function serveCommand(config, args, chalk) {
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
// ── Determine health check path ───────────────────────────────────────────
|
|
464
|
-
// Priority: explicit --health-path > llama.cpp → /health > vLLM → /models > auto-detect custom image > null
|
|
464
|
+
// Priority: explicit --health-path > llama.cpp → /health > task-specific > vLLM → /models > auto-detect custom image > null
|
|
465
465
|
let resolvedHealthPath;
|
|
466
466
|
if (flags.healthPath) {
|
|
467
467
|
resolvedHealthPath = flags.healthPath;
|
|
468
468
|
} else if (isLlamaCpp) {
|
|
469
469
|
resolvedHealthPath = '/health';
|
|
470
470
|
} else if (!customImage) {
|
|
471
|
-
|
|
471
|
+
// Managed runtimes for transcribe/image expose /health; vLLM (chat, embed) uses /models
|
|
472
|
+
if (flags.task === 'transcribe' || flags.task === 'image') {
|
|
473
|
+
resolvedHealthPath = '/health';
|
|
474
|
+
} else {
|
|
475
|
+
resolvedHealthPath = '/models';
|
|
476
|
+
}
|
|
472
477
|
} else {
|
|
473
478
|
resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
|
|
474
479
|
}
|
|
@@ -573,7 +578,17 @@ export async function serveCommand(config, args, chalk) {
|
|
|
573
578
|
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
574
579
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
575
580
|
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
|
|
576
|
-
|
|
581
|
+
if (flags.task === 'transcribe') {
|
|
582
|
+
console.log(chalk.dim(` with open("audio.mp3", "rb") as f:`));
|
|
583
|
+
console.log(chalk.dim(` t = client.audio.transcriptions.create(model="${sdkModel}", file=f, response_format="text")`));
|
|
584
|
+
} else if (flags.task === 'image') {
|
|
585
|
+
console.log(chalk.dim(` resp = client.images.generate(model="${sdkModel}", prompt="...", n=1, size="1024x1024")`));
|
|
586
|
+
console.log(chalk.dim(` # resp.data[0].b64_json contains the base64-encoded PNG`));
|
|
587
|
+
} else if (flags.task === 'embed') {
|
|
588
|
+
console.log(chalk.dim(` resp = client.embeddings.create(model="${sdkModel}", input=["hello world"])`));
|
|
589
|
+
} else {
|
|
590
|
+
console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[{"role": "user", "content": "Hello"}])`));
|
|
591
|
+
}
|
|
577
592
|
console.log();
|
|
578
593
|
}
|
|
579
594
|
}
|
package/src/fallback.js
CHANGED
|
@@ -105,12 +105,14 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
|
|
|
105
105
|
|
|
106
106
|
const d = firstErr.errorData;
|
|
107
107
|
|
|
108
|
-
//
|
|
109
|
-
//
|
|
108
|
+
// Provider retry: PROVISIONING_FAILED means the selected provider couldn't launch the slot.
|
|
109
|
+
// Retry once with prefer_different_provider so the backend routes to a different provider
|
|
110
|
+
// (e.g. RunPod failed → try Vast.ai or Hyperstack) within the same max_cost budget.
|
|
110
111
|
if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
111
|
-
console.log(chalk.dim('\n
|
|
112
|
+
console.log(chalk.dim('\n Provider unavailable — trying alternative provider...\n'));
|
|
112
113
|
try {
|
|
113
|
-
|
|
114
|
+
const retryBody = { ...buildBody(), prefer_different_provider: true };
|
|
115
|
+
return await attempt(retryBody);
|
|
114
116
|
} catch (retryErr) {
|
|
115
117
|
if (retryErr.isPaymentRequired) throw retryErr;
|
|
116
118
|
firstErr = retryErr;
|
|
@@ -355,6 +355,31 @@ describe('stale-capacity retry on PROVISIONING_FAILED', () => {
|
|
|
355
355
|
expect(api.callApi.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
356
356
|
});
|
|
357
357
|
|
|
358
|
+
it('sends prefer_different_provider=true on the PROVISIONING_FAILED retry', async () => {
|
|
359
|
+
api.callApi.mockRejectedValue(makeProvisioningFailedErr());
|
|
360
|
+
|
|
361
|
+
const bodies = [];
|
|
362
|
+
api.callApi.mockImplementation((_ep, opts) => {
|
|
363
|
+
bodies.push(opts.body);
|
|
364
|
+
return Promise.reject(makeProvisioningFailedErr());
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
await callWithFallback(
|
|
369
|
+
'/run',
|
|
370
|
+
{ apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
|
|
371
|
+
() => ({ gpu: 'RTX_4090' }),
|
|
372
|
+
'2',
|
|
373
|
+
chalk,
|
|
374
|
+
{ thing: 'job', cmd: 'badgr run' },
|
|
375
|
+
);
|
|
376
|
+
} catch (_) {}
|
|
377
|
+
|
|
378
|
+
// First call: no prefer_different_provider; retry call: must include it
|
|
379
|
+
expect(bodies[0]).not.toHaveProperty('prefer_different_provider');
|
|
380
|
+
expect(bodies[1]).toMatchObject({ prefer_different_provider: true });
|
|
381
|
+
});
|
|
382
|
+
|
|
358
383
|
it('does NOT double-retry on non-PROVISIONING_FAILED errors', async () => {
|
|
359
384
|
const err = new Error('NO_CAPACITY_MATCH');
|
|
360
385
|
err.errorData = { code: 'NO_CAPACITY_MATCH' };
|
|
@@ -663,3 +663,117 @@ describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
|
|
|
663
663
|
expect(process.exitCode).toBeFalsy();
|
|
664
664
|
});
|
|
665
665
|
});
|
|
666
|
+
|
|
667
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
668
|
+
// 13. --task routing: transcribe and image use /health; embed uses /models
|
|
669
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
describe('--task routing for managed runtimes', () => {
|
|
672
|
+
it('--task transcribe polls /health instead of /models', async () => {
|
|
673
|
+
api.callApi
|
|
674
|
+
.mockResolvedValueOnce(makeServeDep({ model: 'large-v3' })) // POST /serve
|
|
675
|
+
.mockResolvedValueOnce({ status: 'running' }); // pre-health dep check
|
|
676
|
+
|
|
677
|
+
const fetchedUrls = [];
|
|
678
|
+
global.fetch = vi.fn().mockImplementation((url) => {
|
|
679
|
+
fetchedUrls.push(url);
|
|
680
|
+
return Promise.resolve({ ok: true, status: 200 });
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const p = serveCommand(
|
|
684
|
+
config,
|
|
685
|
+
['large-v3', '--task', 'transcribe', '--max-cost', '5'],
|
|
686
|
+
chalk,
|
|
687
|
+
);
|
|
688
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
689
|
+
await p;
|
|
690
|
+
|
|
691
|
+
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
|
|
692
|
+
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
693
|
+
expect(process.exitCode).toBeFalsy();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('--task transcribe sends task field to backend', async () => {
|
|
697
|
+
api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'large-v3' }));
|
|
698
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
699
|
+
|
|
700
|
+
const p = serveCommand(
|
|
701
|
+
config,
|
|
702
|
+
['large-v3', '--task', 'transcribe', '--max-cost', '5', '--no-wait'],
|
|
703
|
+
chalk,
|
|
704
|
+
);
|
|
705
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
706
|
+
await p;
|
|
707
|
+
|
|
708
|
+
const body = api.callApi.mock.calls[0][1].body;
|
|
709
|
+
expect(body.task).toBe('transcribe');
|
|
710
|
+
expect(body.model).toBe('large-v3');
|
|
711
|
+
expect(process.exitCode).toBeFalsy();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('--task image polls /health instead of /models', async () => {
|
|
715
|
+
api.callApi
|
|
716
|
+
.mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }))
|
|
717
|
+
.mockResolvedValueOnce({ status: 'running' });
|
|
718
|
+
|
|
719
|
+
const fetchedUrls = [];
|
|
720
|
+
global.fetch = vi.fn().mockImplementation((url) => {
|
|
721
|
+
fetchedUrls.push(url);
|
|
722
|
+
return Promise.resolve({ ok: true, status: 200 });
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const p = serveCommand(
|
|
726
|
+
config,
|
|
727
|
+
['black-forest-labs/FLUX.1-schnell', '--task', 'image', '--max-cost', '10'],
|
|
728
|
+
chalk,
|
|
729
|
+
);
|
|
730
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
731
|
+
await p;
|
|
732
|
+
|
|
733
|
+
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
|
|
734
|
+
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
735
|
+
expect(process.exitCode).toBeFalsy();
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('--task image sends task field to backend', async () => {
|
|
739
|
+
api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }));
|
|
740
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
741
|
+
|
|
742
|
+
const p = serveCommand(
|
|
743
|
+
config,
|
|
744
|
+
['black-forest-labs/FLUX.1-schnell', '--task', 'image', '--max-cost', '10', '--no-wait'],
|
|
745
|
+
chalk,
|
|
746
|
+
);
|
|
747
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
748
|
+
await p;
|
|
749
|
+
|
|
750
|
+
const body = api.callApi.mock.calls[0][1].body;
|
|
751
|
+
expect(body.task).toBe('image');
|
|
752
|
+
expect(body.model).toBe('black-forest-labs/FLUX.1-schnell');
|
|
753
|
+
expect(process.exitCode).toBeFalsy();
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('--task embed still polls /models (vLLM path)', async () => {
|
|
757
|
+
api.callApi
|
|
758
|
+
.mockResolvedValueOnce(makeServeDep({ model: 'BAAI/bge-large-en-v1.5' }))
|
|
759
|
+
.mockResolvedValueOnce({ status: 'running' });
|
|
760
|
+
|
|
761
|
+
const fetchedUrls = [];
|
|
762
|
+
global.fetch = vi.fn().mockImplementation((url) => {
|
|
763
|
+
fetchedUrls.push(url);
|
|
764
|
+
return Promise.resolve({ ok: true, status: 200 });
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const p = serveCommand(
|
|
768
|
+
config,
|
|
769
|
+
['BAAI/bge-large-en-v1.5', '--task', 'embed', '--max-cost', '5'],
|
|
770
|
+
chalk,
|
|
771
|
+
);
|
|
772
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
773
|
+
await p;
|
|
774
|
+
|
|
775
|
+
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(true);
|
|
776
|
+
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(false);
|
|
777
|
+
expect(process.exitCode).toBeFalsy();
|
|
778
|
+
});
|
|
779
|
+
});
|
package/tests/template.test.js
CHANGED
|
@@ -105,14 +105,14 @@ afterEach(() => {
|
|
|
105
105
|
|
|
106
106
|
describe('TEMPLATES catalog', () => {
|
|
107
107
|
const EXPECTED_NAMES = [
|
|
108
|
-
'comfyui', 'axolotl', 'unsloth', 'vllm', 'llama-cpp',
|
|
108
|
+
'comfyui', 'axolotl', 'batch-inference', 'unsloth', 'vllm', 'llama-cpp',
|
|
109
109
|
'invokeai', 'kohya-ss', 'text-gen-webui', 'sglang', 'tgi',
|
|
110
110
|
'auto1111', 'forge', 'nerfstudio', 'openfold', 'blender-render',
|
|
111
111
|
'openmm', 'gromacs', 'lammps', 'diffusers', 'torchtune',
|
|
112
112
|
];
|
|
113
113
|
|
|
114
|
-
it('contains exactly
|
|
115
|
-
expect(TEMPLATES).toHaveLength(
|
|
114
|
+
it('contains exactly 21 templates', () => {
|
|
115
|
+
expect(TEMPLATES).toHaveLength(21);
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
it('contains all expected template names', () => {
|
|
@@ -180,7 +180,7 @@ describe('TEMPLATES catalog', () => {
|
|
|
180
180
|
for (const [k, t] of Object.entries(TEMPLATE_MAP)) {
|
|
181
181
|
expect(k).toBe(t.name);
|
|
182
182
|
}
|
|
183
|
-
expect(Object.keys(TEMPLATE_MAP)).toHaveLength(
|
|
183
|
+
expect(Object.keys(TEMPLATE_MAP)).toHaveLength(21);
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
186
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr run — local-project upload flow (regression).
|
|
3
|
+
*
|
|
4
|
+
* Guards the bug where `_uploadCodeZip` imported `node-fetch` (an undeclared
|
|
5
|
+
* dependency) and the `form-data` package: both broke `badgr run . --cmd …`
|
|
6
|
+
* with "Cannot find package 'form-data'". The upload now uses Node's built-in
|
|
7
|
+
* FormData / Blob / fetch (Node >=18), so the CLI needs no extra HTTP deps.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { _uploadCodeZip } from '../src/commands/run.js';
|
|
14
|
+
|
|
15
|
+
const chalk = { dim: (s) => s, bold: (s) => s, red: (s) => s };
|
|
16
|
+
|
|
17
|
+
let tmpDir;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-upload-test-'));
|
|
21
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.py'), "print('hi')\n");
|
|
22
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('_uploadCodeZip (local-project flow)', () => {
|
|
31
|
+
it('uses built-in fetch + FormData and returns the backend code_uri', async () => {
|
|
32
|
+
// Built-ins must exist in the supported Node runtime — if these are
|
|
33
|
+
// undefined the upload would fall back to the missing node-fetch/form-data.
|
|
34
|
+
expect(typeof fetch).toBe('function');
|
|
35
|
+
expect(typeof FormData).toBe('function');
|
|
36
|
+
expect(typeof Blob).toBe('function');
|
|
37
|
+
|
|
38
|
+
let captured = null;
|
|
39
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, opts) => {
|
|
40
|
+
captured = { url, opts };
|
|
41
|
+
return { ok: true, json: async () => ({ code_uri: 'https://aibadgr.com/v1/uploads/up_x/download?token=t' }) };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://aibadgr.com/v1' };
|
|
45
|
+
const codeUri = await _uploadCodeZip(config, tmpDir, chalk);
|
|
46
|
+
|
|
47
|
+
expect(codeUri).toBe('https://aibadgr.com/v1/uploads/up_x/download?token=t');
|
|
48
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(captured.url).toBe('https://aibadgr.com/v1/uploads');
|
|
50
|
+
expect(captured.opts.method).toBe('POST');
|
|
51
|
+
expect(captured.opts.body).toBeInstanceOf(FormData);
|
|
52
|
+
expect(captured.opts.body.get('file')).toBeInstanceOf(Blob);
|
|
53
|
+
expect(captured.opts.headers.Authorization).toBe('Bearer sk-test');
|
|
54
|
+
// fetch derives the multipart Content-Type/boundary from the FormData body —
|
|
55
|
+
// the CLI must NOT set it manually (that was the form-data getHeaders() path).
|
|
56
|
+
expect(captured.opts.headers['Content-Type']).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws a clear error and still deletes the temp zip when upload fails', async () => {
|
|
60
|
+
const listZips = () =>
|
|
61
|
+
new Set(fs.readdirSync(os.tmpdir()).filter(f => f.startsWith('badgr-upload-') && f.endsWith('.zip')));
|
|
62
|
+
const before = listZips();
|
|
63
|
+
|
|
64
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
65
|
+
ok: false,
|
|
66
|
+
status: 500,
|
|
67
|
+
statusText: 'Internal Server Error',
|
|
68
|
+
text: async () => 'boom',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://aibadgr.com/v1' };
|
|
72
|
+
await expect(_uploadCodeZip(config, tmpDir, chalk)).rejects.toThrow(/Upload failed: 500/);
|
|
73
|
+
|
|
74
|
+
// The temp zip created by THIS call must be cleaned up even on failure.
|
|
75
|
+
const after = listZips();
|
|
76
|
+
const newLeftovers = [...after].filter(f => !before.has(f));
|
|
77
|
+
expect(newLeftovers).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|