badgr-cli 1.0.32 → 1.0.34

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/src/errors.js ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Unified CLI error catalog — single source of truth for all user-facing errors.
3
+ *
4
+ * Each entry defines:
5
+ * message — what happened (user-safe, no provider/host details)
6
+ * billing — whether billing started, stopped, or is uncertain
7
+ * retried — whether Badgr automatically retried before surfacing this error
8
+ * hint — concrete next step(s) for the user
9
+ * severity — P1/P2/P3/P4 for backend incident tracking
10
+ *
11
+ * Use formatCliError() to render entries as multiline CLI output.
12
+ * Pass internal-only fields (provider names, raw errors) via the `internal`
13
+ * argument — they are only shown when BADGR_DEBUG=1.
14
+ */
15
+
16
+ export const CATALOG = {
17
+ // ── Capacity ──────────────────────────────────────────────────────────────
18
+
19
+ NO_CAPACITY: {
20
+ message: (ctx) =>
21
+ `No ${ctx.gpu || 'GPU'} available${ctx.region ? ` in ${ctx.region}` : ''} right now.`,
22
+ billing: 'never_started',
23
+ retried: false,
24
+ hint: (ctx) => [
25
+ ...(ctx.alternatives?.length
26
+ ? [`Closest available: ${ctx.alternatives.slice(0, 2).map(a => a.gpu).join(', ')}`]
27
+ : []),
28
+ ctx.region ? 'Remove --region to search globally.' : null,
29
+ 'Run `badgr capacity` to see live availability.',
30
+ ].filter(Boolean),
31
+ severity: 'P3',
32
+ },
33
+
34
+ STALE_CAPACITY: {
35
+ message: () => 'A GPU slot appeared available but could not be reserved (stale capacity).',
36
+ billing: 'never_started',
37
+ retried: true,
38
+ hint: () => ['Please try again shortly.'],
39
+ severity: 'P3',
40
+ },
41
+
42
+ PROVISIONING_TIMEOUT: {
43
+ message: () => 'GPU provisioning timed out.',
44
+ billing: 'never_started',
45
+ retried: false,
46
+ hint: () => [
47
+ 'Please try again.',
48
+ 'If this repeats, try a different --gpu type or --region.',
49
+ ],
50
+ severity: 'P2',
51
+ },
52
+
53
+ PROVISIONING_FAILED: {
54
+ message: (ctx) =>
55
+ ctx.failure_category === 'compat_failure'
56
+ ? 'GPU/CUDA driver incompatibility — the container requires a CUDA version this GPU does not support.'
57
+ : 'GPU was reserved but the container failed to start.',
58
+ billing: 'never_started',
59
+ retried: true,
60
+ hint: (ctx) =>
61
+ ctx.failure_category === 'compat_failure'
62
+ ? ['Try a different base image or a different --gpu type.']
63
+ : ['Badgr retried once. Please try again, or try a different --gpu type.'],
64
+ severity: 'P2',
65
+ },
66
+
67
+ // ── Auth / billing ────────────────────────────────────────────────────────
68
+
69
+ AUTH_FAILED: {
70
+ message: () => 'Invalid or expired API key.',
71
+ billing: 'never_started',
72
+ retried: false,
73
+ hint: () => ['Run `badgr login` to set a new key.'],
74
+ severity: 'P3',
75
+ },
76
+
77
+ BILLING_INSUFFICIENT: {
78
+ message: (ctx) => {
79
+ let msg = 'Insufficient balance.';
80
+ if (ctx.balance_usd != null) msg += ` Balance: $${Number(ctx.balance_usd).toFixed(2)}.`;
81
+ if (ctx.required_usd != null) msg += ` Required: $${Number(ctx.required_usd).toFixed(2)}.`;
82
+ return msg;
83
+ },
84
+ billing: 'never_started',
85
+ retried: false,
86
+ hint: (ctx) => [`Add balance: ${ctx.topup_url || 'https://aibadgr.com/dashboard#billing'}`],
87
+ severity: 'P3',
88
+ },
89
+
90
+ // ── Input validation ──────────────────────────────────────────────────────
91
+
92
+ INVALID_GPU: {
93
+ message: (ctx) => `GPU type '${ctx.gpu || 'unknown'}' is not recognized.`,
94
+ billing: 'never_started',
95
+ retried: false,
96
+ hint: () => ['Run `badgr gpus` to see available GPU types.'],
97
+ severity: 'P4',
98
+ },
99
+
100
+ MAX_COST_TOO_LOW: {
101
+ message: (ctx) =>
102
+ `--max-cost $${Number(ctx.maxCost || 0).toFixed(2)} is too low to start a job.`,
103
+ billing: 'never_started',
104
+ retried: false,
105
+ hint: () => [
106
+ 'GPU jobs accrue cost from the moment a machine is reserved.',
107
+ 'Use --max-cost 1.00 or higher.',
108
+ ],
109
+ severity: 'P4',
110
+ },
111
+
112
+ INVALID_MODEL: {
113
+ message: (ctx) =>
114
+ ctx.server_message ||
115
+ `Model '${ctx.model || 'unknown'}' not found or inaccessible on HuggingFace.`,
116
+ billing: 'never_started',
117
+ retried: false,
118
+ hint: () => [
119
+ 'Check the model ID at huggingface.co.',
120
+ 'For private or gated models, set HF_TOKEN in your environment.',
121
+ ],
122
+ severity: 'P4',
123
+ },
124
+
125
+ INSUFFICIENT_VRAM: {
126
+ message: (ctx) =>
127
+ ctx.server_message ||
128
+ `GPU '${ctx.gpu || 'unknown'}' does not have enough VRAM for this model.`,
129
+ billing: 'never_started',
130
+ retried: false,
131
+ hint: () => ['Try --gpu L40S, --gpu A100, or --gpu H100 for larger models.'],
132
+ severity: 'P4',
133
+ },
134
+
135
+ // ── Job / execution ───────────────────────────────────────────────────────
136
+
137
+ JOB_FAILED: {
138
+ message: (ctx) => `Job exited with code ${ctx.exitCode ?? 'unknown'}.`,
139
+ billing: 'stopped',
140
+ retried: false,
141
+ hint: (ctx) => [`Check logs: badgr logs ${ctx.deploymentId || '<id>'}`],
142
+ severity: 'P4',
143
+ },
144
+
145
+ JOB_INFRASTRUCTURE_FAILURE: {
146
+ message: () => 'Container failed to start (infrastructure error — not your code).',
147
+ billing: 'never_started',
148
+ retried: false,
149
+ hint: (ctx) => [
150
+ 'The backend retried automatically. All attempts failed.',
151
+ 'Contact support with your receipt ID for a refund.',
152
+ `Receipt: ${ctx.receiptId || 'run `badgr receipts`'}`,
153
+ ],
154
+ severity: 'P2',
155
+ },
156
+
157
+ HEARTBEAT_LOST: {
158
+ message: () => 'Lost connection to the running job — cloud machine became unresponsive.',
159
+ billing: 'stopped',
160
+ retried: false,
161
+ hint: (ctx) => [
162
+ 'The job was stopped and billing ended.',
163
+ `Receipt: ${ctx.receiptId || 'run `badgr receipts`'} — contact support if you were charged unexpectedly.`,
164
+ ],
165
+ severity: 'P2',
166
+ },
167
+
168
+ // ── Serve / endpoint ──────────────────────────────────────────────────────
169
+
170
+ HEALTH_CHECK_FAILED: {
171
+ message: (ctx) =>
172
+ `Endpoint at '${ctx.healthPath || '/models'}' did not become healthy.`,
173
+ billing: 'stopped',
174
+ retried: false,
175
+ hint: (ctx) => [
176
+ 'The endpoint was terminated to stop billing.',
177
+ ctx.deploymentId ? `Logs: badgr logs ${ctx.deploymentId}` : null,
178
+ 'Check your model config or container startup behaviour.',
179
+ ].filter(Boolean),
180
+ severity: 'P3',
181
+ },
182
+
183
+ HEALTH_CHECK_DEPLOY_FAILED: {
184
+ message: (ctx) =>
185
+ `Deployment failed during startup: ${ctx.failReason || 'unknown error'}.`,
186
+ billing: 'stopped',
187
+ retried: false,
188
+ hint: (ctx) => [
189
+ ctx.deploymentId ? `Logs: badgr logs ${ctx.deploymentId}` : null,
190
+ ].filter(Boolean),
191
+ severity: 'P3',
192
+ },
193
+
194
+ // ── Teardown / receipt ────────────────────────────────────────────────────
195
+
196
+ TEARDOWN_FAILED: {
197
+ message: () => 'Could not stop the job — billing may still be running.',
198
+ billing: 'check_receipt',
199
+ retried: true, // terminateDeployment already retries 3×
200
+ hint: (ctx) => [
201
+ `Run: badgr down ${ctx.deploymentId || '<dep-id>'}`,
202
+ 'Or visit your dashboard to stop billing manually.',
203
+ `Receipt: ${ctx.receiptId || 'run `badgr receipts`'}`,
204
+ ],
205
+ severity: 'P1',
206
+ },
207
+
208
+ RECEIPT_FAILED: {
209
+ message: () => 'Could not record a local receipt for this job.',
210
+ billing: 'check_receipt',
211
+ retried: false,
212
+ hint: () => ['Check `badgr receipts` or your dashboard for billing details.'],
213
+ severity: 'P3',
214
+ },
215
+
216
+ // ── Network / connection ──────────────────────────────────────────────────
217
+
218
+ NETWORK_ERROR: {
219
+ message: (ctx) =>
220
+ `Could not reach the Badgr API (${ctx.url || ctx.baseUrl || 'unknown URL'}).`,
221
+ billing: 'never_started',
222
+ retried: false,
223
+ hint: (ctx) => [
224
+ 'Check your internet connection.',
225
+ 'Run `badgr config` to verify the API URL.',
226
+ ctx.baseUrl ? `Test: curl ${ctx.baseUrl}/health` : null,
227
+ ].filter(Boolean),
228
+ severity: 'P4',
229
+ },
230
+
231
+ CONNECTION_REFUSED: {
232
+ message: (ctx) => `Connection refused at ${ctx.baseUrl || 'the API'}.`,
233
+ billing: 'never_started',
234
+ retried: false,
235
+ hint: () => ['Is the server running? Run `badgr config` to check the API URL.'],
236
+ severity: 'P4',
237
+ },
238
+
239
+ DNS_FAILED: {
240
+ message: (ctx) => `DNS lookup failed for ${ctx.baseUrl || 'the API'}.`,
241
+ billing: 'never_started',
242
+ retried: false,
243
+ hint: () => [
244
+ 'Check your internet connection.',
245
+ 'Run `badgr config` to verify the API URL is correct.',
246
+ ],
247
+ severity: 'P4',
248
+ },
249
+ };
250
+
251
+ // Human-readable billing status labels.
252
+ const _BILLING_LABEL = {
253
+ never_started: 'Billing: never started.',
254
+ stopped: 'Billing: stopped.',
255
+ check_receipt: 'Billing: check your receipt or dashboard.',
256
+ charged: 'Billing: you were charged.',
257
+ };
258
+
259
+ /**
260
+ * Render a structured error as a multiline CLI string ready for console.error().
261
+ *
262
+ * @param {string} code - Key from CATALOG
263
+ * @param {object} ctx - Context passed to message/hint templates
264
+ * @param {object} chalk - chalk instance (or passthrough mock in tests)
265
+ * @param {object} [internal] - Internal-only fields shown only with BADGR_DEBUG=1
266
+ * @returns {string}
267
+ */
268
+ export function formatCliError(code, ctx = {}, chalk, internal = {}) {
269
+ const entry = CATALOG[code];
270
+ if (!entry) {
271
+ return chalk.red(`\n ✗ ${code} — An unexpected error occurred.\n`) +
272
+ chalk.dim(' Run BADGR_DEBUG=1 … for a full trace.\n');
273
+ }
274
+
275
+ const msg = typeof entry.message === 'function' ? entry.message(ctx) : entry.message;
276
+ const hints = (typeof entry.hint === 'function' ? entry.hint(ctx) : entry.hint) ?? [];
277
+ const billing = _BILLING_LABEL[entry.billing] ?? '';
278
+ const debug = process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true';
279
+
280
+ const lines = [chalk.red(`\n ✗ ${code} — ${msg}`)];
281
+ if (entry.retried) lines.push(chalk.dim(' Badgr retried automatically.'));
282
+ if (billing) lines.push(chalk.dim(` ${billing}`));
283
+
284
+ if (hints.length) {
285
+ lines.push('');
286
+ for (const h of hints) lines.push(chalk.dim(` → ${h}`));
287
+ }
288
+
289
+ if (debug && Object.keys(internal).length > 0) {
290
+ lines.push('');
291
+ lines.push(chalk.dim(' [debug]'));
292
+ for (const [k, v] of Object.entries(internal)) {
293
+ if (v != null) lines.push(chalk.dim(` ${k}: ${String(v)}`));
294
+ }
295
+ }
296
+
297
+ lines.push('');
298
+ return lines.join('\n');
299
+ }
package/src/fallback.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import readline from 'readline';
2
+ import { CATALOG, formatCliError } from './errors.js';
2
3
 
3
4
  // ── Shared routing helpers ────────────────────────────────────────────────────
4
5
 
@@ -43,52 +44,91 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
43
44
  const cmd = labels?.cmd ?? 'badgr run';
44
45
  const allowTier2Fallback = opts.allowTier2Fallback !== false; // default true
45
46
 
47
+ // 75s: backend provisioning runs up to 55s and returns PROVISIONING_TIMEOUT;
48
+ // the extra headroom ensures the CLI always sees the real response.
46
49
  async function attempt(body) {
47
- return callApi(endpoint, { method: 'POST', ...callOpts, body, timeoutMs: 30_000 });
50
+ return callApi(endpoint, { method: 'POST', ...callOpts, body, timeoutMs: 75_000 });
48
51
  }
49
52
 
53
+ // Map server error codes → catalog keys and extract context for templates.
50
54
  function buildCapacityError(err, isFallback) {
51
- const d = err.errorData;
52
- let msg;
53
- if (d?.code === 'NO_CAPACITY_MATCH') {
54
- msg = chalk.red('\n ✗ No suitable GPU capacity available right now.\n') +
55
- chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.');
56
- } else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
57
- if (d?.low_cost_provider_failed) {
58
- msg = chalk.red(`\n ✗ No suitable capacity available right now. Try again shortly.\n`);
59
- } else {
60
- msg = chalk.red(`\n ✗ Capacity found but ${thing} failed to start. Please try again.\n`);
61
- if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
62
- if (d?.debug_error) msg += chalk.dim(` Detail: ${d.debug_error}`);
63
- } else {
64
- msg += chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.\n`);
65
- }
66
- }
67
- } else {
68
- msg = chalk.red(`\n ✗ Could not start ${thing}: ${err.message}\n`) +
69
- chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.`);
70
- if (!isFallback) msg += '\n' + chalk.dim(` Check config: badgr config\n`);
55
+ const d = err.errorData ?? {};
56
+ const code = d.code ?? '';
57
+
58
+ // Internal fields: shown only with BADGR_DEBUG=1
59
+ const internal = {
60
+ ...(d.debug_error ? { detail: d.debug_error } : {}),
61
+ ...(d.filters ? { filters: JSON.stringify(d.filters) } : {}),
62
+ };
63
+
64
+ // Map server codes catalog keys + context
65
+ const serverToKey = {
66
+ NO_CAPACITY: 'NO_CAPACITY',
67
+ NO_CAPACITY_MATCH: 'NO_CAPACITY',
68
+ PROVISIONING_TIMEOUT: 'PROVISIONING_TIMEOUT',
69
+ PROVISIONING_FAILED: 'PROVISIONING_FAILED',
70
+ PROVIDER_ADAPTER_ERROR:'PROVISIONING_FAILED',
71
+ INVALID_GPU: 'INVALID_GPU',
72
+ INVALID_MODEL: 'INVALID_MODEL',
73
+ INSUFFICIENT_VRAM: 'INSUFFICIENT_VRAM',
74
+ MAX_COST_TOO_LOW: 'MAX_COST_TOO_LOW',
75
+ };
76
+
77
+ const key = serverToKey[code];
78
+ if (key) {
79
+ const ctx = {
80
+ gpu: d.filters?.gpu ?? d.gpu,
81
+ region: d.filters?.region,
82
+ failure_category: d.failure_category,
83
+ low_cost_failed: d.low_cost_provider_failed,
84
+ server_message: d.message,
85
+ alternatives: d.alternatives,
86
+ };
87
+ return new CapacityError(formatCliError(key, ctx, chalk, internal));
71
88
  }
72
- return new CapacityError(msg);
89
+
90
+ // Unknown / generic error — safe fallback with no internal details exposed
91
+ const fallbackMsg =
92
+ chalk.red(`\n ✗ Could not start ${thing}: ${err.message}\n`) +
93
+ chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.`) +
94
+ (!isFallback ? '\n' + chalk.dim(' Check config: badgr config\n') : '');
95
+ return new CapacityError(fallbackMsg);
73
96
  }
74
97
 
98
+ // First attempt
99
+ let firstErr;
75
100
  try {
76
101
  return await attempt(buildBody());
77
102
  } catch (err) {
78
- const d = err.errorData;
79
-
80
- if (d?.code === 'NO_CAPACITY_MATCH' && effectiveTier !== '2' && allowTier2Fallback) {
81
- console.log(chalk.dim('\n Primary capacity unavailable — expanding search...\n'));
82
- try {
83
- return await attempt(buildBody('2'));
84
- } catch (err2) {
85
- throw buildCapacityError(err2, true);
86
- }
103
+ if (err.isPaymentRequired) throw err;
104
+ firstErr = err;
105
+ }
106
+
107
+ const d = firstErr.errorData;
108
+
109
+ // Stale-capacity retry: PROVISIONING_FAILED means a GPU slot appeared available but
110
+ // couldn't launch. Retry once immediately to pick up a fresh slot before escalating.
111
+ if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
112
+ console.log(chalk.dim('\n Provisioning failed — retrying with fresh capacity...\n'));
113
+ try {
114
+ return await attempt(buildBody());
115
+ } catch (retryErr) {
116
+ if (retryErr.isPaymentRequired) throw retryErr;
117
+ firstErr = retryErr;
87
118
  }
119
+ }
88
120
 
89
- if (err.isPaymentRequired) throw err; // let caller handle payment errors
90
- throw buildCapacityError(err, false);
121
+ // Tier-2 expansion: try budget-tier providers when primary has no capacity
122
+ if (firstErr.errorData?.code === 'NO_CAPACITY_MATCH' && effectiveTier !== '2' && allowTier2Fallback) {
123
+ console.log(chalk.dim('\n Primary capacity unavailable — expanding search...\n'));
124
+ try {
125
+ return await attempt(buildBody('2'));
126
+ } catch (err2) {
127
+ throw buildCapacityError(err2, true);
128
+ }
91
129
  }
130
+
131
+ throw buildCapacityError(firstErr, false);
92
132
  }
93
133
 
94
134
  // ── GPU fallback prompt ───────────────────────────────────────────────────────
package/src/router.js CHANGED
@@ -11,9 +11,10 @@
11
11
  export const GPU_CATALOG = [
12
12
  { id: 'rtx-3080', canonical: 'RTX_3080', name: 'NVIDIA RTX 3080', vramGb: 10, ratePerHour: 0.35, tags: ['inference', 'dev'] },
13
13
  { id: 'rtx-3090', canonical: 'RTX_3090', name: 'NVIDIA RTX 3090', vramGb: 24, ratePerHour: 0.60, tags: ['inference', 'dev'] },
14
- { id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
15
14
  { id: 'a4000', canonical: 'A4000', name: 'NVIDIA RTX A4000', vramGb: 16, ratePerHour: 0.50, tags: ['inference', 'dev'] },
16
15
  { id: 'a5000', canonical: 'A5000', name: 'NVIDIA RTX A5000', vramGb: 24, ratePerHour: 0.70, tags: ['inference', 'dev'] },
16
+ { id: 'rtx-4080', canonical: 'RTX_4080', name: 'NVIDIA RTX 4080', vramGb: 16, ratePerHour: 0.75, tags: ['inference', 'dev'] },
17
+ { id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
17
18
  { id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
18
19
  { id: 'a6000', canonical: 'A6000', name: 'NVIDIA RTX A6000', vramGb: 48, ratePerHour: 1.60, tags: ['inference', 'training'] },
19
20
  { id: 'a100-40gb', canonical: 'A100', name: 'NVIDIA A100 40GB', vramGb: 40, ratePerHour: 1.80, tags: ['training', 'inference'] },
@@ -82,6 +82,18 @@ describe('parseRunArgs', () => {
82
82
  const { flags } = parseRunArgs(['python', 'train.py']);
83
83
  expect(flags.tier).toBeUndefined();
84
84
  });
85
+
86
+ it('parses --min-vram flag', () => {
87
+ const { flags } = parseRunArgs(['python', 'job.py', '--min-vram', '24']);
88
+ expect(flags.minVram).toBe(24);
89
+ expect(flags.gpu).toBeUndefined();
90
+ });
91
+
92
+ it('--min-vram and --gpu can be combined', () => {
93
+ const { flags } = parseRunArgs(['python', 'job.py', '--gpu', 'A100', '--min-vram', '40']);
94
+ expect(flags.gpu).toBe('A100');
95
+ expect(flags.minVram).toBe(40);
96
+ });
85
97
  });
86
98
 
87
99
  describe('classifyFailure', () => {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tests for the unified error catalog (errors.js).
3
+ * Verifies that every catalog entry produces output matching the four CLI guarantees:
4
+ * 1. What happened (code + message in output)
5
+ * 2. Retry status (retried note when entry.retried === true)
6
+ * 3. Billing status (billing label in output)
7
+ * 4. Next step (at least one → hint in output)
8
+ */
9
+ import { describe, it, expect } from 'vitest';
10
+ import { CATALOG, formatCliError } from '../src/errors.js';
11
+
12
+ // Passthrough chalk mock — identical to what the lifecycle test suite uses.
13
+ const chalk = new Proxy({}, {
14
+ get: () => (s) => s,
15
+ });
16
+
17
+ describe('CATALOG completeness', () => {
18
+ const REQUIRED_FIELDS = ['message', 'billing', 'retried', 'hint', 'severity'];
19
+ const VALID_BILLING = ['never_started', 'stopped', 'check_receipt', 'charged'];
20
+ const VALID_SEVERITY = ['P1', 'P2', 'P3', 'P4'];
21
+
22
+ for (const [code, entry] of Object.entries(CATALOG)) {
23
+ it(`${code} has all required fields`, () => {
24
+ for (const f of REQUIRED_FIELDS) {
25
+ expect(entry, `${code} missing field: ${f}`).toHaveProperty(f);
26
+ }
27
+ expect(VALID_BILLING, `${code}.billing invalid`).toContain(entry.billing);
28
+ expect(VALID_SEVERITY, `${code}.severity invalid`).toContain(entry.severity);
29
+ });
30
+ }
31
+ });
32
+
33
+ describe('formatCliError', () => {
34
+ it('includes the error code in the output', () => {
35
+ const out = formatCliError('NO_CAPACITY', {}, chalk);
36
+ expect(out).toContain('NO_CAPACITY');
37
+ });
38
+
39
+ it('includes billing label in output', () => {
40
+ const out = formatCliError('NO_CAPACITY', {}, chalk);
41
+ expect(out).toContain('Billing: never started');
42
+ });
43
+
44
+ it('includes a → hint line', () => {
45
+ const out = formatCliError('NO_CAPACITY', {}, chalk);
46
+ expect(out).toContain('→');
47
+ });
48
+
49
+ it('shows "Badgr retried automatically" only for retried=true entries', () => {
50
+ const retried = formatCliError('PROVISIONING_FAILED', {}, chalk);
51
+ expect(retried).toContain('Badgr retried automatically');
52
+
53
+ const notRetried = formatCliError('NO_CAPACITY', {}, chalk);
54
+ expect(notRetried).not.toContain('Badgr retried automatically');
55
+ });
56
+
57
+ it('does not expose internal fields by default', () => {
58
+ const out = formatCliError('PROVISIONING_FAILED', {}, chalk, { detail: 'SECRET_PROVIDER_URL' });
59
+ expect(out).not.toContain('SECRET_PROVIDER_URL');
60
+ });
61
+
62
+ it('exposes internal fields when BADGR_DEBUG=1', () => {
63
+ process.env.BADGR_DEBUG = '1';
64
+ try {
65
+ const out = formatCliError('PROVISIONING_FAILED', {}, chalk, { detail: 'SECRET_PROVIDER_URL' });
66
+ expect(out).toContain('SECRET_PROVIDER_URL');
67
+ } finally {
68
+ delete process.env.BADGR_DEBUG;
69
+ }
70
+ });
71
+
72
+ it('falls back gracefully for unknown codes', () => {
73
+ const out = formatCliError('TOTALLY_UNKNOWN_CODE', {}, chalk);
74
+ expect(out).toContain('TOTALLY_UNKNOWN_CODE');
75
+ expect(out).toContain('unexpected error');
76
+ });
77
+
78
+ // ── Specific catalog entries ──────────────────────────────────────────────
79
+
80
+ it('PROVISIONING_FAILED compat_failure contains incompatib and GPU type/image', () => {
81
+ const out = formatCliError('PROVISIONING_FAILED', { failure_category: 'compat_failure' }, chalk);
82
+ expect(out).toContain('incompatib');
83
+ expect(out).toMatch(/GPU type|image/i);
84
+ });
85
+
86
+ it('PROVISIONING_FAILED generic contains "failed to start" and "try again"', () => {
87
+ const out = formatCliError('PROVISIONING_FAILED', {}, chalk);
88
+ expect(out).toContain('failed to start');
89
+ expect(out).toMatch(/try again/i);
90
+ expect(out).not.toContain('incompatib');
91
+ });
92
+
93
+ it('NO_CAPACITY output contains "capacity" (satisfies /capacity/i test)', () => {
94
+ const out = formatCliError('NO_CAPACITY', {}, chalk);
95
+ expect(out).toMatch(/capacity/i);
96
+ });
97
+
98
+ it('TEARDOWN_FAILED is P1 severity', () => {
99
+ expect(CATALOG.TEARDOWN_FAILED.severity).toBe('P1');
100
+ });
101
+
102
+ it('TEARDOWN_FAILED billing is check_receipt', () => {
103
+ expect(CATALOG.TEARDOWN_FAILED.billing).toBe('check_receipt');
104
+ });
105
+
106
+ it('TEARDOWN_FAILED output contains billing warning', () => {
107
+ const out = formatCliError('TEARDOWN_FAILED', { deploymentId: 'dep-123', receiptId: 'rcpt-456' }, chalk);
108
+ expect(out).toContain('check your receipt');
109
+ expect(out).toContain('dep-123');
110
+ });
111
+
112
+ it('JOB_INFRASTRUCTURE_FAILURE billing is never_started', () => {
113
+ expect(CATALOG.JOB_INFRASTRUCTURE_FAILURE.billing).toBe('never_started');
114
+ });
115
+
116
+ it('HEALTH_CHECK_FAILED billing is stopped', () => {
117
+ expect(CATALOG.HEALTH_CHECK_FAILED.billing).toBe('stopped');
118
+ });
119
+
120
+ it('BILLING_INSUFFICIENT shows balance and required when provided', () => {
121
+ const out = formatCliError('BILLING_INSUFFICIENT', {
122
+ balance_usd: 1.23,
123
+ required_usd: 5.00,
124
+ topup_url: 'https://example.com/billing',
125
+ }, chalk);
126
+ expect(out).toContain('1.23');
127
+ expect(out).toContain('5.00');
128
+ expect(out).toContain('example.com/billing');
129
+ });
130
+ });