badgr-cli 1.0.32 → 1.0.35

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 CHANGED
@@ -61,11 +61,11 @@ badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S --region EU
61
61
 
62
62
  | Flag | Default | Description |
63
63
  |------|---------|-------------|
64
- | `--gpu <type>` | auto | GPU type — Badgr infers from model size if omitted. Options: RTX_4090, L40S, A6000, A100, H100 |
64
+ | `--gpu <type>` | auto | GPU type override — Badgr Auto selects based on model size if omitted |
65
65
  | `--image <img>` | — | Serve a custom container instead of a HuggingFace model |
66
66
  | `--task <task>` | — | vLLM task override, e.g. `embed` for embedding models |
67
67
  | `--env KEY=VALUE` | — | Environment variable (repeatable) |
68
- | `--tier 1\|2` | 1 | `1` = managed provider routing (default); `2` = marketplace routing, lower cost |
68
+ | `--tier 1\|2` | 1 | `1` = reliable execution (default); `2` = lower-cost burst capacity |
69
69
  | `--count <n>` | 1 | Number of GPUs (1–8) |
70
70
  | `--region US\|EU\|AU` | — | Optional region preference. If omitted, Badgr chooses best available capacity. |
71
71
  | `--max-price <$/hr>` | — | Hard spend cap per GPU-hour |
@@ -83,10 +83,11 @@ badgr run python train.py --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-runtime 60
83
83
 
84
84
  | Flag | Default | Description |
85
85
  |------|---------|-------------|
86
- | `--gpu <type>` | auto | GPU type — Badgr picks best available if omitted |
86
+ | `--gpu <type>` | auto | GPU type override — Badgr Auto selects if omitted |
87
+ | `--min-vram <GB>` | — | Minimum VRAM in GB — optional constraint for Auto routing |
87
88
  | `--image <img>` | python:3.11-slim | Docker image |
88
89
  | `--env KEY=VALUE` | — | Environment variable (repeatable) |
89
- | `--tier 1\|2` | 1 | `1` = managed provider routing (default); `2` = marketplace routing, lower cost |
90
+ | `--tier 1\|2` | 1 | `1` = reliable execution (default); `2` = lower-cost burst capacity |
90
91
  | `--count <n>` | 1 | Number of GPUs |
91
92
  | `--region US\|EU\|AU` | — | Optional region preference. If omitted, Badgr chooses best available capacity. |
92
93
  | `--max-price <$/hr>` | — | Hard spend cap per GPU-hour |
@@ -98,16 +99,18 @@ badgr run python train.py --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-runtime 60
98
99
 
99
100
  ## Routing
100
101
 
101
- Badgr automatically searches verified GPU capacity and chooses the best eligible route for your GPU type, workload, price cap, and optional region preference.
102
+ Badgr Auto selects the best eligible route based on GPU type, VRAM, availability, region, workload requirements, and reliability. Advanced users can optionally choose an execution tier or hardware constraint.
102
103
 
103
- **Tier 1** (default) managed provider routing with guaranteed SLAs.
104
+ Most users should use the default Badgr Auto route. Tiers are an optional advanced control.
104
105
 
105
- **Tier 2** — marketplace routing for lower-cost options when Tier 1 capacity is constrained.
106
+ **Tier 1** (default) Reliable execution. Best for production workloads, model serving, and jobs where startup reliability matters most. Uses managed routing, readiness checks, fallback, and teardown controls.
107
+
108
+ **Tier 2** — Lower-cost burst execution. An optional advanced control for cost-sensitive workloads. Availability may vary.
106
109
 
107
110
  Preview before provisioning:
108
111
 
109
112
  ```bash
110
- badgr serve mistral-7b --gpu RTX_4090 --dry-run
113
+ badgr serve meta-llama/Llama-3.1-8B-Instruct --dry-run
111
114
  ```
112
115
 
113
116
  ---
@@ -154,15 +157,23 @@ const client = new OpenAI({
154
157
 
155
158
  ## GPU options
156
159
 
157
- | Flag value | GPU | VRAM | Estimated Badgr price/hr |
158
- |-----------|-----|------|-------------|
159
- | RTX_4090 | NVIDIA RTX 4090 | 24 GB | $0.65–0.89 |
160
- | L40S | NVIDIA L40S | 48 GB | $1.10–1.40 |
161
- | A6000 | NVIDIA RTX A6000 | 48 GB | $1.05–1.35 |
162
- | A100 | NVIDIA A100 | 80 GB | $1.20–1.50 |
163
- | H100 | NVIDIA H100 | 80 GB | $2.80–3.10 |
160
+ Badgr Auto selects the best eligible GPU for your workload. Add `--gpu <type>` or `--min-vram <GB>` only when you need more control.
161
+
162
+ Available GPU types may vary by region and current capacity. Run `badgr capacity` or use `--dry-run` to confirm availability before provisioning.
163
+
164
+ | Flag value | GPU | VRAM | Best for |
165
+ |-----------|-----|------|---------|
166
+ | RTX_3090 | NVIDIA RTX 3090 | 24 GB | Dev, inference |
167
+ | RTX_4090 | NVIDIA RTX 4090 | 24 GB | Inference, training, dev |
168
+ | L40S | NVIDIA L40S | 48 GB | Inference, vLLM, embeddings |
169
+ | A100 | NVIDIA A100 | 40–80 GB | Training, inference |
170
+ | H100 | NVIDIA H100 | 80 GB | Large model training |
171
+
172
+ Additional GPU types may be routable depending on current capacity — check with `badgr capacity`.
173
+
174
+ Pricing is confirmed before provisioning. Use `--dry-run` to see pricing before committing.
164
175
 
165
- Prices shown are estimated Badgr rates. Final $/GPU-hour is confirmed before provisioning and may vary by GPU type, availability, region, workload, and runtime.
176
+ Full GPU support details: see [GPU_SUPPORT.md](../../GPU_SUPPORT.md) in the repo root.
166
177
 
167
178
  ---
168
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.32",
3
+ "version": "1.0.35",
4
4
  "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,11 +12,11 @@
12
12
  "test:watch": "vitest"
13
13
  },
14
14
  "dependencies": {
15
- "@inquirer/prompts": "^5.1.0",
15
+ "@inquirer/prompts": "^8.5.2",
16
16
  "chalk": "^5.3.0"
17
17
  },
18
18
  "devDependencies": {
19
- "vitest": "^1.6.0"
19
+ "vitest": "^4.1.8"
20
20
  },
21
21
  "engines": {
22
22
  "node": ">=18.0.0"
package/src/api.js CHANGED
@@ -105,7 +105,7 @@ export function runJob(config, body) {
105
105
  apiKey: config.apiKey,
106
106
  baseUrl: config.baseUrl,
107
107
  body,
108
- timeoutMs: 30_000,
108
+ timeoutMs: 130_000,
109
109
  });
110
110
  }
111
111
 
@@ -115,7 +115,7 @@ export function serveModel(config, body) {
115
115
  apiKey: config.apiKey,
116
116
  baseUrl: config.baseUrl,
117
117
  body,
118
- timeoutMs: 30_000,
118
+ timeoutMs: 130_000,
119
119
  });
120
120
  }
121
121
 
@@ -33,6 +33,10 @@ function printReceipt(r, chalk) {
33
33
  if (r.retries !== undefined) console.log(` ${chalk.bold('retries:')} ${r.retries}`);
34
34
  if (r.route?.ratePerHour) console.log(` ${chalk.bold('rate:')} $${r.route.ratePerHour.toFixed(4)}/hr`);
35
35
 
36
+ if (r.failureCategory ?? r.failure_category ?? r.failure_reason) {
37
+ const cat = r.failureCategory ?? r.failure_category ?? r.failure_reason;
38
+ console.log(` ${chalk.bold('failure:')} ${chalk.red(cat)}`);
39
+ }
36
40
  if (r.status) console.log(` ${chalk.bold('status:')} ${r.status}`);
37
41
  console.log(` ${chalk.dim(ts)}`);
38
42
  console.log();
@@ -2,6 +2,7 @@ import { requireApiKey } from '../config.js';
2
2
  import { callApi, terminateDeployment } from '../api.js';
3
3
  import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
4
  import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
5
+ import { formatCliError } from '../errors.js';
5
6
 
6
7
  /**
7
8
  * badgr run python train.py # gpu=auto, attached
@@ -28,6 +29,7 @@ export function parseRunArgs(args) {
28
29
  if (args[i] === '--no-expanded-search') { flags.noFallback = true; i++; continue; }
29
30
  if (args[i] === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
30
31
  if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
32
+ if (args[i] === '--min-vram') { flags.minVram = parseFloat(args[++i]); i++; continue; }
31
33
  if (args[i] === '--env') {
32
34
  const kv = args[++i]; i++;
33
35
  if (!flags.env) flags.env = [];
@@ -324,9 +326,33 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
324
326
  return { status: 'interrupted', reason: 'signal', exitCode: null, runtimeMs: Date.now() - startMs, failureType: null };
325
327
  }
326
328
 
329
+ // Known badgr run flags — used to detect broken shell line continuation.
330
+ const _KNOWN_RUN_FLAGS = new Set([
331
+ '--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
332
+ '--detach', '--fallback', '--no-fallback', '--strict-capacity',
333
+ '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
334
+ ]);
335
+
327
336
  export async function runCommand(config, args, chalk) {
328
337
  const { flags, positional } = parseRunArgs(args);
329
338
 
339
+ // Detect flags that ended up in the command because of broken shell line continuation
340
+ // (e.g. `\ ` with trailing space instead of `\<newline>`).
341
+ const misplaced = positional.filter(a => _KNOWN_RUN_FLAGS.has(a));
342
+ if (misplaced.length > 0) {
343
+ console.error(chalk.red(`\n ✗ These look like badgr flags but were treated as command arguments:`));
344
+ console.error(chalk.red(` ${misplaced.join(', ')}`));
345
+ console.error(chalk.dim(''));
346
+ console.error(chalk.dim(' This usually means a line continuation has a trailing space.'));
347
+ console.error(chalk.dim(' Use a single line, or end each continued line with \\ and no space after:'));
348
+ console.error(chalk.dim(''));
349
+ console.error(chalk.dim(' badgr run python script.py \\'));
350
+ console.error(chalk.dim(' --gpu RTX_4090 --max-cost 10 --max-runtime 60'));
351
+ console.error(chalk.dim(''));
352
+ process.exitCode = 1;
353
+ return;
354
+ }
355
+
330
356
  if (positional.length === 0 && !flags.image) {
331
357
  console.error(chalk.red('Usage: badgr run <command...>'));
332
358
  console.error(chalk.red(' badgr run --image my/image:latest'));
@@ -356,7 +382,6 @@ export async function runCommand(config, args, chalk) {
356
382
  process.exitCode = 1;
357
383
  return;
358
384
  }
359
-
360
385
  const command = positional.length > 0 ? positional : undefined;
361
386
  const cmdStr = command ? command.join(' ') : '';
362
387
  const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
@@ -368,39 +393,26 @@ export async function runCommand(config, args, chalk) {
368
393
  const envObj = parseEnvFlag(flags.env);
369
394
  const effectiveTier = normalizeTier(flags.tier);
370
395
 
371
- let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'auto';
372
-
373
- if (gpu === 'auto') {
374
- console.log(chalk.bold('\n⚡ Running GPU job\n'));
375
- if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
376
- if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
377
- if (command) {
378
- const profKey = inferProfileFromCommand(cmdStr);
379
- const prof = _PROFILES[profKey];
380
- console.log();
381
- console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
382
- console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
383
- if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
384
- if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
385
- }
386
- console.log();
387
- } else {
388
- console.log(chalk.bold('\n⚡ Running GPU job\n'));
389
- if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
390
- if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
391
- console.log(` ${chalk.bold('GPU:')} ${gpu}`);
392
- if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
393
- if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
394
- if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
395
- if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
396
- if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
397
- console.log();
396
+ const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : undefined;
397
+
398
+ console.log(chalk.bold('\n⚡ Running GPU job\n'));
399
+ if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
400
+ if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
401
+ if (gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
402
+ else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
403
+ if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
404
+ if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
405
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
406
+ if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
407
+ if (detach) console.log(` ${chalk.dim('(detached returns immediately)')}`);
408
+ if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
409
+ console.log();
398
410
 
399
- if (!detach && !flags.maxRuntime && !maxCost) {
400
- console.log(chalk.dim(' Tip: add --max-runtime 60 or --max-cost 5.00 to cap spend automatically'));
401
- }
411
+ if (!detach && !flags.maxRuntime && !maxCost) {
412
+ console.log(chalk.dim(' Tip: add --max-runtime 60 or --max-cost 5.00 to cap spend automatically'));
402
413
  }
403
414
 
415
+
404
416
  console.log(chalk.dim(' Finding suitable capacity...'));
405
417
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
406
418
  console.log(chalk.dim(` API: ${config.baseUrl}`));
@@ -411,7 +423,8 @@ export async function runCommand(config, args, chalk) {
411
423
  return {
412
424
  command,
413
425
  image,
414
- gpu: (gpuOverride || gpu),
426
+ gpu: gpuOverride || gpu || 'auto',
427
+ ...(flags.minVram ? { min_vram: flags.minVram } : {}),
415
428
  gpu_count: flags.count || 1,
416
429
  ...(effectiveRegion ? { region: effectiveRegion } : {}),
417
430
  max_price_per_hour: flags.maxPrice,
@@ -526,8 +539,8 @@ export async function runCommand(config, args, chalk) {
526
539
  shuttingDown = true;
527
540
  try {
528
541
  await teardown(reason);
529
- } catch (err) {
530
- console.error(chalk.red(` Warning: teardown may have failed: ${err.message}`));
542
+ } catch {
543
+ console.error(formatCliError('TEARDOWN_FAILED', { deploymentId: dep.deployment_id, receiptId: rcptId }, chalk));
531
544
  }
532
545
  process.exit(reason === 'interrupted' ? 0 : 1);
533
546
  }
@@ -542,10 +555,7 @@ export async function runCommand(config, args, chalk) {
542
555
 
543
556
  if (dep.status === 'failed') {
544
557
  process.removeListener('SIGINT', handleShutdown);
545
- console.error(chalk.red('\n ✗ Container failed to start (infrastructure error).\n'));
546
- console.error(chalk.dim(' The backend retried automatically. All attempts failed.'));
547
- console.error(chalk.dim(` Contact support with receipt ID: ${rcptId}`));
548
- console.log();
558
+ console.error(formatCliError('JOB_INFRASTRUCTURE_FAILURE', { receiptId: rcptId }, chalk));
549
559
  process.exitCode = 1;
550
560
  return;
551
561
  }
@@ -597,13 +607,10 @@ export async function runCommand(config, args, chalk) {
597
607
 
598
608
  if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
599
609
  if (failureType === 'infrastructure') {
600
- console.error(chalk.red(`\n ✗ Machine failure this is not your code.\n`));
601
- console.error(chalk.dim(' Contact support with your receipt ID for a refund.'));
610
+ console.error(formatCliError('JOB_INFRASTRUCTURE_FAILURE', { receiptId: rcptId }, chalk));
602
611
  } else {
603
- console.error(chalk.red(`\n ✗ Job failed (exit ${exitCode ?? 'unknown'})\n`));
604
- console.error(chalk.dim(` Check logs: badgr logs ${dep.deployment_id}`));
612
+ console.error(formatCliError('JOB_FAILED', { exitCode, deploymentId: dep.deployment_id }, chalk));
605
613
  }
606
- console.log();
607
614
  process.exitCode = exitCode ?? 1;
608
615
  return;
609
616
  }
@@ -2,6 +2,7 @@ import { requireApiKey } from '../config.js';
2
2
  import { callApi } from '../api.js';
3
3
  import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
4
  import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
5
+ import { formatCliError } from '../errors.js';
5
6
 
6
7
  /**
7
8
  * badgr serve meta-llama/Llama-3.1-8B-Instruct
@@ -27,6 +28,7 @@ export function parseServeArgs(args) {
27
28
  if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
28
29
  if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
29
30
  if (args[i] === '--health-path') { flags.healthPath = args[++i]; i++; continue; }
31
+ if (args[i] === '--check-nodes') { flags.checkNodes = args[++i]; i++; continue; }
30
32
  // All three aliases map to noMarketplaceFallback
31
33
  if (args[i] === '--no-fallback' ||
32
34
  args[i] === '--strict-capacity' ||
@@ -128,10 +130,61 @@ async function waitForEndpoint(endpointUrl, deploymentId, config, timeoutMs = 5
128
130
  return { ready: false, timedOut: true, depFailed: false };
129
131
  }
130
132
 
133
+ async function validateComfyNodes(baseUrl, nodeList, chalk) {
134
+ const nodes = nodeList.split(',').map(n => n.trim()).filter(Boolean);
135
+ if (!nodes.length) return;
136
+ const objectInfoUrl = baseUrl.replace(/\/v1\/?$/, '') + '/object_info';
137
+ console.log(chalk.dim(`\n Checking custom nodes via ${objectInfoUrl}…`));
138
+ try {
139
+ const res = await fetch(objectInfoUrl, { signal: AbortSignal.timeout(15_000) });
140
+ if (!res.ok) {
141
+ console.log(chalk.yellow(` ⚠ Could not verify nodes (HTTP ${res.status})`));
142
+ return;
143
+ }
144
+ const available = await res.json();
145
+ const found = nodes.filter(n => Object.prototype.hasOwnProperty.call(available, n));
146
+ const missing = nodes.filter(n => !Object.prototype.hasOwnProperty.call(available, n));
147
+ if (found.length) console.log(chalk.green(` ✓ Nodes confirmed: ${found.join(', ')}`));
148
+ if (missing.length) {
149
+ console.log(chalk.yellow(` ⚠ Nodes not found: ${missing.join(', ')}`));
150
+ console.log(chalk.dim(' These may not be installed in this image.'));
151
+ }
152
+ } catch (err) {
153
+ console.log(chalk.yellow(` ⚠ Node check failed: ${err.message}`));
154
+ }
155
+ }
156
+
157
+ // Known badgr serve flags — used to detect broken shell line continuation.
158
+ const _KNOWN_SERVE_FLAGS = new Set([
159
+ '--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
160
+ '--name', '--no-wait', '--max-cost', '--health-path', '--check-nodes',
161
+ '--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
162
+ ]);
163
+
131
164
  export async function serveCommand(config, args, chalk) {
132
165
  const { model, flags } = parseServeArgs(args);
133
166
  const customImage = flags.image || null;
134
167
 
168
+ // Detect flags that ended up as positional args due to broken shell line continuation
169
+ // (e.g. `\ ` with a trailing space instead of `\<newline>`).
170
+ // After parseServeArgs, only the model name should be in positional. Any extra token
171
+ // that matches a known badgr flag name was not parsed — likely a syntax mistake.
172
+ const servePositional = args.filter(a => !a.startsWith('-'));
173
+ const misplaced = servePositional.slice(1).filter(a => _KNOWN_SERVE_FLAGS.has(a));
174
+ if (misplaced.length > 0) {
175
+ console.error(chalk.red(`\n ✗ These look like badgr flags but were treated as arguments:`));
176
+ console.error(chalk.red(` ${misplaced.join(', ')}`));
177
+ console.error(chalk.dim(''));
178
+ console.error(chalk.dim(' This usually means a line continuation has a trailing space.'));
179
+ console.error(chalk.dim(' Use a single line, or end each continued line with \\ and no space after:'));
180
+ console.error(chalk.dim(''));
181
+ console.error(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct \\'));
182
+ console.error(chalk.dim(' --gpu L40S --max-cost 10'));
183
+ console.error(chalk.dim(''));
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+
135
188
  if (!model && !customImage) {
136
189
  console.error(chalk.red('Usage: badgr serve <model>'));
137
190
  console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct'));
@@ -302,9 +355,10 @@ export async function serveCommand(config, args, chalk) {
302
355
  timeoutMs: 10_000,
303
356
  });
304
357
  if (['failed', 'terminated', 'error'].includes(latest.status)) {
305
- console.error(chalk.red(
306
- `\n ✗ Deployment failed before endpoint became ready: ${latest.error || latest.status}\n`
307
- ));
358
+ console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
359
+ deploymentId: dep.deployment_id,
360
+ failReason: latest.error || latest.status,
361
+ }, chalk));
308
362
  updateReceipt(rcptId, { status: 'failed', failReason: latest.error || latest.status });
309
363
  process.exitCode = 1;
310
364
  return;
@@ -317,10 +371,10 @@ export async function serveCommand(config, args, chalk) {
317
371
  process.stdout.write('\n');
318
372
 
319
373
  if (healthResult.depFailed) {
320
- console.error(chalk.red(
321
- `\n ✗ Deployment failed during startup: ${healthResult.failReason || 'unknown error'}\n` +
322
- ` Check logs: badgr logs ${dep.deployment_id}\n`
323
- ));
374
+ console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
375
+ deploymentId: dep.deployment_id,
376
+ failReason: healthResult.failReason,
377
+ }, chalk));
324
378
  updateReceipt(rcptId, { status: 'failed', failReason: healthResult.failReason });
325
379
  process.exitCode = 1;
326
380
  return;
@@ -330,6 +384,11 @@ export async function serveCommand(config, args, chalk) {
330
384
  if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
331
385
  }
332
386
 
387
+ // ── Custom-node validation (ComfyUI) ─────────────────────────────────────
388
+ if (flags.checkNodes && endpointReady) {
389
+ await validateComfyNodes(endpointUrl, flags.checkNodes, chalk);
390
+ }
391
+
333
392
  // ── Update receipt with final state ───────────────────────────────────────
334
393
  updateReceipt(rcptId, {
335
394
  status: endpointReady ? 'ready' : 'starting',