@whatcanirun/cli 0.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.
- package/README.md +91 -0
- package/package.json +37 -0
- package/src/auth/login.ts +129 -0
- package/src/auth/token.ts +58 -0
- package/src/bundle/create.ts +164 -0
- package/src/bundle/validate.ts +82 -0
- package/src/cli.ts +23 -0
- package/src/commands/auth.ts +75 -0
- package/src/commands/index.ts +7 -0
- package/src/commands/run.ts +286 -0
- package/src/commands/show.ts +67 -0
- package/src/commands/submit.ts +64 -0
- package/src/commands/update.ts +24 -0
- package/src/commands/validate.ts +43 -0
- package/src/commands/version.ts +19 -0
- package/src/device/detect.ts +109 -0
- package/src/model/resolve.ts +301 -0
- package/src/runtime/llamacpp.ts +187 -0
- package/src/runtime/mlx.ts +190 -0
- package/src/runtime/resolve.ts +29 -0
- package/src/runtime/types.ts +40 -0
- package/src/upload/client.ts +56 -0
- package/src/utils/id.ts +77 -0
- package/src/utils/log.ts +125 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { DerivedMetrics } from '@whatcanirun/shared';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { basename } from 'path';
|
|
4
|
+
|
|
5
|
+
import { getAuth } from '../auth/token';
|
|
6
|
+
import { createBundle } from '../bundle/create';
|
|
7
|
+
import { validateBundle } from '../bundle/validate';
|
|
8
|
+
import { detectDevice } from '../device/detect';
|
|
9
|
+
import { findHfCachePath, inspectModel, isHuggingFaceRepoId, resolveModel } from '../model/resolve';
|
|
10
|
+
import { resolveRuntime } from '../runtime/resolve';
|
|
11
|
+
import type { BenchResult } from '../runtime/types';
|
|
12
|
+
import { uploadBundle } from '../upload/client';
|
|
13
|
+
import { DEFAULT_BUNDLES_DIR } from '../utils/id';
|
|
14
|
+
import * as log from '../utils/log';
|
|
15
|
+
import { Spinner } from '../utils/log';
|
|
16
|
+
|
|
17
|
+
// -----------------------------------------------------------------------------
|
|
18
|
+
// Command
|
|
19
|
+
// -----------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const command = defineCommand({
|
|
22
|
+
meta: {
|
|
23
|
+
name: 'run',
|
|
24
|
+
description: 'Run a benchmark and optionally submit results',
|
|
25
|
+
},
|
|
26
|
+
args: {
|
|
27
|
+
model: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Hugging Face repo ID or local model path',
|
|
30
|
+
required: true,
|
|
31
|
+
},
|
|
32
|
+
runtime: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'Runtime to use (mlx_lm, llama.cpp)',
|
|
35
|
+
required: true,
|
|
36
|
+
},
|
|
37
|
+
'prompt-tokens': {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Prompt token count (default: 4096)',
|
|
40
|
+
},
|
|
41
|
+
'gen-tokens': {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Generation token count (default: 1024)',
|
|
44
|
+
},
|
|
45
|
+
trials: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Number of trials (default: 10)',
|
|
48
|
+
},
|
|
49
|
+
notes: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Optional notes attached to the run',
|
|
52
|
+
},
|
|
53
|
+
submit: {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
description: 'Upload results after benchmark',
|
|
56
|
+
default: false,
|
|
57
|
+
},
|
|
58
|
+
output: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: 'Bundle output directory (default: ~/.whatcanirun/bundles)',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
async run({ args }) {
|
|
64
|
+
if (args.submit && !getAuth()) {
|
|
65
|
+
log.error('Not logged in. Run `whatcanirun auth login` first.');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const promptTokens = parsePositiveInt(
|
|
70
|
+
(args['prompt-tokens'] as string) || '4096',
|
|
71
|
+
'prompt-tokens'
|
|
72
|
+
);
|
|
73
|
+
const genTokens = parsePositiveInt((args['gen-tokens'] as string) || '1024', 'gen-tokens');
|
|
74
|
+
const numTrials = parsePositiveInt((args.trials as string) || '10', 'trials');
|
|
75
|
+
const outputDir = (args.output as string) || DEFAULT_BUNDLES_DIR;
|
|
76
|
+
|
|
77
|
+
// Resolve runtime.
|
|
78
|
+
let adapter;
|
|
79
|
+
try {
|
|
80
|
+
adapter = resolveRuntime(args.runtime as string);
|
|
81
|
+
} catch (e: unknown) {
|
|
82
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Detect runtime.
|
|
87
|
+
const runtimeInfo = await adapter.detect();
|
|
88
|
+
if (!runtimeInfo) {
|
|
89
|
+
log.error(
|
|
90
|
+
`Runtime \`${args.runtime}\` is not available. Make sure it is installed and on \`PATH\`.`
|
|
91
|
+
);
|
|
92
|
+
const installHints: Record<string, string> = {
|
|
93
|
+
mlx_lm: 'Install with: \`pip install mlx-lm\`.',
|
|
94
|
+
'llama.cpp': 'Install with: \`brew install llama.cpp\`.',
|
|
95
|
+
};
|
|
96
|
+
const hint = installHints[args.runtime as string];
|
|
97
|
+
if (hint) {
|
|
98
|
+
log.blank();
|
|
99
|
+
log.info(hint);
|
|
100
|
+
}
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve and inspect model.
|
|
105
|
+
let modelRef: string;
|
|
106
|
+
try {
|
|
107
|
+
modelRef = await resolveModel(args.model as string);
|
|
108
|
+
} catch (e: unknown) {
|
|
109
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
log.info('Inspecting model...');
|
|
114
|
+
const modelInfo = await inspectModel(modelRef);
|
|
115
|
+
|
|
116
|
+
// Detect device.
|
|
117
|
+
let device;
|
|
118
|
+
try {
|
|
119
|
+
device = await detectDevice();
|
|
120
|
+
} catch (e: unknown) {
|
|
121
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Display config.
|
|
126
|
+
log.blank();
|
|
127
|
+
log.label('Model', modelInfo.display_name);
|
|
128
|
+
if (modelInfo.parameters) log.label('Parameters', modelInfo.parameters);
|
|
129
|
+
log.label('Format', modelInfo.format);
|
|
130
|
+
if (modelInfo.quant) log.label('Quant', modelInfo.quant);
|
|
131
|
+
log.label('Device', `${device.cpu_model} (${device.ram_gb}GB)`);
|
|
132
|
+
log.label('Runtime', `${runtimeInfo.name} ${runtimeInfo.version}`);
|
|
133
|
+
log.label('Config', `pp=${promptTokens} tg=${genTokens} trials=${numTrials}`);
|
|
134
|
+
log.blank();
|
|
135
|
+
|
|
136
|
+
// Run benchmark.
|
|
137
|
+
const isCached = isHuggingFaceRepoId(modelRef) && findHfCachePath(modelRef) !== null;
|
|
138
|
+
const initialMsg = isCached ? 'Loading model from cache...' : 'Downloading model...';
|
|
139
|
+
const spinner = new Spinner(initialMsg).start();
|
|
140
|
+
let bench: BenchResult;
|
|
141
|
+
let trialsStarted = false;
|
|
142
|
+
try {
|
|
143
|
+
bench = await adapter.benchmark({
|
|
144
|
+
model: modelRef,
|
|
145
|
+
promptTokens,
|
|
146
|
+
genTokens,
|
|
147
|
+
numTrials,
|
|
148
|
+
onProgress: (msg) => {
|
|
149
|
+
const trialMatch = msg.match(/^Trial (\d+)\/(\d+)/);
|
|
150
|
+
if (trialMatch) {
|
|
151
|
+
const total = parseInt(trialMatch[2]!, 10);
|
|
152
|
+
if (!trialsStarted) {
|
|
153
|
+
trialsStarted = true;
|
|
154
|
+
spinner.setTotal(total);
|
|
155
|
+
spinner.update('Running trials');
|
|
156
|
+
}
|
|
157
|
+
const tpsMatch = msg.match(/— (.+)$/);
|
|
158
|
+
spinner.tick(tpsMatch?.[1]);
|
|
159
|
+
} else {
|
|
160
|
+
spinner.update(msg);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
spinner.stop('Benchmark complete.');
|
|
165
|
+
} catch (e: unknown) {
|
|
166
|
+
spinner.stop();
|
|
167
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Re-inspect model after benchmark so HF cache is populated on first run.
|
|
172
|
+
if (!modelInfo.artifact_sha256) {
|
|
173
|
+
const updated = await inspectModel(modelRef);
|
|
174
|
+
Object.assign(modelInfo, updated);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Compute derived metrics.
|
|
178
|
+
const metrics = computeMetrics(bench);
|
|
179
|
+
|
|
180
|
+
// Display results.
|
|
181
|
+
log.blank();
|
|
182
|
+
log.header('Results');
|
|
183
|
+
log.label('TTFT (est) p50/p95', `${metrics.ttftP50Ms} ms / ${metrics.ttftP95Ms} ms`);
|
|
184
|
+
log.label('Decode TPS', `${metrics.decodeTpsMean} tok/s`);
|
|
185
|
+
log.label('Prefill TPS', `${Math.round(bench.averages.promptTps * 10) / 10} tok/s`);
|
|
186
|
+
log.label('Weighted TPS', `${metrics.weightedTpsMean} tok/s`);
|
|
187
|
+
if (metrics.peakRssMb > 0) {
|
|
188
|
+
log.label('Peak Memory', `${(metrics.peakRssMb / 1024).toFixed(2)} GB`);
|
|
189
|
+
}
|
|
190
|
+
log.label('Trials', `${bench.trials.length}/${bench.trials.length} passed`);
|
|
191
|
+
log.blank();
|
|
192
|
+
|
|
193
|
+
// Create bundle.
|
|
194
|
+
const bundlePath = await createBundle({
|
|
195
|
+
outputDir,
|
|
196
|
+
device,
|
|
197
|
+
runtimeInfo,
|
|
198
|
+
model: modelInfo,
|
|
199
|
+
bench,
|
|
200
|
+
metrics,
|
|
201
|
+
notes: args.notes as string | undefined,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Validate.
|
|
205
|
+
const validation = await validateBundle(bundlePath);
|
|
206
|
+
if (!validation.valid) {
|
|
207
|
+
log.warn('Bundle validation issues:');
|
|
208
|
+
for (const err of validation.errors) {
|
|
209
|
+
log.warn(` ${err}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const bundleId = basename(bundlePath, '.zip');
|
|
214
|
+
log.bundleSaved(bundlePath);
|
|
215
|
+
log.info(`Submit it via \`whatcanirun submit ${bundleId}\``);
|
|
216
|
+
|
|
217
|
+
// Upload.
|
|
218
|
+
if (args.submit) {
|
|
219
|
+
log.blank();
|
|
220
|
+
log.info('Uploading...');
|
|
221
|
+
try {
|
|
222
|
+
const result = await uploadBundle(bundlePath);
|
|
223
|
+
log.blank();
|
|
224
|
+
log.header('Run created:');
|
|
225
|
+
console.log(result.run_url);
|
|
226
|
+
log.blank();
|
|
227
|
+
log.label('Status', result.status);
|
|
228
|
+
} catch (e: unknown) {
|
|
229
|
+
log.error(`Upload failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
230
|
+
log.info('Bundle is saved locally. You can submit later with:');
|
|
231
|
+
log.info(` whatcanirun submit ${bundlePath}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// -----------------------------------------------------------------------------
|
|
238
|
+
// Helpers
|
|
239
|
+
// -----------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/** Nearest-rank percentile on a sorted-ascending array. */
|
|
242
|
+
function nearestRankPercentile(sorted: number[], p: number): number {
|
|
243
|
+
const rank = Math.ceil((p / 100) * sorted.length) - 1;
|
|
244
|
+
return sorted[Math.max(0, rank)]!;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function computeMetrics(bench: BenchResult): DerivedMetrics {
|
|
248
|
+
const { promptTokens, completionTokens, trials, averages } = bench;
|
|
249
|
+
|
|
250
|
+
// Sort prompt TPS ascending (low TPS = high latency).
|
|
251
|
+
const sortedPromptTps = trials.map((t) => t.promptTps).sort((a, b) => a - b);
|
|
252
|
+
// TTFT estimated from prefill TPS: `prompt_tokens / prompt_tps * 1000`.
|
|
253
|
+
const p50PromptTps = nearestRankPercentile(sortedPromptTps, 50);
|
|
254
|
+
const ttftP50Ms =
|
|
255
|
+
p50PromptTps > 0 ? Math.round((promptTokens / p50PromptTps) * 1000 * 100) / 100 : 0;
|
|
256
|
+
const p5PromptTps = nearestRankPercentile(sortedPromptTps, 5);
|
|
257
|
+
const ttftP95Ms =
|
|
258
|
+
p5PromptTps > 0 ? Math.round((promptTokens / p5PromptTps) * 1000 * 100) / 100 : 0;
|
|
259
|
+
|
|
260
|
+
const decodeTpsMean = Math.round(averages.generationTps * 10) / 10;
|
|
261
|
+
|
|
262
|
+
// Weighted TPS: `(prompt_tokens * prompt_tps + gen_tokens * gen_tps) / (prompt_tokens + gen_tokens)`.
|
|
263
|
+
const weightedTpsMean =
|
|
264
|
+
promptTokens + completionTokens > 0
|
|
265
|
+
? Math.round(
|
|
266
|
+
((promptTokens * averages.promptTps + completionTokens * averages.generationTps) /
|
|
267
|
+
(promptTokens + completionTokens)) *
|
|
268
|
+
10
|
|
269
|
+
) / 10
|
|
270
|
+
: 0;
|
|
271
|
+
|
|
272
|
+
const peakRssMb = Math.round(averages.peakMemoryGb * 1024 * 10) / 10;
|
|
273
|
+
|
|
274
|
+
return { ttftP50Ms, ttftP95Ms, decodeTpsMean, weightedTpsMean, peakRssMb };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parsePositiveInt(value: string, name: string): number {
|
|
278
|
+
const n = parseInt(value, 10);
|
|
279
|
+
if (isNaN(n) || n <= 0) {
|
|
280
|
+
log.error(`Invalid value for --${name}: "${value}". Expected a positive integer.`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
return n;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export default command;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
|
|
3
|
+
import { detectDevice } from '../device/detect';
|
|
4
|
+
import { inspectModel, resolveModel } from '../model/resolve';
|
|
5
|
+
import { resolveRuntime } from '../runtime/resolve';
|
|
6
|
+
import * as log from '../utils/log';
|
|
7
|
+
|
|
8
|
+
const command = defineCommand({
|
|
9
|
+
meta: {
|
|
10
|
+
name: 'show',
|
|
11
|
+
description: 'Display detected device, runtime, or model information',
|
|
12
|
+
},
|
|
13
|
+
args: {
|
|
14
|
+
target: {
|
|
15
|
+
type: 'positional',
|
|
16
|
+
description: 'What to show: device, runtime, or model',
|
|
17
|
+
required: true,
|
|
18
|
+
},
|
|
19
|
+
value: {
|
|
20
|
+
type: 'positional',
|
|
21
|
+
description: 'Runtime name or model path (for runtime/model targets)',
|
|
22
|
+
required: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
async run({ args }) {
|
|
26
|
+
const target = args.target as string;
|
|
27
|
+
|
|
28
|
+
switch (target) {
|
|
29
|
+
case 'device': {
|
|
30
|
+
const device = await detectDevice();
|
|
31
|
+
console.log(JSON.stringify(device, null, 2));
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case 'runtime': {
|
|
35
|
+
const name = args.value as string | undefined;
|
|
36
|
+
if (!name) {
|
|
37
|
+
log.error('Usage: whatcanirun show runtime <name>');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const adapter = resolveRuntime(name);
|
|
41
|
+
const info = await adapter.detect();
|
|
42
|
+
if (!info) {
|
|
43
|
+
log.error(`Runtime '${name}' not found or not available`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
console.log(JSON.stringify(info, null, 2));
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case 'model': {
|
|
50
|
+
const ref = args.value as string | undefined;
|
|
51
|
+
if (!ref) {
|
|
52
|
+
log.error('Usage: whatcanirun show model <path-or-repo-id>');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const resolved = await resolveModel(ref);
|
|
56
|
+
const info = await inspectModel(resolved);
|
|
57
|
+
console.log(JSON.stringify(info, null, 2));
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
default:
|
|
61
|
+
log.error(`Unknown target \`${target}\`. Use: device, runtime, or model`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default command;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
|
|
3
|
+
import { getAuth } from '../auth/token';
|
|
4
|
+
import { validateBundle } from '../bundle/validate';
|
|
5
|
+
import { uploadBundle } from '../upload/client';
|
|
6
|
+
import { resolveBundlePath } from '../utils/id';
|
|
7
|
+
import * as log from '../utils/log';
|
|
8
|
+
|
|
9
|
+
const command = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'submit',
|
|
12
|
+
description: 'Upload an existing bundle',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
bundle: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Bundle ID or path to zip file',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
async run({ args }) {
|
|
22
|
+
if (!getAuth()) {
|
|
23
|
+
log.error('Not logged in. Run `whatcanirun auth login` first.');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let bundlePath;
|
|
28
|
+
try {
|
|
29
|
+
bundlePath = resolveBundlePath(args.bundle as string);
|
|
30
|
+
} catch (e: unknown) {
|
|
31
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Validate first
|
|
36
|
+
log.info('Validating bundle...');
|
|
37
|
+
const validation = await validateBundle(bundlePath);
|
|
38
|
+
if (!validation.valid) {
|
|
39
|
+
log.error('Bundle validation failed:');
|
|
40
|
+
for (const err of validation.errors) {
|
|
41
|
+
log.error(` ${err}`);
|
|
42
|
+
}
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
log.success('Bundle is valid.');
|
|
46
|
+
log.blank();
|
|
47
|
+
|
|
48
|
+
// Upload
|
|
49
|
+
log.info('Uploading...');
|
|
50
|
+
try {
|
|
51
|
+
const result = await uploadBundle(bundlePath);
|
|
52
|
+
log.blank();
|
|
53
|
+
log.header('Run created:');
|
|
54
|
+
console.log(result.run_url);
|
|
55
|
+
log.blank();
|
|
56
|
+
log.label('Status', result.status);
|
|
57
|
+
} catch (e: unknown) {
|
|
58
|
+
log.error(`Upload failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export default command;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
|
|
3
|
+
import * as log from '../utils/log';
|
|
4
|
+
|
|
5
|
+
const command = defineCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: 'update',
|
|
8
|
+
description: 'Update whatcanirun to the latest version',
|
|
9
|
+
},
|
|
10
|
+
async run() {
|
|
11
|
+
const wcirup = Bun.which('wcir-up');
|
|
12
|
+
if (!wcirup) {
|
|
13
|
+
log.error('`wcir-up` not found on `PATH`.');
|
|
14
|
+
log.info('Install it with: `curl -fsSL https://whatcani.run/install | bash`');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const proc = Bun.spawn([wcirup], { stdio: ['inherit', 'inherit', 'inherit'] });
|
|
19
|
+
const code = await proc.exited;
|
|
20
|
+
process.exit(code);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export default command;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
|
|
3
|
+
import { validateBundle } from '../bundle/validate';
|
|
4
|
+
import { resolveBundlePath } from '../utils/id';
|
|
5
|
+
import * as log from '../utils/log';
|
|
6
|
+
|
|
7
|
+
const command = defineCommand({
|
|
8
|
+
meta: {
|
|
9
|
+
name: 'validate',
|
|
10
|
+
description: 'Validate a bundle locally',
|
|
11
|
+
},
|
|
12
|
+
args: {
|
|
13
|
+
bundle: {
|
|
14
|
+
type: 'positional',
|
|
15
|
+
description: 'Bundle ID or path to zip file',
|
|
16
|
+
required: true,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
async run({ args }) {
|
|
20
|
+
let bundlePath;
|
|
21
|
+
try {
|
|
22
|
+
bundlePath = resolveBundlePath(args.bundle as string);
|
|
23
|
+
} catch (e: unknown) {
|
|
24
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
log.info(`Validating: ${bundlePath}`);
|
|
29
|
+
const result = await validateBundle(bundlePath);
|
|
30
|
+
|
|
31
|
+
if (result.valid) {
|
|
32
|
+
log.success('Bundle is valid.');
|
|
33
|
+
} else {
|
|
34
|
+
log.error('Bundle validation failed:');
|
|
35
|
+
for (const err of result.errors) {
|
|
36
|
+
log.error(` ${err}`);
|
|
37
|
+
}
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export default command;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
|
|
3
|
+
import * as log from '../utils/log';
|
|
4
|
+
|
|
5
|
+
const command = defineCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: 'version',
|
|
8
|
+
description: 'Print version information',
|
|
9
|
+
},
|
|
10
|
+
run() {
|
|
11
|
+
log.blank();
|
|
12
|
+
log.header('whatcanirun');
|
|
13
|
+
log.label('Version', '0.1.0');
|
|
14
|
+
log.label('Schema', 'v1');
|
|
15
|
+
log.blank();
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export default command;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// -----------------------------------------------------------------------------
|
|
2
|
+
// Types
|
|
3
|
+
// -----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface DeviceInfo {
|
|
6
|
+
cpu_model: string;
|
|
7
|
+
gpu_model: string;
|
|
8
|
+
ram_gb: number;
|
|
9
|
+
os_name: string;
|
|
10
|
+
os_version: string;
|
|
11
|
+
hostname: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// -----------------------------------------------------------------------------
|
|
15
|
+
// Functions
|
|
16
|
+
// -----------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export function formatSysinfo(device: DeviceInfo): string {
|
|
19
|
+
const lines = [
|
|
20
|
+
`uname: ${process.platform} ${process.arch}`,
|
|
21
|
+
`cpu: ${device.cpu_model}`,
|
|
22
|
+
`gpu: ${device.gpu_model}`,
|
|
23
|
+
`ram: ${device.ram_gb} GB`,
|
|
24
|
+
`os: ${device.os_name} ${device.os_version}`,
|
|
25
|
+
`hostname: ${device.hostname}`,
|
|
26
|
+
];
|
|
27
|
+
return lines.join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function detectDevice(): Promise<DeviceInfo> {
|
|
31
|
+
if (process.platform === 'darwin') return detectMacOS();
|
|
32
|
+
if (process.platform === 'linux') return detectLinux();
|
|
33
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// -----------------------------------------------------------------------------
|
|
37
|
+
// Helpers
|
|
38
|
+
// -----------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
async function exec(cmd: string[]): Promise<string> {
|
|
41
|
+
try {
|
|
42
|
+
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' });
|
|
43
|
+
const text = await new Response(proc.stdout).text();
|
|
44
|
+
const code = await proc.exited;
|
|
45
|
+
if (code !== 0) {
|
|
46
|
+
console.warn(`Warning: \`${cmd.join(' ')}\` exited with code ${code}`);
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
return text.trim();
|
|
50
|
+
} catch (e: unknown) {
|
|
51
|
+
if (e instanceof Error && 'code' in e && (e as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
52
|
+
console.warn(`Warning: command not found: ${cmd[0]}`);
|
|
53
|
+
} else {
|
|
54
|
+
console.warn(
|
|
55
|
+
`Warning: \`${cmd.join(' ')}\` failed: ${e instanceof Error ? e.message : String(e)}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function detectMacOS(): Promise<DeviceInfo> {
|
|
63
|
+
const [cpu, memBytes, osVersion, hostname, gpuRaw] = await Promise.all([
|
|
64
|
+
exec(['sysctl', '-n', 'machdep.cpu.brand_string']),
|
|
65
|
+
exec(['sysctl', '-n', 'hw.memsize']),
|
|
66
|
+
exec(['sw_vers', '-productVersion']),
|
|
67
|
+
exec(['hostname']),
|
|
68
|
+
exec(['system_profiler', 'SPDisplaysDataType', '-detailLevel', 'mini']),
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
let gpu = 'Unknown';
|
|
72
|
+
const chipMatch = gpuRaw.match(/Chipset Model:\s*(.+)/);
|
|
73
|
+
if (chipMatch) {
|
|
74
|
+
gpu = chipMatch[1]!.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
cpu_model: cpu || 'Unknown',
|
|
79
|
+
gpu_model: gpu,
|
|
80
|
+
ram_gb: Math.round(parseInt(memBytes || '0', 10) / 1024 / 1024 / 1024),
|
|
81
|
+
os_name: 'macOS',
|
|
82
|
+
os_version: osVersion || 'Unknown',
|
|
83
|
+
hostname: hostname || 'Unknown',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function detectLinux(): Promise<DeviceInfo> {
|
|
88
|
+
const [cpuinfo, meminfo, osRelease, hostname, gpu] = await Promise.all([
|
|
89
|
+
exec(['cat', '/proc/cpuinfo']),
|
|
90
|
+
exec(['cat', '/proc/meminfo']),
|
|
91
|
+
exec(['cat', '/etc/os-release']),
|
|
92
|
+
exec(['hostname']),
|
|
93
|
+
exec(['nvidia-smi', '--query-gpu=name', '--format=csv,noheader']),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const cpuMatch = cpuinfo.match(/model name\s*:\s*(.+)/);
|
|
97
|
+
const memMatch = meminfo.match(/MemTotal:\s*(\d+)/);
|
|
98
|
+
const osNameMatch = osRelease.match(/PRETTY_NAME="(.+)"/);
|
|
99
|
+
const osVersionMatch = osRelease.match(/VERSION_ID="(.+)"/);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
cpu_model: cpuMatch?.[1]?.trim() || 'Unknown',
|
|
103
|
+
gpu_model: gpu?.split('\n')[0]?.trim() || 'None',
|
|
104
|
+
ram_gb: Math.round(parseInt(memMatch?.[1] || '0', 10) / 1024 / 1024),
|
|
105
|
+
os_name: osNameMatch?.[1] || 'Linux',
|
|
106
|
+
os_version: osVersionMatch?.[1] || 'Unknown',
|
|
107
|
+
hostname: hostname || 'Unknown',
|
|
108
|
+
};
|
|
109
|
+
}
|