badgr-cli 1.0.42 → 1.0.44
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 +81 -2
- package/package.json +2 -1
- package/src/badgr.js +3 -0
- package/src/catalog.js +15 -0
- package/src/commands/comfyui.js +35 -8
- package/src/commands/run.js +87 -99
- package/src/commands/serve.js +186 -123
- package/src/commands/train.js +85 -11
- package/src/progress.js +42 -0
- package/tests/productized-dry-run.test.js +141 -0
- package/tests/serve-lifecycle.test.js +196 -137
- package/tests/template.test.js +11 -13
- package/tests/workload-templates.test.js +37 -2
package/src/progress.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Shared staged-progress rendering used by `badgr serve` and `badgr run` —
|
|
2
|
+
// numbered stage lines plus an in-place-redrawing status block, so neither
|
|
3
|
+
// command spams repeated identical loading lines.
|
|
4
|
+
|
|
5
|
+
export function stage(n, total, label) {
|
|
6
|
+
return ` [${n}/${total}] ${label}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function stageDone(n, total, label) {
|
|
10
|
+
return ` [${n}/${total}] ${label} done`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Redraws a fixed-height status block in place instead of appending new
|
|
14
|
+
// lines each tick — satisfies "no repeated identical loading lines" while
|
|
15
|
+
// still showing live elapsed time. Returns the new line count to pass back
|
|
16
|
+
// in as prevLineCount on the next call.
|
|
17
|
+
export function writeBlock(prevLineCount, lines) {
|
|
18
|
+
if (prevLineCount > 0) process.stdout.write(`\x1b[${prevLineCount}A`);
|
|
19
|
+
for (const line of lines) process.stdout.write('\x1b[2K' + line + '\n');
|
|
20
|
+
return lines.length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function clearBlock(lineCount) {
|
|
24
|
+
if (lineCount <= 0) return;
|
|
25
|
+
process.stdout.write(`\x1b[${lineCount}A`);
|
|
26
|
+
for (let i = 0; i < lineCount; i++) process.stdout.write('\x1b[2K\n');
|
|
27
|
+
process.stdout.write(`\x1b[${lineCount}A`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The live block shown while a stage is in progress — same shape for both
|
|
31
|
+
// `badgr serve` (deployment) and `badgr run` (job): elapsed time, a status
|
|
32
|
+
// word, an *estimated* spend (never presented as confirmed while billing is
|
|
33
|
+
// still live), and the exact commands to inspect logs or stop billing.
|
|
34
|
+
export function renderLiveBlock(chalk, { stageLine, elapsedSec, statusWord, spend, id }) {
|
|
35
|
+
return [
|
|
36
|
+
chalk.dim(`${stageLine} ${elapsedSec}s elapsed`),
|
|
37
|
+
chalk.dim(` Status: ${statusWord}`),
|
|
38
|
+
chalk.dim(` Estimated spend: ~$${spend.toFixed(2)}`),
|
|
39
|
+
chalk.dim(` Logs: badgr logs ${id}`),
|
|
40
|
+
chalk.dim(` Stop billing: badgr down ${id}`),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --dry-run for the productized runner commands (badgr train lora, badgr comfyui batch).
|
|
3
|
+
*
|
|
4
|
+
* Both must preview the request (preset/workflow, GPU, cost) without calling the API,
|
|
5
|
+
* uploading files, or requiring an API key — mirroring the existing `badgr run --dry-run`
|
|
6
|
+
* and `badgr up --dry-run` behavior.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { trainLoraCommand, parseTrainLoraArgs } from '../src/commands/train.js';
|
|
10
|
+
import { comfyBatchCommand, parseComfyBatchArgs } from '../src/commands/comfyui.js';
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/api.js', () => ({
|
|
13
|
+
callApi: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../src/store.js', () => ({
|
|
17
|
+
addReceipt: vi.fn(),
|
|
18
|
+
generateReceiptId: vi.fn(() => 'rcpt-dry-001'),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('fs', async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal();
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
readFileSync: vi.fn(),
|
|
26
|
+
existsSync: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
import * as api from '../src/api.js';
|
|
31
|
+
import * as store from '../src/store.js';
|
|
32
|
+
import * as fs from 'fs';
|
|
33
|
+
|
|
34
|
+
const chalk = {
|
|
35
|
+
bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
process.exitCode = undefined;
|
|
42
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
43
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
process.exitCode = undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('parseTrainLoraArgs', () => {
|
|
53
|
+
it('parses --dry-run', () => {
|
|
54
|
+
const flags = parseTrainLoraArgs(['--base-model', 'x', '--dry-run']);
|
|
55
|
+
expect(flags.dryRun).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('trainLoraCommand --dry-run', () => {
|
|
60
|
+
it('previews the job without calling the API or requiring an API key', async () => {
|
|
61
|
+
await trainLoraCommand({ apiKey: null, baseUrl: 'https://api.test/v1' }, [
|
|
62
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
63
|
+
'--dataset', 'https://example.com/data.jsonl',
|
|
64
|
+
'--dry-run',
|
|
65
|
+
], chalk);
|
|
66
|
+
|
|
67
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
68
|
+
expect(store.addReceipt).not.toHaveBeenCalled();
|
|
69
|
+
expect(process.exitCode).toBeFalsy();
|
|
70
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
71
|
+
expect(logged).toContain('Dry run');
|
|
72
|
+
expect(logged).toContain('mistralai/Mistral-7B-v0.1');
|
|
73
|
+
expect(logged).toContain('RTX_4090'); // default 'small' preset GPU
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not upload a local dataset file during --dry-run', async () => {
|
|
77
|
+
fs.existsSync.mockReturnValue(true);
|
|
78
|
+
fs.readFileSync.mockReturnValue('dummy');
|
|
79
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ upload_id: 'up_x' }) });
|
|
80
|
+
|
|
81
|
+
await trainLoraCommand(config, [
|
|
82
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
83
|
+
'--dataset', './local.jsonl',
|
|
84
|
+
'--dry-run',
|
|
85
|
+
], chalk);
|
|
86
|
+
|
|
87
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
88
|
+
fetchSpy.mockRestore();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('still requires --base-model even with --dry-run', async () => {
|
|
92
|
+
await trainLoraCommand(config, ['--dry-run'], chalk);
|
|
93
|
+
expect(process.exitCode).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('flags an unknown preset instead of silently accepting it', async () => {
|
|
97
|
+
await trainLoraCommand(config, [
|
|
98
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
99
|
+
'--preset', 'huge',
|
|
100
|
+
'--dry-run',
|
|
101
|
+
], chalk);
|
|
102
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
103
|
+
expect(logged).toContain('unknown');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('parseComfyBatchArgs', () => {
|
|
108
|
+
it('parses --dry-run', () => {
|
|
109
|
+
const flags = parseComfyBatchArgs(['--workflow', 'sdxl-basic', '--dry-run']);
|
|
110
|
+
expect(flags.dryRun).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('comfyBatchCommand --dry-run', () => {
|
|
115
|
+
it('previews the batch without calling the API or requiring an API key', async () => {
|
|
116
|
+
await comfyBatchCommand({ apiKey: null, baseUrl: 'https://api.test/v1' }, [
|
|
117
|
+
'--workflow', 'sdxl-basic',
|
|
118
|
+
'--prompt', 'a cat on a beach',
|
|
119
|
+
'--dry-run',
|
|
120
|
+
], chalk);
|
|
121
|
+
|
|
122
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
123
|
+
expect(store.addReceipt).not.toHaveBeenCalled();
|
|
124
|
+
expect(process.exitCode).toBeFalsy();
|
|
125
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
126
|
+
expect(logged).toContain('Dry run');
|
|
127
|
+
expect(logged).toContain('sdxl-basic');
|
|
128
|
+
expect(logged).toContain('RTX_4090');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('warns when the prompt count exceeds the 20-prompt cap', async () => {
|
|
132
|
+
const flags = { workflow: 'sdxl-basic', inlinePrompts: Array(21).fill('x') };
|
|
133
|
+
await comfyBatchCommand({ apiKey: null, baseUrl: 'https://api.test/v1' }, [
|
|
134
|
+
'--workflow', 'sdxl-basic',
|
|
135
|
+
...Array(21).fill(['--prompt', 'x']).flat(),
|
|
136
|
+
'--dry-run',
|
|
137
|
+
], chalk);
|
|
138
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
139
|
+
expect(logged).toContain('exceeds the 20-prompt limit');
|
|
140
|
+
});
|
|
141
|
+
});
|