badgr-cli 1.1.0 → 1.1.2

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 (66) hide show
  1. package/LICENSE +207 -0
  2. package/README.md +135 -3
  3. package/package.json +44 -2
  4. package/src/api.js +16 -0
  5. package/src/badgr.js +2 -2
  6. package/src/commands/batch.js +11 -0
  7. package/src/commands/comfyui.js +31 -15
  8. package/src/commands/embed.js +13 -10
  9. package/src/commands/launch.js +8 -1
  10. package/src/commands/login.js +75 -20
  11. package/src/commands/run.js +45 -13
  12. package/src/commands/sbatch.js +6 -1
  13. package/src/commands/serve.js +44 -30
  14. package/src/commands/train.js +8 -12
  15. package/src/commands/transcribe.js +13 -10
  16. package/src/envFlag.js +10 -0
  17. package/src/onboarding.js +8 -1
  18. package/src/progress.js +48 -0
  19. package/tests/agent-images.test.js +0 -17
  20. package/tests/api.test.js +0 -168
  21. package/tests/artifactDownload.test.js +0 -113
  22. package/tests/artifacts.test.js +0 -168
  23. package/tests/batch.test.js +0 -641
  24. package/tests/browser.test.js +0 -51
  25. package/tests/capacity.test.js +0 -68
  26. package/tests/commands.test.js +0 -417
  27. package/tests/config.test.js +0 -96
  28. package/tests/connect.test.js +0 -83
  29. package/tests/detect.test.js +0 -191
  30. package/tests/down.test.js +0 -150
  31. package/tests/errors.test.js +0 -130
  32. package/tests/fallback-timeout.test.js +0 -41
  33. package/tests/fanout.test.js +0 -124
  34. package/tests/gpu-doctor-classifiers.test.js +0 -402
  35. package/tests/gpu-doctor-doctor.test.js +0 -304
  36. package/tests/gpu-doctor-probe-cache.test.js +0 -110
  37. package/tests/gpu-doctor-probes.test.js +0 -257
  38. package/tests/heartbeat.test.js +0 -70
  39. package/tests/job-progress-poll.test.js +0 -136
  40. package/tests/launch-command-argv.test.js +0 -93
  41. package/tests/launch-readiness.test.js +0 -403
  42. package/tests/launch.test.js +0 -440
  43. package/tests/onboarding.test.js +0 -134
  44. package/tests/productized-dry-run.test.js +0 -141
  45. package/tests/productized-runners.test.js +0 -237
  46. package/tests/pull.test.js +0 -266
  47. package/tests/rerun.test.js +0 -94
  48. package/tests/restart.test.js +0 -88
  49. package/tests/router.test.js +0 -98
  50. package/tests/run-lifecycle.test.js +0 -1054
  51. package/tests/sbatch.test.js +0 -190
  52. package/tests/secrets.test.js +0 -16
  53. package/tests/serve-apps.test.js +0 -189
  54. package/tests/serve-lifecycle.test.js +0 -931
  55. package/tests/slurm.test.js +0 -77
  56. package/tests/spec.test.js +0 -201
  57. package/tests/status.test.js +0 -73
  58. package/tests/store.test.js +0 -187
  59. package/tests/task.test.js +0 -109
  60. package/tests/template.test.js +0 -556
  61. package/tests/train-lora-dataset.test.js +0 -176
  62. package/tests/upload.test.js +0 -79
  63. package/tests/workload-rerun.test.js +0 -56
  64. package/tests/workload-spec.test.js +0 -180
  65. package/tests/workload-templates.test.js +0 -865
  66. package/tests/workload-workspace-paths.test.js +0 -46
@@ -7,12 +7,14 @@
7
7
  */
8
8
  import { readFileSync, existsSync } from 'fs';
9
9
  import { ensureBadgrReady } from '../onboarding.js';
10
+ import { requireApiKey } from '../config.js';
10
11
  import { callApi, listDeployments } from '../api.js';
11
12
  import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
12
13
  import { normalizeTier, callWithFallback } from '../fallback.js';
13
14
  import { formatCliError } from '../errors.js';
14
15
  import { BLESSED_COMFY_WORKFLOWS } from '../catalog.js';
15
- import { pollJobUntilTerminal, renderJobClosingBlock, stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
16
+ import { parseEnvFlag } from '../envFlag.js';
17
+ import { pollJobUntilTerminal, renderJobClosingBlock, stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
16
18
 
17
19
  const COMFYUI_IMAGE = process.env.COMFYUI_IMAGE || 'yanwk/comfyui-boot:cu126-megapak';
18
20
  const HEALTH_PATH = '/system_stats';
@@ -35,6 +37,7 @@ export function parseComfyuiArgs(args) {
35
37
  if (a === '--check-nodes') { flags.checkNodes = args[++i]; i++; continue; }
36
38
  if (a === '--persistent') { flags.persistent = true; i++; continue; }
37
39
  if (a === '--yes' || a === '-y') { flags.yes = true; i++; continue; }
40
+ if (a === '--dry-run') { flags.dryRun = true; i++; continue; }
38
41
  if (a === '--env') {
39
42
  const kv = args[++i]; i++;
40
43
  if (!flags.env) flags.env = [];
@@ -46,15 +49,6 @@ export function parseComfyuiArgs(args) {
46
49
  return { workflow: positional[0] || null, flags };
47
50
  }
48
51
 
49
- function parseEnvFlag(envList) {
50
- const obj = {};
51
- for (const kv of (envList || [])) {
52
- const idx = kv.indexOf('=');
53
- if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
54
- }
55
- return obj;
56
- }
57
-
58
52
  // Live block while ComfyUI comes up — same generic "Checking health" stage
59
53
  // line and status block as every other launch path (see serve.js's
60
54
  // waitForEndpoint), so ComfyUI doesn't read as a different UX than vLLM.
@@ -179,16 +173,20 @@ export async function comfyBatchCommand(config, args, chalk) {
179
173
 
180
174
  if (flags.dryRun) {
181
175
  const workflowSpec = BLESSED_COMFY_WORKFLOWS[flags.workflow];
176
+ const resolvedGpu = flags.gpuType || workflowSpec?.gpu_type;
182
177
  console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
183
178
  console.log(` ${chalk.bold('Workflow:')} ${flags.workflow}${workflowSpec ? '' : chalk.yellow(' (unknown — server will reject this)')}`);
184
179
  if (workflowSpec) {
185
- console.log(` ${chalk.bold('GPU:')} ${flags.gpuType || workflowSpec.gpu_type}`);
180
+ console.log(` ${chalk.bold('GPU:')} ${resolvedGpu}`);
186
181
  console.log(` ${chalk.dim(workflowSpec.description)}`);
187
182
  }
188
183
  console.log(` ${chalk.bold('Prompts:')} ${prompts.length}${prompts.length > 20 ? chalk.yellow(' (exceeds the 20-prompt limit — server will reject this)') : ''}`);
184
+ console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(normalizeTier(flags.tier))}`);
189
185
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
190
186
  console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime ?? 60}min`);
191
- console.log(chalk.dim('\n Remove --dry-run to submit.\n'));
187
+ console.log();
188
+ if (resolvedGpu) await printCapacityPreview(chalk, config, { gpu: resolvedGpu });
189
+ console.log(chalk.dim(' Remove --dry-run to submit.\n'));
192
190
  return;
193
191
  }
194
192
 
@@ -282,9 +280,15 @@ export async function comfyuiCommand(config, args, chalk) {
282
280
  return;
283
281
  }
284
282
 
285
- config = await ensureBadgrReady(config, chalk);
283
+ // A dry run previews the plan only and never provisions or spends anything,
284
+ // so it doesn't need the interactive browser login — just a stored key.
285
+ if (flags.dryRun) {
286
+ requireApiKey(config);
287
+ } else {
288
+ config = await ensureBadgrReady(config, chalk);
289
+ }
286
290
 
287
- if (!flags.maxCost && !flags.persistent) {
291
+ if (!flags.maxCost && !flags.persistent && !flags.dryRun) {
288
292
  console.error(chalk.red('\n ✗ ComfyUI endpoints bill continuously. Specify a spending limit:\n'));
289
293
  console.error(chalk.dim(' --max-cost 5 auto-stop when $5 is reached'));
290
294
  console.error(chalk.dim(' --persistent run until you stop it manually\n'));
@@ -328,17 +332,29 @@ export async function comfyuiCommand(config, args, chalk) {
328
332
  const STAGE_TOTAL = 4;
329
333
  let stageN = 1;
330
334
 
331
- console.log(chalk.bold('\n⚡ Running ComfyUI\n'));
335
+ console.log(chalk.bold(flags.dryRun
336
+ ? '\n⚡ Dry run — no GPU will be provisioned — ComfyUI\n'
337
+ : '\n⚡ Running ComfyUI\n'));
332
338
  console.log(` ${chalk.bold('Workflow:')} ${workflow} (${nodeCount} nodes)`);
333
339
  console.log(` ${chalk.bold('Image:')} ${COMFYUI_IMAGE}`);
334
340
  console.log(` ${chalk.bold('GPU:')} ${gpu === 'AUTO' ? chalk.dim('auto (16+ GB VRAM)') : gpu}`);
341
+ if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
342
+ if (flags.dryRun) console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(effectiveTier)}`);
335
343
  if (flags.maxCost) {
336
344
  console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)} (auto-stop)`);
345
+ } else if (flags.dryRun) {
346
+ console.log(` ${chalk.bold('Max cost:')} ${chalk.dim('none')}`);
337
347
  } else {
338
348
  console.log(chalk.yellow(' ⚠ Persistent — billing until: badgr down <id>'));
339
349
  }
340
350
  console.log();
341
351
 
352
+ if (flags.dryRun) {
353
+ await printCapacityPreview(chalk, config, { gpu, region: flags.region?.toUpperCase(), maxPrice: flags.maxPrice });
354
+ console.log(chalk.dim(' Remove --dry-run to provision.\n'));
355
+ return;
356
+ }
357
+
342
358
  // Duplicate check
343
359
  if (config.apiKey) {
344
360
  try {
@@ -14,6 +14,8 @@ import { requireApiKey } from '../config.js';
14
14
  import { addReceipt, generateReceiptId } from '../store.js';
15
15
  import { normalizeTier, callWithFallback } from '../fallback.js';
16
16
  import { monitorBatchJob, fmtRuntime } from '../batch.js';
17
+ import { parseEnvFlag } from '../envFlag.js';
18
+ import { printCapacityPreview, formatTierLabel } from '../progress.js';
17
19
 
18
20
  const EMBED_IMAGE = 'vllm/vllm-openai:latest';
19
21
  const DEFAULT_EMBED_MODEL = 'BAAI/bge-large-en-v1.5';
@@ -35,6 +37,7 @@ export function parseEmbedArgs(args) {
35
37
  if (a === '--region') { flags.region = args[++i]; i++; continue; }
36
38
  if (a === '--batch-size') { flags.batchSize = parseInt(args[++i], 10); i++; continue; }
37
39
  if (a === '--detach') { flags.detach = true; i++; continue; }
40
+ if (a === '--dry-run') { flags.dryRun = true; i++; continue; }
38
41
  if (a === '--env') {
39
42
  const kv = args[++i]; i++;
40
43
  if (!flags.env) flags.env = [];
@@ -49,15 +52,6 @@ export function parseEmbedArgs(args) {
49
52
  return { model, input, flags };
50
53
  }
51
54
 
52
- function parseEnvFlag(envList) {
53
- const obj = {};
54
- for (const kv of (envList || [])) {
55
- const idx = kv.indexOf('=');
56
- if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
57
- }
58
- return obj;
59
- }
60
-
61
55
  function isUrl(s) {
62
56
  return /^(https?|s3|gs|hf):\/\//i.test(s);
63
57
  }
@@ -120,14 +114,23 @@ export async function embedCommand(config, args, chalk) {
120
114
  ...(flags.batchSize ? { EMBED_BATCH_SIZE: String(flags.batchSize) } : {}),
121
115
  };
122
116
 
123
- console.log(chalk.bold('\n📐 Embeddings\n'));
117
+ console.log(chalk.bold(flags.dryRun ? '\n📐 Dry run — no GPU will be provisioned — Embeddings\n' : '\n📐 Embeddings\n'));
124
118
  console.log(` ${chalk.bold('Input:')} ${resolved.inputLabel}`);
125
119
  console.log(` ${chalk.bold('Model:')} ${model}`);
126
120
  console.log(` ${chalk.bold('Image:')} ${EMBED_IMAGE}`);
127
121
  console.log(` ${chalk.bold('GPU:')} ${gpu === 'AUTO' ? chalk.dim('auto (8+ GB VRAM)') : gpu}`);
122
+ if (flags.dryRun) console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(effectiveTier)}`);
128
123
  console.log(` ${chalk.bold('Max runtime:')} ${maxRuntimeMin}min`);
129
124
  if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
125
+ if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
130
126
  console.log();
127
+
128
+ if (flags.dryRun) {
129
+ await printCapacityPreview(chalk, config, { gpu, region: flags.region?.toUpperCase(), maxPrice: flags.maxPrice });
130
+ console.log(chalk.dim(' Remove --dry-run to submit.\n'));
131
+ return;
132
+ }
133
+
131
134
  process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
132
135
 
133
136
  function buildBody(tierOverride) {
@@ -286,15 +286,20 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
286
286
  const envKey = PROVIDER_ENV_KEYS[spec.provider];
287
287
  const userSuppliedKey = flags.env?.some(kv => kv.startsWith(`${envKey}=`));
288
288
  let credential = userSuppliedKey ? null : getCredential(spec.provider);
289
+ flags.authRequired = { provider: spec.provider, status: credential || userSuppliedKey ? 'connected' : 'missing' };
289
290
  if (!credential && !userSuppliedKey) {
290
291
  // Missing credential no longer forces a separate `badgr connect` +
291
292
  // rerun in an interactive terminal — prompt inline and continue the
292
- // same launch. Non-interactive contexts still hard-fail as before.
293
+ // same launch (including under --dry-run, so the dry-run preview
294
+ // reflects the connected state the real launch will actually use, and
295
+ // a follow-up real launch never needs a second prompt). Non-interactive
296
+ // contexts still hard-fail as before.
293
297
  credential = await resolveMissingCredential(spec.provider, chalk);
294
298
  if (!credential) {
295
299
  process.exitCode = 1;
296
300
  return;
297
301
  }
302
+ flags.authRequired.status = 'connected';
298
303
  }
299
304
  // An explicit --env always wins over the stored credential — never
300
305
  // append the injected default alongside a user-supplied value for the
@@ -304,6 +309,8 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
304
309
  if (!flags.env) flags.env = [];
305
310
  flags.env.push(`${envKey}=${credential}`);
306
311
  }
312
+ } else {
313
+ flags.authRequired = { provider: null, status: 'none' };
307
314
  }
308
315
  // cline has no `provider` — Badgr mints and injects its own short-lived
309
316
  // job-scoped model token server-side (see backend jobs_routes.py); the
@@ -1,36 +1,91 @@
1
- import { input } from '@inquirer/prompts';
2
- import { DEFAULTS } from '../config.js';
1
+ import { DEFAULTS, loadConfig } from '../config.js';
3
2
  import { callApi } from '../api.js';
3
+ import { ensureLoggedIn } from '../onboarding.js';
4
4
 
5
- export async function loginCommand(chalk, saveConfigFn) {
5
+ const API_KEYS_URL = 'https://aibadgr.com/dashboard/api-keys';
6
+
7
+ function parseArgs(args) {
8
+ const flags = {};
9
+ for (let i = 0; i < args.length; i++) {
10
+ if (args[i] === '--key') flags.key = args[++i];
11
+ }
12
+ return flags;
13
+ }
14
+
15
+ /**
16
+ * badgr login — interactive TTY: if a saved key is already
17
+ * valid, reports that and stops (no browser
18
+ * round-trip). Otherwise opens a browser login
19
+ * link and polls until it completes (same flow
20
+ * `badgr run`/`launch`/`serve`/`comfyui` trigger
21
+ * just-in-time — see onboarding.js's
22
+ * ensureLoggedIn).
23
+ * badgr login --key <key> — non-interactive: paste an existing key directly
24
+ * (CI, scripts, or anyone who already has one).
25
+ *
26
+ * The pasted-key path validates against a live API call before saving —
27
+ * a confirmed-invalid key (401/403) is never written to
28
+ * ~/.badgr/config.json. A network failure during validation still saves
29
+ * the key (with a warning), since that failure says nothing about whether
30
+ * the key itself is valid.
31
+ */
32
+ export async function loginCommand(chalk, saveConfigFn, args = []) {
6
33
  console.log(chalk.bold('\nBadgr Login\n'));
7
34
 
8
- const apiKey = await input({
9
- message: 'Enter your Badgr API key:',
10
- validate: v => v.trim() ? true : 'API key is required',
11
- });
35
+ const flags = parseArgs(args);
12
36
 
13
- const config = saveConfigFn({
14
- apiKey: apiKey.trim(),
15
- baseUrl: DEFAULTS.baseUrl,
16
- });
37
+ if (!flags.key) {
38
+ if (process.stdin.isTTY && process.stdout.isTTY) {
39
+ const existing = loadConfig();
40
+ if (existing.apiKey) {
41
+ try {
42
+ await callApi('/models', { apiKey: existing.apiKey, baseUrl: existing.baseUrl });
43
+ console.log(chalk.green('✓ Already logged in'));
44
+ console.log(chalk.dim(` Run ${chalk.cyan('badgr login --key <value>')} to switch accounts.\n`));
45
+ return existing;
46
+ } catch (err) {
47
+ if (err.httpStatus !== 401 && err.httpStatus !== 403) {
48
+ console.log(chalk.yellow(' ⚠ Could not reach the API to confirm the saved key — logging in again.'));
49
+ }
50
+ // Confirmed-invalid or unverifiable — fall through to re-auth below.
51
+ }
52
+ }
17
53
 
18
- console.log(chalk.green('\n✓ Logged in'));
19
- console.log(chalk.dim(` Config saved to ~/.badgr/config.json`));
54
+ const config = await ensureLoggedIn({ ...DEFAULTS }, chalk);
55
+ console.log(chalk.dim(` Run ${chalk.cyan('badgr run python train.py')} to launch your first job.\n`));
56
+ return config;
57
+ }
58
+
59
+ console.error(chalk.red('\n ✗ --key <value> is required in non-interactive mode.\n'));
60
+ console.error(chalk.dim(` Get an API key: ${API_KEYS_URL}`));
61
+ console.error(chalk.dim(' Example: badgr login --key bdgr_...\n'));
62
+ process.exitCode = 1;
63
+ return null;
64
+ }
65
+
66
+ const apiKey = flags.key.trim();
67
+ if (!apiKey) {
68
+ console.error(chalk.red('\n ✗ API key is required.\n'));
69
+ process.exitCode = 1;
70
+ return null;
71
+ }
20
72
 
21
- // Verify the key works against the live API
22
73
  try {
23
- await callApi('/models', { apiKey: config.apiKey, baseUrl: config.baseUrl });
24
- console.log(chalk.green('✓ API reachable'));
25
- console.log(chalk.green('✓ API key valid\n'));
74
+ await callApi('/models', { apiKey, baseUrl: DEFAULTS.baseUrl });
26
75
  } catch (err) {
27
76
  if (err.httpStatus === 401 || err.httpStatus === 403) {
28
- console.log(chalk.yellow(' API key may be invalid double-check and run badgr login again\n'));
29
- } else {
30
- console.log(chalk.yellow('⚠ Could not reach the API right now — check your internet connection\n'));
77
+ console.error(chalk.red('\n ✗ That API key was rejectednot saved.'));
78
+ console.error(chalk.dim(` Get a valid key: ${API_KEYS_URL}\n`));
79
+ process.exitCode = 1;
80
+ return null;
31
81
  }
82
+ console.log(chalk.yellow('\n ⚠ Could not reach the API to verify the key right now — saving anyway.'));
32
83
  }
33
84
 
85
+ const config = saveConfigFn({ apiKey, baseUrl: DEFAULTS.baseUrl });
86
+
87
+ console.log(chalk.green('\n✓ Logged in'));
88
+ console.log(chalk.dim(` Config saved to ~/.badgr/config.json`));
34
89
  console.log(chalk.dim(` Run ${chalk.cyan('badgr run python train.py')} to launch your first job.\n`));
35
90
  return config;
36
91
  }
@@ -8,10 +8,11 @@ import { addReceipt, updateReceipt, generateReceiptId, selectedComputeFromDeploy
8
8
  import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
9
9
  import { formatCliError } from '../errors.js';
10
10
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
11
- import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
11
+ import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
12
12
  import { detectWorkload, workloadTypeLabel } from '../detect.js';
13
13
  import { ensureBadgrReady } from '../onboarding.js';
14
14
  import { VM_CLASSES, parseGbSize } from '../spec.js';
15
+ import { parseEnvFlag } from '../envFlag.js';
15
16
 
16
17
  function vmClassLine(sizeKey) {
17
18
  const vmClass = VM_CLASSES[sizeKey];
@@ -98,15 +99,6 @@ export function parseRunArgs(args) {
98
99
  return { flags, positional, commandArgv };
99
100
  }
100
101
 
101
- function parseEnvFlag(envList) {
102
- const obj = {};
103
- for (const kv of (envList || [])) {
104
- const idx = kv.indexOf('=');
105
- if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
106
- }
107
- return obj;
108
- }
109
-
110
102
  // Heuristic for --env keys that look like secrets — used to warn (not
111
103
  // block) since there is no dashboard --profile injection path yet and
112
104
  // --env is currently the only way to get a provider key into a launch VM.
@@ -458,7 +450,10 @@ async function _zipDirectory(dirPath, chalk) {
458
450
  throw new Error(`Directory not found: ${absDir}`);
459
451
  }
460
452
 
461
- const tmpFile = path.join(os.tmpdir(), `badgr-upload-${Date.now()}.zip`);
453
+ // Random suffix (not just Date.now()) avoids two concurrent zips
454
+ // (e.g. a real upload racing a --dry-run size estimate) colliding on the
455
+ // same filename within the same millisecond.
456
+ const tmpFile = path.join(os.tmpdir(), `badgr-upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.zip`);
462
457
 
463
458
  const { default: archiver } = await import('archiver');
464
459
  await new Promise((resolve, reject) => {
@@ -478,6 +473,18 @@ async function _zipDirectory(dirPath, chalk) {
478
473
  return tmpFile;
479
474
  }
480
475
 
476
+ // Local-only size estimate for a dry run: zips the project exactly like a
477
+ // real upload would, measures it, then deletes the temp file — no network
478
+ // call, no uploadBlob, so `--dry-run` never actually uploads the project.
479
+ async function _estimateUploadSizeMb(dirPath) {
480
+ const tmpFile = await _zipDirectory(dirPath);
481
+ try {
482
+ return (fs.statSync(tmpFile).size / 1024 / 1024).toFixed(1);
483
+ } finally {
484
+ fs.unlinkSync(tmpFile);
485
+ }
486
+ }
487
+
481
488
  export async function _uploadCodeZip(config, dirPath, chalk) {
482
489
  process.stdout.write(chalk.dim(' Packing project...'));
483
490
  const tmpFile = await _zipDirectory(dirPath, chalk);
@@ -775,6 +782,12 @@ export async function runCommand(config, args, chalk, opts = {}) {
775
782
  console.log(` ${chalk.bold(isLaunch || flags.noGpu ? 'Compute:' : 'GPU:')} ${isLaunch || flags.noGpu ? 'CPU VM (no GPU)' : (gpu || chalk.dim('auto'))}`);
776
783
  if (isLaunch && flags.size) console.log(` ${chalk.bold('VM class:')} ${vmClassLine(flags.size)}`);
777
784
  if (isLaunch && quotedRate != null) console.log(` ${chalk.bold('Badgr rate:')} $${quotedRate.toFixed(2)}/hour`);
785
+ if (isLaunch && flags.authRequired?.provider) {
786
+ const authLine = flags.authRequired.status === 'connected'
787
+ ? chalk.green(`${flags.authRequired.provider} connected`)
788
+ : chalk.yellow(`${flags.authRequired.provider} not connected — will prompt (or run: badgr connect ${flags.authRequired.provider})`);
789
+ console.log(` ${chalk.bold('Auth:')} ${authLine}`);
790
+ }
778
791
  if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
779
792
  if (flags.cpu) console.log(` ${chalk.bold('CPU:')} ${flags.cpu} cores`);
780
793
  if (flags.memory) console.log(` ${chalk.bold('Memory:')} ${flags.memory} GB`);
@@ -787,7 +800,27 @@ export async function runCommand(config, args, chalk, opts = {}) {
787
800
  if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
788
801
  if (flags.resumeCmd) console.log(` ${chalk.bold('Resume cmd:')} ${flags.resumeCmd}`);
789
802
  if (flags.artifacts?.length) console.log(` ${chalk.bold('Artifacts:')} ${flags.artifacts.join(', ')}`);
790
- console.log(chalk.dim('\n Remove --dry-run to provision.\n'));
803
+ if (!isLaunch) console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(effectiveTier)}`);
804
+
805
+ // Upload-size estimate is GPU-job-specific (spec: "badgr run ... upload
806
+ // size") and does a real local zip pass — skip it for CPU launches
807
+ // (badgr launch always sources from '.'), where it would add a real,
808
+ // possibly-slow filesystem operation to every dry-run preview for no
809
+ // requested benefit.
810
+ if (isLocalPath && !isLaunch) {
811
+ try {
812
+ const sizeMb = await _estimateUploadSizeMb(path.resolve(firstArg));
813
+ console.log(` ${chalk.bold('Upload size:')} ~${sizeMb} MB (not uploaded)`);
814
+ } catch (err) {
815
+ console.log(chalk.dim(` Upload size: could not estimate (${err.message})`));
816
+ }
817
+ }
818
+
819
+ console.log();
820
+ if (!isLaunch && !flags.noGpu) {
821
+ await printCapacityPreview(chalk, config, { gpu, region: flags.region?.toUpperCase(), maxPrice: flags.maxPrice });
822
+ }
823
+ console.log(chalk.dim(' Remove --dry-run to provision.\n'));
791
824
  return;
792
825
  }
793
826
 
@@ -824,7 +857,6 @@ export async function runCommand(config, args, chalk, opts = {}) {
824
857
  if (flags.artifacts?.length) console.log(` ${chalk.bold('Artifacts:')} ${flags.artifacts.join(', ')}`);
825
858
  console.log();
826
859
 
827
-
828
860
  // Resolve --workspace name → ws_… ID before submitting
829
861
  let resolvedWorkspaceId = flags.workspace ?? null;
830
862
  if (resolvedWorkspaceId && !resolvedWorkspaceId.startsWith('ws_')) {
@@ -22,6 +22,7 @@ import { normalizeGpuType } from '../spec.js';
22
22
  import { monitorBatchJob, fmtRuntime } from '../batch.js';
23
23
  import { runFanOut, DEFAULT_CONCURRENCY } from '../fanout.js';
24
24
  import { callApi } from '../api.js';
25
+ import { printCapacityPreview } from '../progress.js';
25
26
 
26
27
  const DEFAULT_IMAGE = 'python:3.11-slim';
27
28
  const DEFAULT_MAX_RUNTIME_MIN = 60;
@@ -153,9 +154,13 @@ export async function sbatchCommand(config, args, chalk) {
153
154
  const taskIds = isArray ? job.arrayIndices : [null];
154
155
  const concurrency = flags.maxConcurrency ?? DEFAULT_CONCURRENCY;
155
156
 
157
+ const resolvedGpu = job.gpuCount > 0 ? (job.gpuType ? normalizeGpuType(job.gpuType) : 'AUTO') : 'NONE';
158
+
156
159
  if (flags.dryRun) {
160
+ requireApiKey(config);
157
161
  console.log(chalk.dim(` Dry run — no GPU provisioned. Would submit ${taskIds.length} job(s) (max ${Math.min(concurrency, taskIds.length)} concurrent) with:`));
158
- console.log(chalk.dim(` image=${opts.image} tier=${opts.tier} max_cost=$${opts.maxCostUsd} max_runtime=${opts.maxRuntimeMinutes}min\n`));
162
+ console.log(chalk.dim(` image=${opts.image} gpu=${resolvedGpu} tier=${opts.tier} max_cost=$${opts.maxCostUsd} max_runtime=${opts.maxRuntimeMinutes}min\n`));
163
+ if (resolvedGpu !== 'NONE') await printCapacityPreview(chalk, config, { gpu: resolvedGpu, region: opts.region?.toUpperCase(), maxPrice: undefined });
159
164
  return;
160
165
  }
161
166
 
@@ -3,8 +3,10 @@ import { callApi, listDeployments } from '../api.js';
3
3
  import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
4
  import { normalizeTier, callWithFallback } from '../fallback.js';
5
5
  import { formatCliError } from '../errors.js';
6
+ import { requireApiKey } from '../config.js';
7
+ import { parseEnvFlag } from '../envFlag.js';
6
8
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS, isLikelyGatedModel } from '../catalog.js';
7
- import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
9
+ import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
8
10
 
9
11
  const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
10
12
 
@@ -45,6 +47,7 @@ export function parseServeArgs(args) {
45
47
  if (args[i] === '--hf-repo') { flags.hfRepo = args[++i]; i++; continue; }
46
48
  if (args[i] === '--hf-file') { flags.hfFile = args[++i]; i++; continue; }
47
49
  if (args[i] === '--list-aliases') { flags.listAliases = true; i++; continue; }
50
+ if (args[i] === '--dry-run') { flags.dryRun = true; i++; continue; }
48
51
  if (args[i] === '--env') {
49
52
  const kv = args[++i]; i++;
50
53
  if (!flags.env) flags.env = [];
@@ -57,15 +60,6 @@ export function parseServeArgs(args) {
57
60
  return { model, flags };
58
61
  }
59
62
 
60
- function parseEnvFlag(envList) {
61
- const obj = {};
62
- for (const kv of (envList || [])) {
63
- const idx = kv.indexOf('=');
64
- if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
65
- }
66
- return obj;
67
- }
68
-
69
63
  function envObjHasHfToken(envList) {
70
64
  return (envList || []).some(kv => kv.startsWith('HF_TOKEN='));
71
65
  }
@@ -122,6 +116,17 @@ function _detectHealthPath(image) {
122
116
  return null;
123
117
  }
124
118
 
119
+ // Priority: explicit --health-path > llama.cpp → /health > task-specific >
120
+ // vLLM → /models > auto-detect custom image > null. Pure (no `dep` needed),
121
+ // so both the dry-run preview and the real post-launch health check use the
122
+ // exact same resolution and can never show a different path than they poll.
123
+ function _resolveHealthPath({ healthPath, isLlamaCpp, customImage, task }) {
124
+ if (healthPath) return healthPath;
125
+ if (isLlamaCpp) return '/health';
126
+ if (!customImage) return (task === 'transcribe' || task === 'image') ? '/health' : '/models';
127
+ return _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
128
+ }
129
+
125
130
  /**
126
131
  * Wait for the deployment's app-level endpoint to become ready by polling Badgr's
127
132
  * own deployment status (GET /deployments/{id}) — never the RunPod proxy/pod
@@ -211,7 +216,7 @@ const _KNOWN_SERVE_FLAGS = new Set([
211
216
  '--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
212
217
  '--name', '--no-wait', '--max-cost', '--idle-timeout', '--health-path', '--check-nodes',
213
218
  '--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
214
- '--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file',
219
+ '--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file', '--dry-run',
215
220
  ]);
216
221
 
217
222
  function extractFlag(args, flagName) {
@@ -396,7 +401,14 @@ export async function serveCommand(config, args, chalk) {
396
401
  return;
397
402
  }
398
403
 
399
- config = await ensureBadgrReady(config, chalk);
404
+ // A dry run previews the plan only and never provisions or spends anything,
405
+ // so it doesn't need the interactive browser login — just a stored key, to
406
+ // keep its existing fail-fast behavior in non-interactive contexts (tests/CI).
407
+ if (flags.dryRun) {
408
+ requireApiKey(config);
409
+ } else {
410
+ config = await ensureBadgrReady(config, chalk);
411
+ }
400
412
 
401
413
  // ── Validate flags early ───────────────────────────────────────────────────
402
414
  if (flags.count !== undefined && (!Number.isFinite(flags.count) || flags.count < 1)) {
@@ -416,7 +428,7 @@ export async function serveCommand(config, args, chalk) {
416
428
  }
417
429
 
418
430
  // Endpoints bill continuously — require explicit cost control.
419
- if (!flags.maxCost && !flags.persistent) {
431
+ if (!flags.maxCost && !flags.persistent && !flags.dryRun) {
420
432
  const example = model || (customImage ? '--image ...' : '<model>');
421
433
  console.error(chalk.red('\n ✗ Endpoints bill continuously until stopped. Specify a spending limit:\n'));
422
434
  console.error(chalk.dim(` --max-cost 5 auto-stop when $5 is reached`));
@@ -469,8 +481,15 @@ export async function serveCommand(config, args, chalk) {
469
481
  if (flags.gpu) headerLines.push(['GPU', gpuLabel]);
470
482
  if (flags.task) headerLines.push(['Task', flags.task]);
471
483
  if (flags.env?.length) headerLines.push(['Env', flags.env.join(', ')]);
484
+ if (flags.dryRun) {
485
+ headerLines.push(['Tier', formatTierLabel(effectiveTier)]);
486
+ const previewHealthPath = _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
487
+ headerLines.push(['Health path', previewHealthPath || chalk.dim('none (custom image, unset)')]);
488
+ }
472
489
 
473
- console.log(chalk.bold(`\n⚡ Serving ${title}\n`));
490
+ console.log(chalk.bold(flags.dryRun
491
+ ? `\n⚡ Dry run — no GPU will be provisioned — ${title}\n`
492
+ : `\n⚡ Serving ${title}\n`));
474
493
  const labelWidth = Math.max(...headerLines.map(([label]) => label.length)) + 1;
475
494
  for (const [label, value] of headerLines) {
476
495
  console.log(` ${chalk.bold(`${label}:`.padEnd(labelWidth + 1))}${value}`);
@@ -488,6 +507,16 @@ export async function serveCommand(config, args, chalk) {
488
507
  }
489
508
  console.log();
490
509
 
510
+ if (flags.dryRun) {
511
+ await printCapacityPreview(chalk, config, {
512
+ gpu,
513
+ region: flags.region ? flags.region.toUpperCase() : undefined,
514
+ maxPrice: flags.maxPrice,
515
+ });
516
+ console.log(chalk.dim(' Remove --dry-run to provision.\n'));
517
+ return;
518
+ }
519
+
491
520
  const STAGE_TOTAL = 5;
492
521
  let stageN = 1;
493
522
 
@@ -620,22 +649,7 @@ export async function serveCommand(config, args, chalk) {
620
649
  }
621
650
 
622
651
  // ── Determine health check path ───────────────────────────────────────────
623
- // Priority: explicit --health-path > llama.cpp /health > task-specific > vLLM → /models > auto-detect custom image > null
624
- let resolvedHealthPath;
625
- if (flags.healthPath) {
626
- resolvedHealthPath = flags.healthPath;
627
- } else if (isLlamaCpp) {
628
- resolvedHealthPath = '/health';
629
- } else if (!customImage) {
630
- // Managed runtimes for transcribe/image expose /health; vLLM (chat, embed) uses /models
631
- if (flags.task === 'transcribe' || flags.task === 'image') {
632
- resolvedHealthPath = '/health';
633
- } else {
634
- resolvedHealthPath = '/models';
635
- }
636
- } else {
637
- resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
638
- }
652
+ const resolvedHealthPath = _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
639
653
 
640
654
  // Gated-model guidance is only shown when it's actually needed — on failure —
641
655
  // not up front, so common launches stay short and uncluttered.
@@ -10,8 +10,9 @@ import { requireApiKey } from '../config.js';
10
10
  import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
11
11
  import { normalizeTier, callWithFallback } from '../fallback.js';
12
12
  import { monitorBatchJob, fmtRuntime } from '../batch.js';
13
- import { pollJobUntilTerminal, renderJobClosingBlock } from '../progress.js';
13
+ import { pollJobUntilTerminal, renderJobClosingBlock, printCapacityPreview, formatTierLabel } from '../progress.js';
14
14
  import { uploadBlob } from '../api.js';
15
+ import { parseEnvFlag } from '../envFlag.js';
15
16
 
16
17
  const MAX_CONFIG_B = 512 * 1024; // 512 KB config limit
17
18
 
@@ -64,15 +65,6 @@ export function parseTrainArgs(args) {
64
65
  return { configFile: positional[0] || null, flags };
65
66
  }
66
67
 
67
- function parseEnvFlag(envList) {
68
- const obj = {};
69
- for (const kv of (envList || [])) {
70
- const idx = kv.indexOf('=');
71
- if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
72
- }
73
- return obj;
74
- }
75
-
76
68
  /**
77
69
  * Detect training framework from config content.
78
70
  * Returns 'axolotl' | 'unsloth' | 'trl' | 'generic'.
@@ -155,20 +147,24 @@ export async function trainLoraCommand(config, args, chalk) {
155
147
  if (flags.dryRun) {
156
148
  const preset = flags.preset || 'small';
157
149
  const presetInfo = LORA_PRESET_INFO[preset];
150
+ const resolvedGpu = flags.gpuType || presetInfo?.gpu_type;
158
151
  console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
159
152
  console.log(` ${chalk.bold('Base model:')} ${flags.baseModel}`);
160
153
  console.log(` ${chalk.bold('Dataset:')} ${flags.fileId || flags.dataset || chalk.dim('(none given)')}`);
161
154
  console.log(` ${chalk.bold('Preset:')} ${preset}${presetInfo ? '' : chalk.yellow(' (unknown — server will reject this)')}`);
162
155
  if (presetInfo) {
163
- console.log(` ${chalk.bold('GPU:')} ${flags.gpuType || presetInfo.gpu_type}`);
156
+ console.log(` ${chalk.bold('GPU:')} ${resolvedGpu}`);
164
157
  console.log(` ${chalk.bold('LoRA rank:')} ${presetInfo.rank}`);
165
158
  console.log(` ${chalk.bold('Epochs:')} ${presetInfo.epochs}`);
166
159
  console.log(` ${chalk.dim(presetInfo.description)}`);
167
160
  }
161
+ console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(normalizeTier(flags.tier))}`);
168
162
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
169
163
  console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime ?? 240}min`);
170
164
  if (flags.resume) console.log(` ${chalk.bold('Resume from:')} ${flags.resume}`);
171
- console.log(chalk.dim('\n Remove --dry-run to submit (local file datasets are uploaded first).\n'));
165
+ console.log();
166
+ if (resolvedGpu) await printCapacityPreview(chalk, config, { gpu: resolvedGpu });
167
+ console.log(chalk.dim(' Remove --dry-run to submit (local file datasets are uploaded first).\n'));
172
168
  return;
173
169
  }
174
170