badgr-cli 1.0.48 → 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 -28
  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
@@ -0,0 +1,138 @@
1
+ // Classifies vLLM / GPU workload log text into a likely failure category.
2
+ // Read-only: takes a string, returns a classification. Never touches the log file's source process.
3
+
4
+ import { redactLine } from './redact.js';
5
+
6
+ const RULES = [
7
+ {
8
+ category: 'vram_oom',
9
+ label: 'VRAM / model fit',
10
+ patterns: [/CUDA out of memory/i, /torch\.cuda\.OutOfMemoryError/i, /CUDA error: out of memory/i, /OutOfMemoryError/i],
11
+ fixes: [
12
+ 'Lower --max-model-len (context length)',
13
+ 'Reduce concurrency / batch size',
14
+ 'Lower --gpu-memory-utilization',
15
+ 'Use quantized weights (AWQ/GPTQ/FP8/4bit)',
16
+ 'Use a larger GPU',
17
+ ],
18
+ },
19
+ {
20
+ category: 'nccl_distributed',
21
+ label: 'NCCL / distributed setup',
22
+ patterns: [/NCCL (timeout|error|WARN)/i, /Timeout.*(NCCL|process group)/i, /torch\.distributed.*timeout/i, /Watchdog caught collective operation timeout/i],
23
+ fixes: [
24
+ 'Check NCCL_SOCKET_IFNAME / network interface configuration',
25
+ 'Verify all GPUs/nodes can reach each other',
26
+ 'Reduce tensor-parallel or pipeline-parallel size',
27
+ 'Increase the NCCL timeout',
28
+ ],
29
+ },
30
+ {
31
+ category: 'cuda_pytorch_mismatch',
32
+ label: 'CUDA / PyTorch mismatch',
33
+ patterns: [/CUDA error: no kernel image/i, /CUDA driver version is insufficient/i, /Torch not compiled with CUDA/i, /illegal memory access/i, /CUDA error:/i, /CUDA initialization error/i],
34
+ fixes: [
35
+ 'Verify torch was installed with a CUDA build matching the driver',
36
+ 'Match the container CUDA image to the host driver version',
37
+ 'Reinstall torch for the correct CUDA runtime',
38
+ ],
39
+ },
40
+ {
41
+ category: 'missing_model',
42
+ label: 'Missing model / weights',
43
+ patterns: [/Repository Not Found/i, /404 Client Error/i, /model.*not found/i, /No such file or directory.*\.(safetensors|bin|gguf|ckpt)/i, /is not a local folder and is not a valid model identifier/i],
44
+ fixes: [
45
+ 'Verify the model repo ID or local path',
46
+ 'Check the Hugging Face token / access for gated models',
47
+ 'Confirm the weights finished downloading',
48
+ ],
49
+ },
50
+ {
51
+ category: 'missing_dependency',
52
+ label: 'Missing dependency',
53
+ patterns: [/ModuleNotFoundError/i, /ImportError/i, /No module named/i],
54
+ fixes: [
55
+ 'Install the missing Python package',
56
+ 'Verify the image/environment includes all required dependencies',
57
+ ],
58
+ },
59
+ {
60
+ category: 'health_check',
61
+ label: 'Health check / endpoint readiness',
62
+ patterns: [/health check failed/i, /readiness probe failed/i, /connection refused/i, /port .*not responding/i, /address already in use/i],
63
+ fixes: [
64
+ 'Give the server more time to start (model load can take minutes)',
65
+ 'Verify the port is exposed',
66
+ 'Check the logs for a crash during startup',
67
+ ],
68
+ },
69
+ {
70
+ category: 'provider_pod_issue',
71
+ label: 'Provider / pod issue',
72
+ patterns: [/pod (terminated|preempted|evicted)/i, /instance (reclaimed|terminated|preempted)/i, /spot instance.*(reclaimed|terminated)/i],
73
+ fixes: [
74
+ 'Retry on a non-spot / on-demand instance',
75
+ 'Check the provider status page',
76
+ 'Try `badgr serve` for managed provisioning with automatic failover',
77
+ ],
78
+ },
79
+ {
80
+ category: 'disk_cache',
81
+ label: 'Disk / cache',
82
+ patterns: [/No space left on device/i, /disk quota exceeded/i, /ENOSPC/],
83
+ fixes: [
84
+ 'Free up disk space',
85
+ 'Point HF_HOME to a larger volume',
86
+ 'Clear the Hugging Face cache',
87
+ ],
88
+ },
89
+ ];
90
+
91
+ // Secondary signals that corroborate a failure but aren't a category on their own.
92
+ const ENGINE_SIGNALS = [/EngineDeadError/i, /worker died/i, /model failed to load/i];
93
+
94
+ function extractLine(text, pattern) {
95
+ const line = text.split(/\r?\n/).find((l) => pattern.test(l));
96
+ return line ? redactLine(line.trim().slice(0, 200)) : null;
97
+ }
98
+
99
+ /**
100
+ * Classify log text into one of: vram_oom, cuda_pytorch_mismatch, missing_model,
101
+ * missing_dependency, nccl_distributed, health_check, provider_pod_issue,
102
+ * disk_cache, unknown.
103
+ */
104
+ export function classifyLog(text) {
105
+ const matches = [];
106
+ for (const rule of RULES) {
107
+ const hitPattern = rule.patterns.find((p) => p.test(text));
108
+ if (hitPattern) {
109
+ matches.push({ category: rule.category, label: rule.label, fixes: rule.fixes, evidenceLine: extractLine(text, hitPattern) });
110
+ }
111
+ }
112
+
113
+ const engineEvidence = ENGINE_SIGNALS.map((p) => extractLine(text, p)).filter(Boolean);
114
+
115
+ if (matches.length === 0) {
116
+ return {
117
+ matched: false,
118
+ category: 'unknown',
119
+ label: 'Unknown failure',
120
+ fixes: ['Share the last 50 lines of the log for a closer look', 'Search the error text directly'],
121
+ evidenceLines: engineEvidence,
122
+ allCategories: [],
123
+ };
124
+ }
125
+
126
+ // VRAM/OOM is the most actionable, common failure — prefer it when present.
127
+ const primary = matches.find((m) => m.category === 'vram_oom') || matches[0];
128
+ const evidenceLines = [...new Set([primary.evidenceLine, ...engineEvidence].filter(Boolean))];
129
+
130
+ return {
131
+ matched: true,
132
+ category: primary.category,
133
+ label: primary.label,
134
+ fixes: primary.fixes,
135
+ evidenceLines,
136
+ allCategories: [...new Set(matches.map((m) => m.category))],
137
+ };
138
+ }
@@ -0,0 +1,107 @@
1
+ // Simple known-model/size heuristics — not perfect introspection.
2
+ // All numbers here are estimates; callers should present them as
3
+ // "likely" / "estimated" / "recommended", never as guarantees.
4
+
5
+ const BYTES_PER_PARAM = {
6
+ fp16: 2,
7
+ bf16: 2,
8
+ fp8: 1,
9
+ awq: 0.6,
10
+ gptq: 0.6,
11
+ '4bit': 0.55,
12
+ gguf: 0.6,
13
+ };
14
+
15
+ export function estimateModelParamsB(model) {
16
+ const m = String(model).match(/(\d+(?:\.\d+)?)\s*[bB](?:[-_]|$)/);
17
+ return m ? Number(m[1]) : null;
18
+ }
19
+
20
+ export function detectQuantization(model) {
21
+ const s = String(model);
22
+ if (/awq/i.test(s)) return 'awq';
23
+ if (/gptq/i.test(s)) return 'gptq';
24
+ if (/fp8/i.test(s)) return 'fp8';
25
+ if (/gguf/i.test(s)) return 'gguf';
26
+ if (/(^|[-_./])4bit|int4|[-_]q4/i.test(s)) return '4bit';
27
+ return null;
28
+ }
29
+
30
+ export function sizeClassLabel(paramsB) {
31
+ if (paramsB == null) return 'unknown';
32
+ if (paramsB >= 65) return '70B-class';
33
+ if (paramsB >= 28) return '32B-class';
34
+ if (paramsB >= 13) return '14B-class';
35
+ if (paramsB >= 7.5) return '8B-class';
36
+ if (paramsB >= 5) return '7B-class';
37
+ return `${paramsB}B-class`;
38
+ }
39
+
40
+ export function recommendedGpuClassForVram(vramGb) {
41
+ if (vramGb == null) return 'unknown — could not estimate required VRAM';
42
+ if (vramGb <= 16) return '16GB+ class (e.g. RTX 4080, L4)';
43
+ if (vramGb <= 24) return '24GB+ class (e.g. RTX 4090, RTX 3090)';
44
+ if (vramGb <= 48) return '48GB class (e.g. L40S, A6000)';
45
+ if (vramGb <= 80) return '80GB class (e.g. A100 80GB, H100)';
46
+ return '80GB+ class or multi-GPU tensor-parallel route';
47
+ }
48
+
49
+ /**
50
+ * Rough VRAM range estimate for a model label. Not exact — parameter count
51
+ * is inferred from the name, weights are sized by quantization, and a
52
+ * generous KV-cache/runtime overhead is layered on top.
53
+ *
54
+ * `gpuCount` models a tensor-parallel split across N GPUs: weights are
55
+ * assumed to shard evenly, while the KV-cache/context overhead is charged
56
+ * per GPU (every rank still needs its own activation/KV headroom). Returns
57
+ * both the per-GPU figures (what a single card in the group needs — use
58
+ * this to compare against one GPU's free VRAM) and the total across all
59
+ * GPUs (for reporting the whole group's footprint).
60
+ */
61
+ export function estimateModelFit(model, { contextLen = 8192, gpuCount = 1 } = {}) {
62
+ const paramsB = estimateModelParamsB(model);
63
+ const quant = detectQuantization(model);
64
+ const bytesPerParam = quant ? BYTES_PER_PARAM[quant] : BYTES_PER_PARAM.fp16;
65
+ const safeGpuCount = Math.max(1, Math.round(gpuCount) || 1);
66
+
67
+ if (paramsB == null) {
68
+ return {
69
+ paramsB: null,
70
+ quant,
71
+ sizeClass: 'unknown',
72
+ gpuCount: safeGpuCount,
73
+ vramMinGb: null,
74
+ vramMaxGb: null,
75
+ perGpuVramMinGb: null,
76
+ perGpuVramMaxGb: null,
77
+ recommendedGpuClass: recommendedGpuClassForVram(null),
78
+ quantSuggestion: 'could not infer parameter count from the model name — pass --vram-gb to size it manually',
79
+ };
80
+ }
81
+
82
+ const weightsGb = paramsB * bytesPerParam;
83
+ const contextOverheadGb = (contextLen / 8192) * 1.5;
84
+ const vramMinGb = Math.ceil(weightsGb * 1.15);
85
+ const vramMaxGb = Math.ceil(weightsGb * 1.45 + contextOverheadGb);
86
+ const perGpuVramMinGb = Math.ceil(weightsGb * 1.15 / safeGpuCount);
87
+ const perGpuVramMaxGb = Math.ceil((weightsGb * 1.45) / safeGpuCount + contextOverheadGb);
88
+
89
+ const quantSuggestion = quant
90
+ ? `already quantized (${quant})`
91
+ : perGpuVramMaxGb > 24
92
+ ? 'quantized weights (AWQ/GPTQ/FP8/4bit) would reduce the footprint'
93
+ : 'quantization optional at this size';
94
+
95
+ return {
96
+ paramsB,
97
+ quant,
98
+ sizeClass: sizeClassLabel(paramsB),
99
+ gpuCount: safeGpuCount,
100
+ vramMinGb,
101
+ vramMaxGb,
102
+ perGpuVramMinGb,
103
+ perGpuVramMaxGb,
104
+ recommendedGpuClass: recommendedGpuClassForVram(perGpuVramMaxGb),
105
+ quantSuggestion,
106
+ };
107
+ }
@@ -0,0 +1,38 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ // Short-lived, best-effort cache for the expensive nvidia-smi / python-torch
6
+ // probes so repeated `badgr doctor` runs (e.g. while iterating on a fix)
7
+ // don't re-pay a multi-second `python -c "import torch"` cost every time.
8
+ // Never required for correctness: any read/write failure degrades silently
9
+ // to "just run the probe again".
10
+
11
+ const DEFAULT_CACHE_PATH = path.join(os.tmpdir(), 'badgr-doctor-probe-cache.json');
12
+ const DEFAULT_TTL_MS = 10_000;
13
+
14
+ export function readProbeCache(key, { ttlMs = DEFAULT_TTL_MS, cachePath = DEFAULT_CACHE_PATH, fsImpl = fs } = {}) {
15
+ try {
16
+ const store = JSON.parse(fsImpl.readFileSync(cachePath, 'utf8'));
17
+ const entry = store[key];
18
+ if (entry && Date.now() - entry.ts < ttlMs) return entry.value;
19
+ } catch {
20
+ // no cache file yet, corrupt contents, or unreadable — treat as a miss
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ export function writeProbeCache(key, value, { cachePath = DEFAULT_CACHE_PATH, fsImpl = fs } = {}) {
26
+ let store = {};
27
+ try {
28
+ store = JSON.parse(fsImpl.readFileSync(cachePath, 'utf8'));
29
+ } catch {
30
+ // start a fresh store
31
+ }
32
+ store[key] = { ts: Date.now(), value };
33
+ try {
34
+ fsImpl.writeFileSync(cachePath, JSON.stringify(store));
35
+ } catch {
36
+ // best-effort — a failed cache write must never break a diagnosis
37
+ }
38
+ }
@@ -0,0 +1,29 @@
1
+ // Best-effort redaction for evidence lines pulled from user logs/URLs before
2
+ // they're echoed back in the report. Not a guarantee — callers should still
3
+ // avoid piping untrusted logs to shared output — but it strips the common
4
+ // cases: API tokens, Authorization headers, credentials embedded in URLs,
5
+ // home directory usernames, and email addresses.
6
+
7
+ const PATTERNS = [
8
+ [/\bhf_[A-Za-z0-9]{10,}\b/g, 'hf_[REDACTED]'],
9
+ [/\bsk-[A-Za-z0-9]{10,}\b/g, 'sk-[REDACTED]'],
10
+ [/\bghp_[A-Za-z0-9]{10,}\b/g, 'ghp_[REDACTED]'],
11
+ [/\bAKIA[0-9A-Z]{12,}\b/g, 'AKIA[REDACTED]'],
12
+ [/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, 'Bearer [REDACTED]'],
13
+ [/(Authorization:\s*)\S+/gi, '$1[REDACTED]'],
14
+ [/(api[_-]?key["'=:\s]+)[A-Za-z0-9\-._]{8,}/gi, '$1[REDACTED]'],
15
+ [/(token["'=:\s]+)[A-Za-z0-9\-._]{8,}/gi, '$1[REDACTED]'],
16
+ [/(password["'=:\s]+)\S+/gi, '$1[REDACTED]'],
17
+ [/:\/\/([^:@/\s]+):([^:@/\s]+)@/g, '://[REDACTED]:[REDACTED]@'],
18
+ [/\/(home|Users)\/([^/\s]+)/g, '/$1/[user]'],
19
+ [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[email]'],
20
+ ];
21
+
22
+ export function redactLine(line) {
23
+ if (typeof line !== 'string' || !line) return line;
24
+ let out = line;
25
+ for (const [pattern, replacement] of PATTERNS) {
26
+ out = out.replace(pattern, replacement);
27
+ }
28
+ return out;
29
+ }
@@ -0,0 +1,61 @@
1
+ import { execFileSync } from 'child_process';
2
+
3
+ // Read-only probe script: imports torch (if present) and reports what it sees.
4
+ // Never writes, downloads, or mutates anything.
5
+ const PROBE = `
6
+ import json
7
+ result = {"torchInstalled": False, "cudaAvailable": False, "cudaVersion": None, "deviceName": None, "deviceCount": 0, "freeVramGb": None, "totalVramGb": None, "error": None}
8
+ try:
9
+ import torch
10
+ result["torchInstalled"] = True
11
+ result["cudaAvailable"] = bool(torch.cuda.is_available())
12
+ result["cudaVersion"] = torch.version.cuda
13
+ if result["cudaAvailable"]:
14
+ result["deviceCount"] = torch.cuda.device_count()
15
+ if result["deviceCount"] > 0:
16
+ result["deviceName"] = torch.cuda.get_device_name(0)
17
+ try:
18
+ free_b, total_b = torch.cuda.mem_get_info(0)
19
+ result["freeVramGb"] = round(free_b / (1024 ** 3), 1)
20
+ result["totalVramGb"] = round(total_b / (1024 ** 3), 1)
21
+ except Exception:
22
+ pass
23
+ except Exception as e:
24
+ result["error"] = str(e)
25
+ print(json.dumps(result))
26
+ `;
27
+
28
+ /**
29
+ * Read-only python/torch probe. Never installs or downloads anything.
30
+ */
31
+ export function detectTorch(execImpl = execFileSync) {
32
+ let pythonAvailable = false;
33
+ for (const bin of ['python3', 'python']) {
34
+ let out;
35
+ try {
36
+ out = execImpl(bin, ['-c', PROBE], { encoding: 'utf8', timeout: 15000 });
37
+ } catch (err) {
38
+ if (err && err.code === 'ENOENT') continue;
39
+ pythonAvailable = true;
40
+ continue;
41
+ }
42
+ pythonAvailable = true;
43
+ try {
44
+ const lastLine = out.trim().split(/\r?\n/).pop();
45
+ const parsed = JSON.parse(lastLine);
46
+ return { pythonAvailable: true, pythonBinary: bin, ...parsed };
47
+ } catch {
48
+ continue;
49
+ }
50
+ }
51
+ return {
52
+ pythonAvailable,
53
+ pythonBinary: null,
54
+ torchInstalled: false,
55
+ cudaAvailable: false,
56
+ cudaVersion: null,
57
+ deviceName: null,
58
+ deviceCount: 0,
59
+ error: pythonAvailable ? 'could not read torch probe output' : 'python not found',
60
+ };
61
+ }
@@ -0,0 +1,96 @@
1
+ // Parses a ComfyUI workflow JSON (API-export or UI-export format) just
2
+ // enough to surface model references, non-core custom nodes, and a rough
3
+ // workload class / VRAM bucket. Read-only — never touches the Comfy install.
4
+
5
+ const KNOWN_BASE_CLASSES = new Set([
6
+ 'CheckpointLoaderSimple', 'CheckpointLoader', 'CLIPTextEncode', 'KSampler', 'KSamplerAdvanced',
7
+ 'VAEDecode', 'VAEEncode', 'EmptyLatentImage', 'SaveImage', 'LoadImage', 'LoraLoader',
8
+ 'LoraLoaderModelOnly', 'ControlNetLoader', 'ControlNetApply', 'ControlNetApplyAdvanced',
9
+ 'UpscaleModelLoader', 'ImageUpscaleWithModel', 'VAELoader', 'CLIPLoader', 'DualCLIPLoader',
10
+ 'UNETLoader', 'ConditioningCombine', 'ConditioningZeroOut', 'ImageScale', 'PreviewImage',
11
+ 'LatentUpscale', 'CLIPSetLastLayer', 'CLIPVisionLoader', 'CLIPVisionEncode', 'StyleModelLoader',
12
+ 'GLIGENLoader', 'DiffControlNetLoader', 'ImagePadForOutpaint', 'LatentComposite', 'LatentBlend',
13
+ 'Note', 'Reroute', 'PrimitiveNode',
14
+ ]);
15
+
16
+ const MODEL_FIELD_KEYS = ['ckpt_name', 'vae_name', 'lora_name', 'unet_name', 'clip_name', 'control_net_name', 'model_name', 'style_model_name'];
17
+ const MODEL_FILE_PATTERN = /\.(safetensors|ckpt|pt|bin|gguf|pth)$/i;
18
+
19
+ function iterNodes(workflow) {
20
+ if (Array.isArray(workflow?.nodes)) {
21
+ // UI-export format: { nodes: [{ type, widgets_values }], ... }
22
+ return workflow.nodes.map((n) => ({ classType: n.type, inputs: n.widgets_values }));
23
+ }
24
+ if (workflow && typeof workflow === 'object') {
25
+ // API-export format: { "1": { class_type, inputs }, "2": {...}, ... }
26
+ return Object.values(workflow)
27
+ .filter((n) => n && typeof n === 'object' && n.class_type)
28
+ .map((n) => ({ classType: n.class_type, inputs: n.inputs }));
29
+ }
30
+ return [];
31
+ }
32
+
33
+ export function parseWorkflowFile(raw) {
34
+ return JSON.parse(raw);
35
+ }
36
+
37
+ /**
38
+ * Best-effort ComfyUI workflow diagnosis. Not a full parser or node
39
+ * validator — just enough signal to name a likely workload class and
40
+ * flag anything that might be a missing model or custom node.
41
+ */
42
+ export function diagnoseWorkflow(workflow) {
43
+ const nodes = iterNodes(workflow);
44
+ const models = new Set();
45
+ const customNodes = new Set();
46
+ const classTypes = new Set();
47
+
48
+ for (const node of nodes) {
49
+ if (!node.classType) continue;
50
+ classTypes.add(node.classType);
51
+ if (!KNOWN_BASE_CLASSES.has(node.classType)) customNodes.add(node.classType);
52
+
53
+ if (node.inputs && typeof node.inputs === 'object' && !Array.isArray(node.inputs)) {
54
+ for (const key of MODEL_FIELD_KEYS) {
55
+ const val = node.inputs[key];
56
+ if (typeof val === 'string' && val.trim()) models.add(val.trim());
57
+ }
58
+ } else if (Array.isArray(node.inputs)) {
59
+ for (const val of node.inputs) {
60
+ if (typeof val === 'string' && MODEL_FILE_PATTERN.test(val)) models.add(val.trim());
61
+ }
62
+ }
63
+ }
64
+
65
+ const signalText = [...models, ...classTypes].join(' ').toLowerCase();
66
+ let likelyClass = 'unknown';
67
+ let vramBucket = '8–12GB+ (rough estimate)';
68
+
69
+ if (/flux/.test(signalText)) {
70
+ likelyClass = 'Flux';
71
+ vramBucket = '24GB+ (Flux dev/schnell)';
72
+ } else if (/hunyuan/.test(signalText)) {
73
+ likelyClass = 'Hunyuan Video';
74
+ vramBucket = '24–48GB+ (video)';
75
+ } else if (/\b(wan2?|ltx|svd|animatediff)\b/.test(signalText) || /video/.test(signalText)) {
76
+ likelyClass = 'video workflow';
77
+ vramBucket = '16–24GB+ (video)';
78
+ } else if (/xl|sdxl/.test(signalText)) {
79
+ likelyClass = 'SDXL';
80
+ vramBucket = '12–16GB+';
81
+ } else if (/sd[-_]?(1\.5|15)|v1-5/.test(signalText)) {
82
+ likelyClass = 'SD 1.5';
83
+ vramBucket = '6–8GB+';
84
+ } else if (nodes.length === 0) {
85
+ likelyClass = 'unrecognized (no nodes parsed)';
86
+ vramBucket = 'unknown';
87
+ }
88
+
89
+ return {
90
+ nodeCount: nodes.length,
91
+ likelyClass,
92
+ vramBucket,
93
+ models: [...models],
94
+ customNodes: [...customNodes],
95
+ };
96
+ }
@@ -0,0 +1,124 @@
1
+ import { callApi } from './api.js';
2
+ import { saveConfig, requireApiKey } from './config.js';
3
+ import { openBrowser } from './browser.js';
4
+
5
+ /**
6
+ * Just-in-time login + funding — the first meaningful action a new user
7
+ * takes should be `badgr launch ...` (or `badgr comfyui`/`badgr serve`/etc.),
8
+ * not a separate `badgr login`/`badgr billing add` onboarding ritual.
9
+ *
10
+ * `ensureBadgrReady(config, chalk)` is the shared pre-flight check every
11
+ * provisioning command should call before uploading anything or hitting the
12
+ * API: if not logged in, it opens a browser to a one-time login link and
13
+ * polls until the CLI is authorized; if logged in with a $0 balance, it
14
+ * opens the billing checkout and polls until funded. Returns the (possibly
15
+ * updated) config to use for the rest of the command.
16
+ *
17
+ * Non-interactive environments (CI, no TTY) skip straight to the old
18
+ * hard-fail behavior (`requireApiKey`) instead of opening a browser that
19
+ * will never be clicked — there is no useful "wait for a human" story there.
20
+ */
21
+
22
+ const POLL_INTERVAL_MS = 2000;
23
+ const SESSION_POLL_TIMEOUT_MS = 10 * 60 * 1000; // matches the backend session TTL
24
+ const BILLING_POLL_TIMEOUT_MS = 10 * 60 * 1000;
25
+ const BILLING_URL = 'https://aibadgr.com/dashboard#billing';
26
+
27
+ function sleep(ms) {
28
+ return new Promise(resolve => setTimeout(resolve, ms));
29
+ }
30
+
31
+ function apiBase(config) {
32
+ return config.baseUrl.replace(/\/v1\/?$/, '').replace(/\/api\/v1\/?$/, '');
33
+ }
34
+
35
+ async function pollCliSession(config, sessionId) {
36
+ const start = Date.now();
37
+ while (Date.now() - start < SESSION_POLL_TIMEOUT_MS) {
38
+ await sleep(POLL_INTERVAL_MS);
39
+ let status;
40
+ try {
41
+ status = await callApi(`/cli/session/${sessionId}`, { apiKey: '', baseUrl: config.baseUrl });
42
+ } catch {
43
+ continue; // transient network hiccup — keep polling until the timeout
44
+ }
45
+ if (status.status === 'complete') return status;
46
+ if (status.status === 'expired') {
47
+ throw new Error('Login link expired before it was completed. Run `badgr launch ...` again, or `badgr login` directly.');
48
+ }
49
+ }
50
+ throw new Error('Timed out waiting for login in the browser. Run `badgr login` directly.');
51
+ }
52
+
53
+ async function ensureLoggedIn(config, chalk) {
54
+ console.log(chalk.dim("\n You're not logged in to Badgr."));
55
+ console.log(chalk.dim(' Badgr uses prepaid credits to pay for the VM and model usage.\n'));
56
+
57
+ let session;
58
+ try {
59
+ session = await callApi('/cli/session', { method: 'POST', apiKey: '', baseUrl: config.baseUrl });
60
+ } catch (err) {
61
+ console.error(chalk.red(`\n ✗ Could not start login: ${err.message}\n`));
62
+ requireApiKey(config); // falls through to the standard "Run: badgr login" error
63
+ return config;
64
+ }
65
+
66
+ console.log(chalk.dim(` Opening ${session.login_url} ...`));
67
+ openBrowser(session.login_url);
68
+
69
+ const result = await pollCliSession(config, session.session_id);
70
+ const updated = saveConfig({ apiKey: result.api_key });
71
+ console.log(chalk.green('\n ✓ CLI connected'));
72
+ console.log(chalk.green(` ✓ Balance: $${(result.credits ?? 0).toFixed(2)}\n`));
73
+ return updated;
74
+ }
75
+
76
+ async function pollBalance(config) {
77
+ const start = Date.now();
78
+ while (Date.now() - start < BILLING_POLL_TIMEOUT_MS) {
79
+ await sleep(POLL_INTERVAL_MS);
80
+ try {
81
+ const me = await callApi('/api/me', { apiKey: config.apiKey, baseUrl: apiBase(config) });
82
+ if ((me.credits ?? 0) > 0) return;
83
+ } catch {
84
+ continue;
85
+ }
86
+ }
87
+ throw new Error('Timed out waiting for payment. Run `badgr billing add 10` directly.');
88
+ }
89
+
90
+ async function ensureFunded(config, chalk) {
91
+ let me;
92
+ try {
93
+ me = await callApi('/api/me', { apiKey: config.apiKey, baseUrl: apiBase(config) });
94
+ } catch {
95
+ // Best-effort UX nicety, not the spend-cap enforcement point — the
96
+ // per-request insufficient_balance error path still protects spend if
97
+ // this check itself can't reach the API.
98
+ return;
99
+ }
100
+ if ((me.credits ?? 0) > 0) return;
101
+
102
+ console.log(chalk.yellow('\n Your Badgr balance is $0.00.'));
103
+ console.log(chalk.dim(' Credits pay for: the disposable VM, Badgr model usage, artifact storage and transfer.\n'));
104
+ console.log(chalk.dim(` Opening ${BILLING_URL} ...`));
105
+ openBrowser(BILLING_URL);
106
+
107
+ await pollBalance(config);
108
+ console.log(chalk.green('\n ✓ Payment confirmed\n'));
109
+ }
110
+
111
+ export async function ensureBadgrReady(config, chalk) {
112
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
113
+ // No human to click a browser link — fail fast with the existing message.
114
+ requireApiKey(config);
115
+ return config;
116
+ }
117
+
118
+ let cfg = config;
119
+ if (!cfg.apiKey) {
120
+ cfg = await ensureLoggedIn(cfg, chalk);
121
+ }
122
+ await ensureFunded(cfg, chalk);
123
+ return cfg;
124
+ }