badgr-cli 1.0.47 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +38 -0
  2. package/package.json +1 -1
  3. package/src/api.js +16 -2
  4. package/src/artifactDownload.js +55 -0
  5. package/src/badgr.js +104 -0
  6. package/src/batch.js +22 -4
  7. package/src/browser.js +23 -0
  8. package/src/commands/artifacts.js +75 -0
  9. package/src/commands/batch.js +221 -20
  10. package/src/commands/billing.js +1 -12
  11. package/src/commands/capacity.js +9 -4
  12. package/src/commands/comfyui.js +3 -3
  13. package/src/commands/connect.js +83 -0
  14. package/src/commands/doctor.js +127 -0
  15. package/src/commands/down.js +29 -6
  16. package/src/commands/launch.js +431 -0
  17. package/src/commands/pull.js +137 -0
  18. package/src/commands/run.js +253 -37
  19. package/src/commands/sbatch.js +232 -0
  20. package/src/commands/serve.js +3 -3
  21. package/src/commands/status.js +12 -4
  22. package/src/commands/task.js +25 -0
  23. package/src/commands/test-run.js +4 -2
  24. package/src/credentials.js +65 -0
  25. package/src/fallback.js +7 -2
  26. package/src/fanout.js +70 -0
  27. package/src/gpuDoctor/diskInfo.js +42 -0
  28. package/src/gpuDoctor/doctor.js +451 -0
  29. package/src/gpuDoctor/gpuInfo.js +70 -0
  30. package/src/gpuDoctor/healthCheck.js +63 -0
  31. package/src/gpuDoctor/logClassifier.js +138 -0
  32. package/src/gpuDoctor/modelFit.js +107 -0
  33. package/src/gpuDoctor/probeCache.js +38 -0
  34. package/src/gpuDoctor/redact.js +29 -0
  35. package/src/gpuDoctor/torchInfo.js +61 -0
  36. package/src/gpuDoctor/workflowDoctor.js +96 -0
  37. package/src/onboarding.js +124 -0
  38. package/src/slurm.js +193 -0
  39. package/src/spec.js +59 -2
  40. package/src/store.js +16 -0
  41. package/tests/agent-images.test.js +17 -0
  42. package/tests/artifactDownload.test.js +113 -0
  43. package/tests/artifacts.test.js +168 -0
  44. package/tests/batch.test.js +312 -0
  45. package/tests/browser.test.js +51 -0
  46. package/tests/capacity.test.js +68 -0
  47. package/tests/commands.test.js +44 -0
  48. package/tests/connect.test.js +83 -0
  49. package/tests/down.test.js +23 -1
  50. package/tests/fallback-timeout.test.js +41 -0
  51. package/tests/fanout.test.js +124 -0
  52. package/tests/gpu-doctor-classifiers.test.js +402 -0
  53. package/tests/gpu-doctor-doctor.test.js +304 -0
  54. package/tests/gpu-doctor-probe-cache.test.js +110 -0
  55. package/tests/gpu-doctor-probes.test.js +257 -0
  56. package/tests/launch-command-argv.test.js +93 -0
  57. package/tests/launch-readiness.test.js +1 -0
  58. package/tests/launch.test.js +440 -0
  59. package/tests/onboarding.test.js +134 -0
  60. package/tests/pull.test.js +266 -0
  61. package/tests/run-lifecycle.test.js +405 -6
  62. package/tests/sbatch.test.js +190 -0
  63. package/tests/secrets.test.js +16 -0
  64. package/tests/slurm.test.js +77 -0
  65. package/tests/spec.test.js +59 -1
  66. package/tests/status.test.js +73 -0
  67. package/tests/task.test.js +109 -0
  68. package/tests/template.test.js +7 -0
package/README.md CHANGED
@@ -46,10 +46,47 @@ Runs a blessed ComfyUI workflow, no setup, and prints image URLs when done. No m
46
46
 
47
47
  ---
48
48
 
49
+ ## Something not working? `badgr doctor`
50
+
51
+ Read-only diagnosis for GPU workload failures — checks your GPU, drivers,
52
+ CUDA, PyTorch, and disk, then tells you what's likely wrong and what to try
53
+ next. No login, no setup, never mutates your machine.
54
+
55
+ ```bash
56
+ badgr doctor
57
+ ```
58
+
59
+ ```bash
60
+ # A model won't fit / you're not sure it'll fit before you try
61
+ badgr doctor --model meta-llama/Llama-3.1-8B-Instruct
62
+
63
+ # You have an error from a crashed job — save it to a file first
64
+ badgr doctor --logs error.log
65
+
66
+ # A ComfyUI workflow is failing or referencing something missing
67
+ badgr doctor --workflow my-workflow.json
68
+
69
+ # You started a server and it's not responding (ComfyUI, llama.cpp, and
70
+ # generic health formats are recognized too, not just OpenAI-style)
71
+ badgr doctor --url http://localhost:8000/v1/models
72
+
73
+ # Splitting a big model across multiple GPUs (tensor parallel)
74
+ badgr doctor --model meta-llama/Llama-3.1-70B-Instruct-AWQ --serve --gpu-count 2
75
+
76
+ # Machine-readable output for scripts/CI
77
+ badgr doctor --json
78
+ ```
79
+
80
+ Run `badgr doctor --help` for the full flag list. Details in
81
+ [`docs/gpu-doctor.md`](../../docs/gpu-doctor.md).
82
+
83
+ ---
84
+
49
85
  ## Commands
50
86
 
51
87
  ```text
52
88
  login
89
+ doctor
53
90
  run
54
91
  serve
55
92
  status
@@ -62,6 +99,7 @@ test
62
99
  | Command | What it does |
63
100
  |---------|-------------|
64
101
  | `badgr login` | Save API key to `~/.badgr/config.json` |
102
+ | `badgr doctor` | Diagnose a GPU workload failure — read-only, no login needed |
65
103
  | `badgr run <command>` | Run a one-off GPU job (any container command) |
66
104
  | `badgr serve <model>` | Start a persistent OpenAI-compatible endpoint |
67
105
  | `badgr status` | Show what's running and what's billing |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.47",
3
+ "version": "1.1.0",
4
4
  "description": "Badgr — run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -135,7 +135,21 @@ export function runJob(config, body) {
135
135
  apiKey: config.apiKey,
136
136
  baseUrl: config.baseUrl,
137
137
  body,
138
- timeoutMs: 130_000,
138
+ timeoutMs: 220_000, // must stay above backend's ~200s provision deadline — see fallback.js's callWithFallback
139
+ });
140
+ }
141
+
142
+ // Preview the VM class + Badgr rate for a CPU launch before provisioning
143
+ // anything (see backend run_serve_routes.py's POST /v1/run/quote). Callers
144
+ // should treat a failure here as non-fatal — this is a best-effort preview,
145
+ // never a precondition for launching.
146
+ export function quoteRun(config, body) {
147
+ return callApi('/run/quote', {
148
+ method: 'POST',
149
+ apiKey: config.apiKey,
150
+ baseUrl: config.baseUrl,
151
+ body,
152
+ timeoutMs: 5_000,
139
153
  });
140
154
  }
141
155
 
@@ -145,7 +159,7 @@ export function serveModel(config, body) {
145
159
  apiKey: config.apiKey,
146
160
  baseUrl: config.baseUrl,
147
161
  body,
148
- timeoutMs: 130_000,
162
+ timeoutMs: 220_000, // must stay above backend's ~200s provision deadline — see fallback.js's callWithFallback
149
163
  });
150
164
  }
151
165
 
@@ -0,0 +1,55 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ /**
6
+ * Resolves the /v1/deployments/{id}/artifacts/download URL from a
7
+ * configured baseUrl, whether or not it already ends in /v1.
8
+ */
9
+ export function artifactDownloadUrl(baseUrl, deploymentId) {
10
+ const base = String(baseUrl || '').replace(/\/v1\/?$/, '').replace(/\/+$/, '');
11
+ return `${base}/v1/deployments/${deploymentId}/artifacts/download`;
12
+ }
13
+
14
+ /**
15
+ * Downloads a deployment's artifact tar.gz and extracts it into destDir
16
+ * (created if needed). Shared by `badgr pull`, `badgr artifacts`, and
17
+ * `badgr batch artifacts` — the only thing that differs between them is
18
+ * how each wraps a non-OK response into a user-facing message.
19
+ *
20
+ * On a non-OK response, throws an Error with `.httpStatus`/`.statusText`/
21
+ * `.bodyText` set so callers can build their own message; the thrown
22
+ * Error's own `.message` is a reasonable generic fallback. A network-level
23
+ * failure (DNS, connection refused, etc.) propagates as whatever `fetch`
24
+ * itself throws, with no `.httpStatus` — callers can use that distinction
25
+ * to tell "couldn't reach Badgr" apart from "Badgr said no".
26
+ */
27
+ export async function downloadAndExtractArtifact(config, deploymentId, destDir) {
28
+ fs.mkdirSync(destDir, { recursive: true });
29
+
30
+ const res = await fetch(artifactDownloadUrl(config.baseUrl, deploymentId), {
31
+ headers: { Authorization: `Bearer ${config.apiKey}` },
32
+ });
33
+ if (!res.ok) {
34
+ const bodyText = await res.text().catch(() => '');
35
+ const err = new Error(`artifact download failed: ${res.status} ${res.statusText}${bodyText ? ` — ${bodyText}` : ''}`);
36
+ err.httpStatus = res.status;
37
+ err.statusText = res.statusText;
38
+ err.bodyText = bodyText;
39
+ throw err;
40
+ }
41
+
42
+ const bytes = Buffer.from(await res.arrayBuffer());
43
+ const tmpFile = path.join(os.tmpdir(), `badgr-artifact-${deploymentId}-${Date.now()}.tar.gz`);
44
+ fs.writeFileSync(tmpFile, bytes);
45
+ try {
46
+ // node-tar (v7) has no default export — only named exports (x, c, ...).
47
+ // Destructuring `default` here always resolved to undefined, crashing
48
+ // every real extraction with "Cannot read properties of undefined
49
+ // (reading 'x')" — found via a live badgr artifacts/pull download.
50
+ const { x } = await import('tar');
51
+ await x({ file: tmpFile, cwd: destDir });
52
+ } finally {
53
+ fs.rmSync(tmpFile, { force: true });
54
+ }
55
+ }
package/src/badgr.js CHANGED
@@ -8,6 +8,9 @@ import { statusCommand } from './commands/status.js';
8
8
  import { logsCommand } from './commands/logs.js';
9
9
  import { receiptsCommand } from './commands/receipts.js';
10
10
  import { runCommand } from './commands/run.js';
11
+ import { launchCommand } from './commands/launch.js';
12
+ import { taskCommand } from './commands/task.js';
13
+ import { artifactsCommand } from './commands/artifacts.js';
11
14
  import { serveCommand } from './commands/serve.js';
12
15
  import { modelsCommand } from './commands/models.js';
13
16
  import { capacityCommand } from './commands/capacity.js';
@@ -20,23 +23,34 @@ import { embedCommand } from './commands/embed.js';
20
23
  import { templateCommand } from './commands/template.js';
21
24
  import { workloadCommand } from './commands/workload.js';
22
25
  import { batchCommand } from './commands/batch.js';
26
+ import { sbatchCommand } from './commands/sbatch.js';
23
27
  import { workspaceCommand } from './commands/workspace.js';
24
28
  import { detectCommand } from './commands/detect.js';
25
29
  import { restartCommand } from './commands/restart.js';
26
30
  import { rerunCommand } from './commands/rerun.js';
27
31
  import { heartbeatCommand } from './commands/heartbeat.js';
32
+ import { pullCommand } from './commands/pull.js';
33
+ import { doctorCommand } from './commands/doctor.js';
34
+ import { connectCommand } from './commands/connect.js';
28
35
 
29
36
  const HELP = `
30
37
  ${chalk.bold('badgr')} — run or serve GPU workloads from one command
31
38
 
32
39
  ${chalk.bold('COMMANDS')}
33
40
  ${chalk.cyan('badgr login')} Authenticate with your API key
41
+ ${chalk.cyan('badgr connect <provider>')} Credential setup (badgr connect anthropic/openai) — also prompted inline by badgr launch if missing
34
42
  ${chalk.cyan('badgr detect <path>')} Inspect a project and report the GPU job Badgr would run
35
43
  ${chalk.cyan('badgr run <command>')} Run a one-off GPU job
44
+ ${chalk.cyan('badgr launch cline|claude|codex|playwright "<task>"')} Run a coding/testing workload on a CPU VM — image + command auto-selected
45
+ ${chalk.cyan('badgr launch <source> -- <command>')} Advanced escape hatch: run any other command on a CPU VM
46
+ ${chalk.cyan('badgr task "<desc>" -- <command>')} Label + launch — thin wrapper over badgr launch . -- <command>
36
47
  ${chalk.cyan('badgr serve <model>')} Serve a model with an OpenAI-compatible endpoint
37
48
  ${chalk.cyan('badgr serve openwebui')} Serve Open WebUI — chat UI, connects to a model endpoint
49
+ ${chalk.cyan('badgr doctor')} Diagnose why a GPU workload is likely failing (read-only, no login needed)
38
50
  ${chalk.cyan('badgr status')} Show what's running and what's billing
39
51
  ${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
52
+ ${chalk.cyan('badgr pull <id>')} Safely pull cloud-agent patch artifacts
53
+ ${chalk.cyan('badgr artifacts <id>')} Download non-patch outputs (test reports, screenshots, traces)
40
54
  ${chalk.cyan('badgr down <id>')} Stop a deployment and end billing
41
55
  ${chalk.cyan('badgr restart <id>')} Relaunch an endpoint with the same config and API key
42
56
  ${chalk.cyan('badgr rerun <id>')} Replay a past job or endpoint with its exact original spec
@@ -50,10 +64,12 @@ ${chalk.bold('COMMANDS')}
50
64
  ${chalk.cyan('badgr workspace list')} List workspace trackers (job history + cost per named context)
51
65
  ${chalk.cyan('badgr workspace create')} Create a workspace tracker (link jobs to a named storage path)
52
66
  ${chalk.cyan('badgr batch run <workload.yml>')} Run a generic containerized batch job with artifact capture
67
+ ${chalk.cyan('badgr batch run <yml> --fan-out <dir>')} Run the same program once per file in <dir> — one deployment per input, in parallel
53
68
  ${chalk.cyan('badgr batch status <run_id>')} Show status, failure reason, cost, teardown
54
69
  ${chalk.cyan('badgr batch artifacts <run_id>')} Download and extract output artifacts
55
70
  ${chalk.cyan('badgr batch receipt <run_id>')} Show the full batch receipt
56
71
  ${chalk.cyan('badgr batch compare <a> <b>')} Compare success_metric between two runs
72
+ ${chalk.cyan('badgr sbatch <job.slurm>')} Run an existing Slurm batch script (cpus/mem/gres/array/time translated)
57
73
 
58
74
  ${chalk.bold('SHORTCUTS')} ${chalk.dim('(wrappers around run / serve for common workloads)')}
59
75
  ${chalk.cyan('badgr comfyui run <workflow.json>')} Launch ComfyUI, return endpoint URL
@@ -68,6 +84,40 @@ ${chalk.bold('SHORTCUTS')} ${chalk.dim('(wrappers around run / serve for common
68
84
  ${chalk.cyan('badgr serve --list-aliases')} List blessed vLLM model shortcuts (qwen-7b, llama-8b, …)
69
85
 
70
86
  ${chalk.bold('EXAMPLES')}
87
+ ${chalk.dim('# Frictionless launch — no source, no --, no image, no --max-cost required')}
88
+ ${chalk.dim('# (a $2 default cost cap applies automatically; override with --max-cost).')}
89
+ ${chalk.dim('# cline is Badgr-native (no credential needed); claude/codex prompt inline for a')}
90
+ ${chalk.dim('# provider key the first time, then remember it; playwright needs no credential.')}
91
+ ${chalk.dim('# Each workload auto-selects its VM class (small, or "browser" for playwright);')}
92
+ ${chalk.dim('# override with --size small|medium|browser.')}
93
+ badgr launch cline "Fix the checkout bug"
94
+ badgr launch claude "Fix the checkout bug"
95
+ badgr launch codex "Write tests"
96
+ badgr launch playwright "Test the checkout flow"
97
+ badgr launch claude --size medium "Run the complete test suite"
98
+ badgr pull <id>
99
+ badgr artifacts <id>
100
+ badgr logs <id>
101
+ badgr receipts <id>
102
+
103
+ ${chalk.dim('# Explicit form — advanced escape hatch for anything not in the four workloads above.')}
104
+ ${chalk.dim('# Badgr flags go BEFORE --; everything after -- is passed to your command unchanged.')}
105
+ ${chalk.dim('# The default runner image is Python-only (no Node.js/npm) — a Node-based command')}
106
+ ${chalk.dim('# must install what it needs, or use --image with a custom image (see images/badgr-agent-*):')}
107
+ badgr launch . --max-cost 1 -- npm test
108
+ badgr launch https://github.com/user/repo --max-cost 1 -- python narrgo.py
109
+ badgr artifacts <id>
110
+
111
+ ${chalk.dim('# badgr task is a thin label wrapper over badgr launch . -- <command>:')}
112
+ badgr task "Run the Chromium tests and tell me what failed" --max-cost 1 -- npm run test:chromium
113
+
114
+ ${chalk.dim('# Diagnose why a GPU workload is likely failing (local, read-only, no login):')}
115
+ badgr doctor
116
+ badgr doctor --model Qwen/Qwen2.5-7B-Instruct --serve
117
+ badgr doctor --logs ./vllm.log
118
+ badgr doctor --workflow ./workflow.json
119
+ badgr doctor --url http://localhost:8000/health
120
+
71
121
  ${chalk.dim('# Point Badgr at any project and see what it detects:')}
72
122
  badgr detect .
73
123
  badgr run . --max-cost 5 --save my-job
@@ -106,12 +156,31 @@ ${chalk.bold('EXAMPLES')}
106
156
  badgr batch artifacts dep-abc123
107
157
  badgr batch compare dep-abc123 dep-def456
108
158
 
159
+ ${chalk.dim('# Run one program across many inputs — one deployment per file, in parallel:')}
160
+ ${chalk.dim('# workload.yml must declare exactly one inputs: entry (the path that varies per task).')}
161
+ ${chalk.dim('# --max-concurrency caps in-flight deployments (default: 5). --only reruns just the named files.')}
162
+ badgr batch run workload.yml --fan-out ./scenarios
163
+ badgr batch run workload.yml --fan-out ./scenarios --max-concurrency 10
164
+ badgr batch run workload.yml --fan-out ./scenarios --only failed1.json,failed2.json
165
+
166
+ ${chalk.dim('# Run an existing Slurm batch script — cpus-per-task/mem/gres/array/time translated:')}
167
+ ${chalk.dim('# --max-concurrency caps in-flight array tasks (default: 5).')}
168
+ badgr sbatch job.slurm
169
+ badgr sbatch job.slurm --dry-run
170
+ badgr sbatch array_job.slurm ${chalk.dim('# #SBATCH --array=1-100 fans out into one deployment per task')}
171
+ badgr sbatch array_job.slurm --max-concurrency 10
172
+
109
173
  ${chalk.dim('# Tier 2 — marketplace routing, lower-cost options:')}
110
174
  badgr serve meta-llama/Llama-3.1-8B-Instruct --tier 2 --max-cost 10
111
175
 
112
176
  ${chalk.dim('# Pin a specific GPU:')}
113
177
  badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S --max-cost 10
114
178
 
179
+ ${chalk.dim('# Describe basic compute needs instead of a GPU model — Badgr finds a compatible machine:')}
180
+ badgr run . --cpu 16 --memory 64GB --gpu-memory 24GB --max-cost 5
181
+ badgr run . --gpu-memory 24GB ${chalk.dim('# any GPU with at least 24GB VRAM')}
182
+ badgr run . --gpu A100 --count 4 ${chalk.dim('# exact hardware, for workloads that need it')}
183
+
115
184
  ${chalk.dim('# Manage a running deployment:')}
116
185
  badgr status
117
186
  badgr logs dep-abc123
@@ -120,6 +189,10 @@ ${chalk.bold('EXAMPLES')}
120
189
 
121
190
  ${chalk.bold('badgr run OPTIONS')}
122
191
  --gpu <type> GPU type (default: auto — Badgr picks best available)
192
+ --gpu-memory <size> Minimum GPU VRAM, e.g. 24GB — Badgr picks any GPU that satisfies it (alias: --min-vram)
193
+ --cpu <cores> Minimum CPU cores
194
+ --memory <size> Minimum RAM, e.g. 64GB
195
+ --no-gpu Run on a CPU-only VM — no GPU is provisioned (conflicts with --gpu/--gpu-memory)
123
196
  --tier 1 Managed provider routing (default)
124
197
  --tier 2 Marketplace provider routing, lower-cost options
125
198
  --image <image> Docker image (default: python:3.11-slim)
@@ -131,6 +204,30 @@ ${chalk.bold('badgr run OPTIONS')}
131
204
  --max-cost <$> Auto-stop when spend reaches this amount
132
205
  --detach Return immediately, don't stream logs
133
206
 
207
+ ${chalk.bold('badgr launch OPTIONS')}
208
+ ${chalk.yellow('All badgr launch flags below must come BEFORE --. Everything after -- is')}
209
+ ${chalk.yellow('passed to your command verbatim, including anything that looks like a flag —')}
210
+ ${chalk.yellow('badgr launch . -- claude --max-cost 1 sends --max-cost 1 to claude, not badgr.')}
211
+ --cmd "<command>" Quoted command form (equivalent to \`-- <command>\`)
212
+ --image <image> Custom image instead of the default CPU runtime image
213
+ --detach Keep running after disconnect (default for launch)
214
+ --no-detach Stream logs and wait instead of detaching
215
+ --env KEY=VALUE Set an environment variable (repeatable). Provider keys (ANTHROPIC_API_KEY,
216
+ etc.) passed this way may land in shell history and \`ps\` output — badgr
217
+ launch warns when a key looks secret. Dashboard-managed --profile secrets
218
+ are planned but not built yet.
219
+ --artifacts <path> Extra path to capture and upload as a downloadable artifact (repeatable) —
220
+ e.g. --artifacts playwright-report --artifacts test-results. Retrieve with
221
+ \`badgr artifacts <id>\`. Relative paths are resolved from the workspace root.
222
+ --max-cost <$> Auto-stop when spend reaches this amount
223
+ --max-runtime <min> Auto-stop after N minutes (default: 60)
224
+ --region US|EU|AU Region preference
225
+ --size small|medium|browser VM class override. Defaults per workload: small for cline/claude/
226
+ codex/explicit form, browser for playwright (Chromium preinstalled).
227
+
228
+ ${chalk.bold('badgr artifacts OPTIONS')}
229
+ --output <dir> Directory to extract into (default: ~/.badgr/artifacts/<id>)
230
+
134
231
  ${chalk.bold('badgr serve OPTIONS')}
135
232
  --gpu <type> GPU type (default: auto — inferred from model size)
136
233
  --image <image> Serve a custom container instead of a HuggingFace model
@@ -169,8 +266,13 @@ async function main() {
169
266
 
170
267
  switch (cmd) {
171
268
  case 'login': return loginCommand(chalk, saveConfig);
269
+ case 'connect': return connectCommand(rest, chalk);
172
270
  case 'detect': return detectCommand(config, rest, chalk);
173
271
  case 'run': return runCommand(config, rest, chalk);
272
+ case 'launch': return launchCommand(config, rest, chalk);
273
+ case 'task': return taskCommand(config, rest, chalk);
274
+ case 'artifacts': return artifactsCommand(config, rest, chalk);
275
+ case 'pull': return pullCommand(config, rest, chalk);
174
276
  case 'serve': return serveCommand(config, rest, chalk);
175
277
  case 'status': return statusCommand(config, rest, chalk);
176
278
  case 'logs': return logsCommand(config, rest, chalk);
@@ -178,6 +280,7 @@ async function main() {
178
280
  case 'restart': return restartCommand(config, rest, chalk);
179
281
  case 'rerun': return rerunCommand(config, rest, chalk);
180
282
  case 'heartbeat': return heartbeatCommand(config, rest, chalk);
283
+ case 'doctor': return doctorCommand(config, rest, chalk);
181
284
  case 'receipts': return receiptsCommand(config, rest, chalk);
182
285
  case 'models': return modelsCommand(config, chalk);
183
286
  case 'capacity': return capacityCommand(config, rest, chalk);
@@ -190,6 +293,7 @@ async function main() {
190
293
  case 'template': return templateCommand(config, rest, chalk);
191
294
  case 'workload': return workloadCommand(config, rest, chalk);
192
295
  case 'batch': return batchCommand(config, rest, chalk);
296
+ case 'sbatch': return sbatchCommand(config, rest, chalk);
193
297
  case 'workspace': return workspaceCommand(config, rest, chalk);
194
298
  // legacy aliases kept for compatibility
195
299
  case 'up': return upCommand(config, rest, chalk);
package/src/batch.js CHANGED
@@ -86,16 +86,27 @@ export async function monitorBatchJob(config, depId, rcptId, opts) {
86
86
  'interrupted': chalk.yellow('\n Stopping...'),
87
87
  };
88
88
  console.log(msgs[reason] ?? chalk.yellow('\n Stopping...'));
89
- try { await terminateDeployment(config, depId); } catch {}
89
+ // A 200 response only means deletion was requested, not confirmed
90
+ // teardown_ok reflects whether the provider resource is actually gone.
91
+ let teardownConfirmed = false;
92
+ try {
93
+ const result = await terminateDeployment(config, depId);
94
+ teardownConfirmed = result?.teardown_ok === 'ok';
95
+ } catch {}
90
96
  const runtimeMs = Date.now() - attachStart;
91
97
  const finalCost = ratePerHour * (runtimeMs / 3_600_000);
92
98
  updateReceipt(rcptId, {
93
99
  status: reason,
100
+ teardownStatus: teardownConfirmed ? 'terminated' : 'failed',
94
101
  runtimeSeconds: Math.round(runtimeMs / 1000),
95
102
  finalCost,
96
103
  });
97
104
  console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
98
- console.log(chalk.dim(' Stopped. Billing ended.\n'));
105
+ if (teardownConfirmed) {
106
+ console.log(chalk.dim(' Stopped. Billing ended.\n'));
107
+ } else {
108
+ console.log(chalk.yellow(` Stop requested but not confirmed — resource may still be billing, check \`badgr status\` and \`badgr down ${depId}\`\n`));
109
+ }
99
110
  }
100
111
 
101
112
  while (true) {
@@ -135,14 +146,21 @@ export async function monitorBatchJob(config, depId, rcptId, opts) {
135
146
  const exitCode = dep.exit_code ?? null;
136
147
  const runtimeMs = Date.now() - attachStart;
137
148
  const finalCost = ratePerHour * (runtimeMs / 3_600_000);
138
- try { await terminateDeployment(config, depId); } catch {}
149
+ // A 200 response only means deletion was requested, not confirmed
150
+ // teardown_ok reflects whether the provider resource is actually gone.
151
+ let teardownConfirmed = false;
152
+ try {
153
+ const result = await terminateDeployment(config, depId);
154
+ teardownConfirmed = result?.teardown_ok === 'ok';
155
+ } catch {}
139
156
  updateReceipt(rcptId, {
140
157
  status: dep.status,
141
158
  exitCode,
159
+ teardownStatus: teardownConfirmed ? 'terminated' : 'failed',
142
160
  runtimeSeconds: Math.round(runtimeMs / 1000),
143
161
  finalCost,
144
162
  });
145
- return { status: dep.status, exitCode, runtimeMs, reason: 'complete' };
163
+ return { status: dep.status, exitCode, runtimeMs, teardownConfirmed, reason: 'complete' };
146
164
  }
147
165
  }
148
166
 
package/src/browser.js ADDED
@@ -0,0 +1,23 @@
1
+ import { execFileSync } from 'child_process';
2
+
3
+ /** Best-effort open a URL in the user's default browser. Never throws —
4
+ * callers should always also print the URL, since headless/CI environments
5
+ * (or an unrecognized platform) may have nothing to open it with.
6
+ *
7
+ * Uses execFileSync (argv array, no shell) rather than execSync (a shell
8
+ * string) — url is not always a local constant (onboarding.js passes
9
+ * session.login_url, a backend API response), so building a shell command
10
+ * via string interpolation would be a real command-injection vector if a
11
+ * response ever contained shell metacharacters. execFileSync passes url as
12
+ * one literal argv element to the opener command directly; no shell parses
13
+ * it at all. */
14
+ export function openBrowser(url) {
15
+ const platform = process.platform;
16
+ try {
17
+ if (platform === 'darwin') execFileSync('open', [url]);
18
+ else if (platform === 'win32') execFileSync('cmd', ['/c', 'start', '', url]);
19
+ else execFileSync('xdg-open', [url]);
20
+ } catch {
21
+ // Silently ignore — the caller prints the URL as a fallback.
22
+ }
23
+ }
@@ -0,0 +1,75 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { requireApiKey, CONFIG_DIR } from '../config.js';
4
+ import { downloadAndExtractArtifact } from '../artifactDownload.js';
5
+
6
+ /**
7
+ * badgr artifacts <id> [--output <dir>]
8
+ *
9
+ * Generic artifact retrieval for `badgr launch`/`badgr run` output paths
10
+ * (e.g. --artifacts playwright-report --artifacts test-results) — anything
11
+ * captured that isn't a git patch. `badgr pull` is for code changes;
12
+ * `badgr artifacts` is for everything else a command produced.
13
+ */
14
+
15
+ function defaultDestDir(deploymentId) {
16
+ return path.join(CONFIG_DIR, 'artifacts', deploymentId);
17
+ }
18
+
19
+ export function parseArtifactsArgs(args) {
20
+ const flags = {};
21
+ const positional = [];
22
+ for (let i = 0; i < args.length; i++) {
23
+ if (args[i] === '--output') { flags.output = args[++i]; continue; }
24
+ positional.push(args[i]);
25
+ }
26
+ return { deploymentId: positional[0], flags };
27
+ }
28
+
29
+ export function listFilesRecursive(dir, prefix = '') {
30
+ const out = [];
31
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
32
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
33
+ if (entry.isDirectory()) out.push(...listFilesRecursive(path.join(dir, entry.name), rel));
34
+ else out.push(rel);
35
+ }
36
+ return out.sort();
37
+ }
38
+
39
+ export async function artifactsCommand(config, args, chalk) {
40
+ requireApiKey(config);
41
+ const { deploymentId, flags } = parseArtifactsArgs(args);
42
+ if (!deploymentId) {
43
+ console.error(chalk.red('\n Usage: badgr artifacts <deployment-id> [--output <dir>]\n'));
44
+ console.error(chalk.dim(' Example: badgr artifacts dep-abc123\n'));
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+
49
+ const destDir = flags.output ? path.resolve(flags.output) : defaultDestDir(deploymentId);
50
+
51
+ try {
52
+ await downloadAndExtractArtifact(config, deploymentId, destDir);
53
+ } catch (err) {
54
+ if (err.httpStatus === 404) {
55
+ console.error(chalk.red(`\n ✗ No artifact found for ${deploymentId}.\n`));
56
+ console.error(chalk.dim(' This means either the run had no --artifacts paths declared, it failed before'));
57
+ console.error(chalk.dim(' producing any of them, or it is still running. Check `badgr logs ' + deploymentId + '`.\n'));
58
+ } else if (err.httpStatus !== undefined) {
59
+ console.error(chalk.red(`\n ✗ Artifact download failed: ${err.httpStatus} ${err.statusText}${err.bodyText ? ` — ${err.bodyText}` : ''}\n`));
60
+ } else {
61
+ console.error(chalk.red(`\n ✗ Could not reach Badgr: ${err.message}\n`));
62
+ }
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+
67
+ const files = listFilesRecursive(destDir);
68
+ console.log(chalk.green(`\n Downloaded artifacts for ${deploymentId} → ${destDir}\n`));
69
+ if (files.length === 0) {
70
+ console.log(chalk.dim(' (artifact was empty)'));
71
+ } else {
72
+ for (const f of files) console.log(` - ${f}`);
73
+ }
74
+ console.log();
75
+ }