fm-bench 0.6.2 → 0.6.3

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 CHANGED
@@ -30,7 +30,7 @@ fm-bench
30
30
  One command discovers your models, runs the standard prompt suite, and prints a full benchmark report:
31
31
 
32
32
  ```text
33
- fm-bench 0.6.2 | darwin/arm64 | fm
33
+ fm-bench 0.6.3 | darwin/arm64 | fm
34
34
  prompts 3 | runs 5 | concurrency 1 | stream on | measured 15 | failed 0 | skipped 0 | elapsed 38.20s
35
35
 
36
36
  ┌───┬────────┬────────┬─────────┬──────┬──────┬──────────┬──────┬──────────┬─────┬─────┐
@@ -273,10 +273,12 @@ A live single-line progress indicator runs on stderr during interactive sessions
273
273
 
274
274
  ## Requirements
275
275
 
276
- - macOS 27 or newer (Apple's `fm` CLI is preinstalled).
276
+ - macOS 27.0 or newer (Apple's `fm` CLI is preinstalled there).
277
277
  - Node.js 20 or newer.
278
278
  - Apple Intelligence enabled on the device.
279
279
 
280
+ Benchmark commands refuse to start on older macOS versions and report the detected version plus the latest supported macOS — see [docs/supported-platforms.md](docs/supported-platforms.md).
281
+
280
282
  `pcc` (Private Cloud Compute) availability depends on Apple's current eligibility. `fm-bench` shows it as skipped if `fm available --model pcc` reports unavailable.
281
283
 
282
284
  See [docs/methodology.md](docs/methodology.md) for benchmark methodology and metric references.
package/docs/releasing.md CHANGED
@@ -17,7 +17,7 @@
17
17
  ## Option B — Local
18
18
 
19
19
  ```sh
20
- npm ci && npm test && npm run lint
20
+ npm ci && npm test && npm run lint && npm run check:pack
21
21
  npm version patch # or minor / major
22
22
  git push --follow-tags
23
23
  ```
@@ -37,6 +37,7 @@ gh release edit v0.6.0 --notes-file /tmp/notes.md --repo devinoldenburg/fm-bench
37
37
 
38
38
  ```sh
39
39
  npm run publish:dry-run
40
+ npm run check:pack
40
41
  ```
41
42
 
42
43
  CI runs the same dry-run on every push to `main` and on pull requests.
@@ -0,0 +1,26 @@
1
+ # Supported platforms
2
+
3
+ `fm-bench` benchmarks Apple's `fm` command, so support follows the platforms where Apple ships that CLI.
4
+
5
+ ## Requirements
6
+
7
+ - **macOS 27.0 or newer** — Apple's `fm` CLI is preinstalled starting with macOS 27. Older macOS releases do not ship `fm`, so `fm-bench` cannot run there.
8
+ - **Node.js 20 or newer**.
9
+ - **Apple Intelligence enabled** on the device.
10
+
11
+ `pcc` (Private Cloud Compute) availability additionally depends on Apple's current eligibility. `fm-bench` reports it as skipped when `fm available --model pcc` reports unavailable.
12
+
13
+ ## Version enforcement
14
+
15
+ Commands that launch `fm` benchmarks — the default `run` command and `models` — check the macOS version first and refuse to start on anything older than macOS 27:
16
+
17
+ ```text
18
+ fm-bench: unsupported macOS: detected macOS 26.1, but fm-bench requires macOS 27 or newer (Apple's fm CLI is preinstalled there).
19
+ Latest supported: macOS 27.0 or newer (fm is not available on older macOS releases).
20
+ ```
21
+
22
+ The process exits with code `2`. This is deliberate: running on an unsupported macOS could never produce a valid benchmark, so the CLI fails fast with the exact version it found and the latest supported macOS version.
23
+
24
+ Commands that only read local report files — `compare`, `history`, `validate`, `export`, and `legend` — are not gated and work anywhere Node.js runs.
25
+
26
+ `fm-bench doctor` still runs on unsupported hosts so you can diagnose the environment: it prints the detected macOS version, an explicit `macOS support` line, and the latest supported version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fm-bench",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Dynamic benchmark CLI for Apple's fm command on macOS 27+.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,11 +16,12 @@
16
16
  "scripts": {
17
17
  "test": "node --test",
18
18
  "lint": "node --check bin/fm-bench.js && find src test -name '*.js' -print0 | xargs -0 -n1 node --check",
19
- "prepack": "npm test && npm run lint",
19
+ "prepack": "npm test && npm run lint && npm run check:pack",
20
20
  "release:patch": "npm version patch && git push --follow-tags",
21
21
  "release:minor": "npm version minor && git push --follow-tags",
22
22
  "release:major": "npm version major && git push --follow-tags",
23
- "publish:dry-run": "npm publish --dry-run --access public"
23
+ "publish:dry-run": "npm publish --dry-run --access public",
24
+ "check:pack": "node scripts/check-package.mjs"
24
25
  },
25
26
  "keywords": [
26
27
  "apple",
@@ -46,5 +47,8 @@
46
47
  "publishConfig": {
47
48
  "access": "public",
48
49
  "registry": "https://registry.npmjs.org/"
49
- }
50
+ },
51
+ "os": [
52
+ "darwin"
53
+ ]
50
54
  }
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ import { diffReports, renderCompareReport } from './compare.js';
5
5
  import { renderHtmlReport } from './export.js';
6
6
  import { validateReport } from './schema.js';
7
7
  import { loadHistory, renderHistoryReport } from './history.js';
8
+ import { detectMacosVersion, evaluateMacosSupport, formatMacosRequirementError, MIN_SUPPORTED_MACOS, parseMacosVersion } from './macos.js';
8
9
  import { runProcess } from './process.js';
9
10
  import { createProgress } from './progress.js';
10
11
  import { flattenResults, toCsv, writeReport } from './report.js';
@@ -14,7 +15,7 @@ import { legendEntries, renderBenchmarkReport, renderLatencyHistogram, renderLeg
14
15
  const require = createRequire(import.meta.url);
15
16
  const packageJson = require('../package.json');
16
17
 
17
- export async function runCli(argv = process.argv.slice(2)) {
18
+ export async function runCli(argv = process.argv.slice(2), env = {}) {
18
19
  const parsed = parseArgs(argv);
19
20
 
20
21
  if (parsed.help) {
@@ -27,6 +28,8 @@ export async function runCli(argv = process.argv.slice(2)) {
27
28
  return;
28
29
  }
29
30
 
31
+ await assertSupportedMacos(parsed, env);
32
+
30
33
  if (parsed.command === 'legend') {
31
34
  if (parsed.format === 'json') {
32
35
  console.log(JSON.stringify(legendEntries(), null, 2));
@@ -153,6 +156,25 @@ export async function runCli(argv = process.argv.slice(2)) {
153
156
  }
154
157
  }
155
158
 
159
+ // Offline report tools stay usable anywhere. `doctor` is also allowed through so
160
+ // it can print the detected version and the latest supported macOS when the host
161
+ // is too old. Only commands that launch `fm` benchmarks are hard-gated.
162
+ const SKIP_MACOS_GATE = new Set(['compare', 'history', 'validate', 'export', 'legend', 'doctor']);
163
+
164
+ async function assertSupportedMacos(parsed, env = {}) {
165
+ if (SKIP_MACOS_GATE.has(parsed.command)) return;
166
+
167
+ const evaluation = evaluateMacosSupport(
168
+ env.platform ?? process.platform,
169
+ parseMacosVersion(await detectMacosVersion(env))
170
+ );
171
+ if (evaluation.supported) return;
172
+
173
+ const error = new Error(formatMacosRequirementError(evaluation));
174
+ error.exitCode = 2;
175
+ throw error;
176
+ }
177
+
156
178
  function evaluateCi(payload) {
157
179
  const reasons = [];
158
180
  const totalFailed = payload.summary.reduce((sum, item) => sum + item.failures, 0);
@@ -554,11 +576,14 @@ async function runDoctor(options) {
554
576
  checks.push(['node', process.version, true]);
555
577
  checks.push(['platform', `${process.platform}/${process.arch}`, process.platform === 'darwin']);
556
578
 
557
- const swVers = await runProcess('sw_vers', [], { timeoutMs: 5_000 });
558
- const macOS = swVers.stdout || swVers.stderr;
559
- const versionMatch = macOS.match(/ProductVersion:\s*([0-9.]+)/);
560
- const major = versionMatch ? Number.parseInt(versionMatch[1].split('.')[0], 10) : null;
561
- checks.push(['macOS', versionMatch?.[1] || 'unknown', major == null || major >= 27]);
579
+ const macOS = await detectMacosVersion();
580
+ const parsedVersion = parseMacosVersion(macOS);
581
+ const support = evaluateMacosSupport(process.platform, parsedVersion);
582
+ checks.push(['macOS', parsedVersion?.version || 'unknown', support.supported]);
583
+ if (!support.supported) {
584
+ checks.push(['macOS support', support.reason, false]);
585
+ checks.push(['latest supported', support.latestSupported, false]);
586
+ }
562
587
 
563
588
  const hwModel = await runProcess('sysctl', ['-n', 'hw.model'], { timeoutMs: 3_000 });
564
589
  const hwModelStr = (hwModel.stdout || '').trim();
@@ -595,17 +620,23 @@ async function runDoctor(options) {
595
620
  checks.push(['battery', `${pct} (${source})`, ok]);
596
621
  }
597
622
 
598
- const inspection = await inspectModels(options);
599
- checks.push(['fm', inspection.fmBin, inspection.models.length > 0]);
600
- for (const model of inspection.models) {
601
- checks.push([`model:${model.name}`, model.available ? 'available' : model.reason || 'unavailable', model.available]);
623
+ let models = [];
624
+ try {
625
+ const inspection = await inspectModels(options);
626
+ models = inspection.models;
627
+ checks.push(['fm', inspection.fmBin, inspection.models.length > 0]);
628
+ for (const model of inspection.models) {
629
+ checks.push([`model:${model.name}`, model.available ? 'available' : model.reason || 'unavailable', model.available]);
630
+ }
631
+ } catch (error) {
632
+ checks.push(['fm', error.message || String(error), false]);
602
633
  }
603
634
 
604
635
  const lines = checks.map(([name, detail, ok]) => `${ok ? 'ok ' : 'warn'} ${name.padEnd(16)} ${String(detail).replace(/\s+/g, ' ').trim()}`);
605
636
  console.log(lines.join('\n'));
606
637
 
607
638
  if (options.out) {
608
- await fs.writeFile(options.out, `${JSON.stringify({ checks, models: inspection.models }, null, 2)}\n`, 'utf8');
639
+ await fs.writeFile(options.out, `${JSON.stringify({ checks, models }, null, 2)}\n`, 'utf8');
609
640
  }
610
641
  }
611
642
 
@@ -669,7 +700,7 @@ function resolveProgress(parsed) {
669
700
  function helpText() {
670
701
  return `fm-bench ${packageJson.version}
671
702
 
672
- Dynamic benchmark CLI for Apple's fm command on macOS 27+.
703
+ Dynamic benchmark CLI for Apple's fm command on macOS ${MIN_SUPPORTED_MACOS}+.
673
704
 
674
705
  Usage:
675
706
  fm-bench [run] [options]
package/src/macos.js ADDED
@@ -0,0 +1,71 @@
1
+ import { runProcess } from './process.js';
2
+
3
+ // macOS releases that ship Apple's `fm` CLI. Older macOS versions cannot run
4
+ // fm-bench at all because the benchmarked binary does not exist there.
5
+ export const SUPPORTED_MACOS_MAJOR = 27;
6
+
7
+ export const MIN_SUPPORTED_MACOS = `${SUPPORTED_MACOS_MAJOR}.0`;
8
+
9
+ export const MACOS_REQUIREMENT_MESSAGE =
10
+ `fm-bench requires macOS ${SUPPORTED_MACOS_MAJOR} or newer (Apple's fm CLI is preinstalled there).`;
11
+
12
+ export function parseMacosVersion(text = '') {
13
+ const match = String(text).match(/ProductVersion:\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
14
+ if (!match) return null;
15
+ return {
16
+ major: Number.parseInt(match[1], 10),
17
+ minor: match[2] != null ? Number.parseInt(match[2], 10) : 0,
18
+ patch: match[3] != null ? Number.parseInt(match[3], 10) : 0,
19
+ version: [match[1], match[2], match[3]].filter((part) => part != null).join('.')
20
+ };
21
+ }
22
+
23
+ export function isMacosSupported(parsed) {
24
+ return parsed != null && Number.isInteger(parsed.major) && parsed.major >= SUPPORTED_MACOS_MAJOR;
25
+ }
26
+
27
+ export function evaluateMacosSupport(platform, parsed) {
28
+ if (platform !== 'darwin') {
29
+ return {
30
+ supported: false,
31
+ reason: `fm-bench only runs on macOS with Apple's fm CLI (detected platform: ${platform}).`,
32
+ latestSupported: `macOS ${MIN_SUPPORTED_MACOS} or newer`
33
+ };
34
+ }
35
+ if (parsed == null) {
36
+ return {
37
+ supported: true,
38
+ warnOnly: true,
39
+ reason: 'could not determine the macOS version',
40
+ latestSupported: `macOS ${MIN_SUPPORTED_MACOS} or newer`
41
+ };
42
+ }
43
+ if (isMacosSupported(parsed)) {
44
+ return {
45
+ supported: true,
46
+ reason: null,
47
+ latestSupported: `macOS ${MIN_SUPPORTED_MACOS} or newer`
48
+ };
49
+ }
50
+ return {
51
+ supported: false,
52
+ reason: `detected macOS ${parsed.version}, but ${MACOS_REQUIREMENT_MESSAGE}`,
53
+ latestSupported: `macOS ${MIN_SUPPORTED_MACOS} or newer`
54
+ };
55
+ }
56
+
57
+ export function formatMacosRequirementError({ reason, latestSupported }) {
58
+ return [
59
+ `unsupported macOS: ${reason}`,
60
+ `Latest supported: ${latestSupported} (fm is not available on older macOS releases).`
61
+ ].join('\n');
62
+ }
63
+
64
+ export async function detectMacosVersion(options = {}) {
65
+ const timeoutMs = options.timeoutMs ?? 5_000;
66
+ if (typeof options.swVers === 'function') {
67
+ return options.swVers();
68
+ }
69
+ const result = await runProcess('sw_vers', [], { timeoutMs });
70
+ return result.stdout || result.stderr;
71
+ }