fm-bench 0.6.2 → 0.7.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/src/compare.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { stripAnsi } from './ansi.js';
1
2
  import { compareCompatibility } from './schema.js';
2
3
  import { formatMs, formatNumber, formatPercent } from './table.js';
3
4
 
@@ -266,12 +267,19 @@ function applyTone(text, tone) {
266
267
  }
267
268
 
268
269
  function pad(text, width) {
269
- const len = String(text ?? '').length;
270
- return String(text ?? '') + ' '.repeat(Math.max(0, width - len));
270
+ const str = safeCell(text);
271
+ const len = str.length;
272
+ return str + ' '.repeat(Math.max(0, width - len));
271
273
  }
272
274
 
273
275
  function fitCell(text, width) {
274
- const str = String(text ?? '');
276
+ const str = safeCell(text);
275
277
  if (str.length <= width) return str + ' '.repeat(width - str.length);
276
278
  return `${str.slice(0, width - 1)}…`;
277
279
  }
280
+
281
+ // Values can originate from report files that fm-bench did not write, so strip
282
+ // ANSI escapes and control characters before they reach the terminal.
283
+ function safeCell(text) {
284
+ return stripAnsi(String(text ?? '')).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
285
+ }
package/src/fm-help.js ADDED
@@ -0,0 +1,131 @@
1
+ // Pure parsers for `fm` help and status text.
2
+ //
3
+ // These functions take raw text and return plain data so they can be unit
4
+ // tested without spawning a process, and so the capability layer, the model
5
+ // discovery path, and the availability path agree on how `fm` output is read.
6
+
7
+ import { stripAnsi } from './ansi.js';
8
+
9
+ const SECTION_HEADER = /^\s*[A-Z][A-Z0-9 /-]+\s*$/;
10
+
11
+ /**
12
+ * Extract models from the MODELS section of `fm --help`, falling back to a
13
+ * `--model` option list such as `Model to use (system, pcc)`.
14
+ * @param {string} helpText
15
+ * @returns {{ name: string, description: string }[]}
16
+ */
17
+ export function parseModelsFromHelp(helpText = '') {
18
+ const lines = stripAnsi(helpText).split(/\r?\n/);
19
+ const models = new Map();
20
+ let inModels = false;
21
+
22
+ for (const line of lines) {
23
+ if (/^\s*MODELS\s*$/.test(line)) {
24
+ inModels = true;
25
+ continue;
26
+ }
27
+
28
+ if (inModels && SECTION_HEADER.test(line)) {
29
+ inModels = false;
30
+ }
31
+
32
+ if (inModels) {
33
+ const match = line.match(/^\s*([A-Za-z0-9._:-]+)\s{2,}(.+?)\s*$/);
34
+ if (match) {
35
+ const name = match[1];
36
+ const description = match[2].replace(/\s*\(default\)\s*$/, '').trim();
37
+ const existing = models.get(name);
38
+ // The MODELS section is authoritative over a bare option list.
39
+ if (existing) {
40
+ if (!existing.description) existing.description = description;
41
+ } else {
42
+ models.set(name, { name, description });
43
+ }
44
+ }
45
+ }
46
+
47
+ if (/(^|\s)--model\b/.test(line)) {
48
+ const optionMatch = line.match(/\(([^)]*)\)/);
49
+ if (optionMatch) {
50
+ for (const raw of optionMatch[1].split(',')) {
51
+ const name = raw.trim();
52
+ if (/^[A-Za-z0-9._:-]+$/.test(name) && !models.has(name)) {
53
+ models.set(name, { name, description: '' });
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ return [...models.values()];
61
+ }
62
+
63
+ /**
64
+ * Read model names and availability out of `fm available` (no model filter).
65
+ * @param {string} output
66
+ * @returns {{ name: string, available: boolean }[]}
67
+ */
68
+ export function parseAvailabilityList(output = '') {
69
+ const models = new Map();
70
+ for (const line of stripAnsi(output).split(/\r?\n/)) {
71
+ const match = line.match(/^\s*([A-Za-z][A-Za-z0-9._-]*)(?:\s+model)?\s+(?:is\s+)?(available|unavailable|not available)\b/i);
72
+ if (!match) continue;
73
+ const name = match[1].toLowerCase();
74
+ models.set(name, { name, available: /^available$/i.test(match[2]) });
75
+ }
76
+ return [...models.values()];
77
+ }
78
+
79
+ /**
80
+ * Interpret `fm available --model <model>` output.
81
+ *
82
+ * `fm` reports the model plus the word "available" on success. Anything with
83
+ * an explicit error/unavailable marker, or a non-zero exit code, counts as
84
+ * unavailable so a partially supported host is never reported as working.
85
+ *
86
+ * @param {string} model
87
+ * @param {string} output combined stdout+stderr
88
+ * @param {number|null} code process exit code
89
+ */
90
+ export function parseAvailabilityOutput(model, output = '', code = null) {
91
+ const clean = stripAnsi(output).trim();
92
+ const lower = clean.toLowerCase();
93
+ const modelLower = String(model).toLowerCase();
94
+ const hasError = /\berror:|\bunavailable\b|\bnot available\b|\bnot supported\b|\bis invalid for\b/.test(lower);
95
+ const modelPattern = escapeRegExp(modelLower);
96
+ const hasAvailable = new RegExp(`\\b${modelPattern}\\b[\\s\\S]{0,80}\\bavailable\\b`).test(lower)
97
+ || new RegExp(`\\bavailable\\b[\\s\\S]{0,80}\\b${modelPattern}\\b`).test(lower);
98
+
99
+ return {
100
+ model,
101
+ available: code === 0 && hasAvailable && !hasError,
102
+ raw: clean,
103
+ reason: hasError ? firstLine(clean) : ''
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Collapse `fm` diagnostics into one actionable line. `fm` writes multi-line
109
+ * usage blocks for argument errors; benchmark output only needs the cause.
110
+ * @param {string} text
111
+ */
112
+ export function firstLine(text = '') {
113
+ const clean = stripAnsi(text).replace(/\s+/g, ' ').trim();
114
+ if (!clean) return '';
115
+ const sentences = clean.split(/(?<=\.)\s+(?=[A-Z])/);
116
+ const head = sentences.find((part) => /error|invalid|unavailable|not supported|failed/i.test(part)) || sentences[0];
117
+ return head.replace(/^Error:\s*/i, '').trim();
118
+ }
119
+
120
+ /**
121
+ * True when an `fm` diagnostic means "this build does not have that model"
122
+ * rather than "the model exists but is currently unusable".
123
+ * @param {string} text
124
+ */
125
+ export function isUnsupportedModelError(text = '') {
126
+ return /is invalid for '--model|unknown model|no such model|unrecognized model/i.test(stripAnsi(text));
127
+ }
128
+
129
+ function escapeRegExp(value) {
130
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ }
package/src/fm.js CHANGED
@@ -1,60 +1,16 @@
1
- import crypto from 'node:crypto';
2
1
  import os from 'node:os';
3
2
  import { stripAnsi } from './ansi.js';
3
+ import { detectFmCapabilities } from './capabilities.js';
4
+ import { firstLine, isUnsupportedModelError, parseAvailabilityOutput } from './fm-help.js';
4
5
  import { runProcess } from './process.js';
5
6
  import { parseBatteryOutput, parseThermalOutput } from './system.js';
6
7
 
7
- const DEFAULT_MODELS = [
8
- { name: 'system', description: 'On-device Apple Foundation Model' },
9
- { name: 'pcc', description: 'Apple Foundation Model on Private Cloud Compute' }
10
- ];
8
+ export { parseModelsFromHelp, parseAvailabilityOutput } from './fm-help.js';
11
9
 
12
10
  export function fmBinaryFromOptions(options = {}) {
13
11
  return options.fmBin || process.env.FM_BIN || 'fm';
14
12
  }
15
13
 
16
- export function parseModelsFromHelp(helpText) {
17
- const clean = stripAnsi(helpText);
18
- const lines = clean.split(/\r?\n/);
19
- const models = new Map();
20
- let inModels = false;
21
-
22
- for (const line of lines) {
23
- if (/^\s*MODELS\s*$/.test(line)) {
24
- inModels = true;
25
- continue;
26
- }
27
-
28
- if (inModels && /^\s*[A-Z][A-Z -]+\s*$/.test(line) && !/^\s*MODELS\s*$/.test(line)) {
29
- inModels = false;
30
- }
31
-
32
- if (inModels) {
33
- const match = line.match(/^\s*([A-Za-z0-9._:-]+)\s{2,}(.+?)\s*$/);
34
- if (match) {
35
- models.set(match[1], {
36
- name: match[1],
37
- description: match[2].replace(/\s*\(default\)\s*$/, '').trim()
38
- });
39
- }
40
- }
41
-
42
- const optionMatch = /--model\b/.test(line)
43
- ? line.match(/\bmodel\b.*?\(([^)]+)\)/i)
44
- : null;
45
- if (optionMatch) {
46
- for (const raw of optionMatch[1].split(',')) {
47
- const name = raw.trim();
48
- if (/^[A-Za-z0-9._:-]+$/.test(name) && !models.has(name)) {
49
- models.set(name, { name, description: '' });
50
- }
51
- }
52
- }
53
- }
54
-
55
- return [...models.values()];
56
- }
57
-
58
14
  export async function getFmHelp(fmBin, timeoutMs = 10_000) {
59
15
  const result = await runProcess(fmBin, ['--help'], { timeoutMs });
60
16
  if (result.error) {
@@ -69,40 +25,42 @@ export async function getFmHelp(fmBin, timeoutMs = 10_000) {
69
25
  };
70
26
  }
71
27
 
28
+ /**
29
+ * Discover models, preferring an already-detected capability probe so a run
30
+ * does not spawn `fm --help` more than once.
31
+ * @param {Record<string, any>} options
32
+ */
72
33
  export async function discoverModels(options = {}) {
73
34
  const fmBin = fmBinaryFromOptions(options);
74
- const help = await getFmHelp(fmBin, options.timeoutMs ?? 10_000);
75
- let models = parseModelsFromHelp(help.text);
76
-
77
- if (models.length === 0 && /Apple Foundation Models CLI/i.test(stripAnsi(help.text))) {
78
- models = DEFAULT_MODELS;
79
- }
80
-
35
+ const capabilities = options.capabilities ?? await detectFmCapabilities(fmBin, options);
81
36
  return {
82
37
  fmBin,
83
- models,
84
- help: stripAnsi(help.text)
85
- };
86
- }
87
-
88
- export function parseAvailabilityOutput(model, output, code) {
89
- const clean = stripAnsi(output).trim();
90
- const lower = clean.toLowerCase();
91
- const modelLower = model.toLowerCase();
92
- const hasError = /\berror:|\bunavailable\b|\bnot available\b|\bnot supported\b/.test(lower);
93
- const hasAvailable = new RegExp(`\\b${escapeRegExp(modelLower)}\\b[\\s\\S]{0,80}\\bavailable\\b|\\bavailable\\b[\\s\\S]{0,80}\\b${escapeRegExp(modelLower)}\\b`).test(lower)
94
- || lower.includes(`${modelLower} model available`)
95
- || lower.includes(`${titleCase(modelLower)} model available`.toLowerCase());
96
-
97
- return {
98
- model,
99
- available: code === 0 && hasAvailable && !hasError,
100
- raw: clean,
101
- reason: hasError ? clean : ''
38
+ models: capabilities.models,
39
+ help: capabilities.help,
40
+ capabilities
102
41
  };
103
42
  }
104
43
 
44
+ /**
45
+ * Check one model against the detected `fm` build.
46
+ *
47
+ * Models the build does not expose are reported as unsupported without
48
+ * spawning `fm` at all, so a raw argument-error blob from `fm` can never end
49
+ * up in a report or in `fm-bench models` output.
50
+ */
105
51
  export async function checkModelAvailability(fmBin, model, options = {}) {
52
+ const capabilities = options.capabilities;
53
+ const known = capabilities?.models?.map((entry) => entry.name) ?? [];
54
+ if (capabilities && known.length > 0 && !known.includes(model)) {
55
+ return {
56
+ model,
57
+ available: false,
58
+ unsupported: true,
59
+ raw: '',
60
+ reason: `not supported by this fm build (supported: ${known.join(', ')})`
61
+ };
62
+ }
63
+
106
64
  const result = await runProcess(fmBin, ['available', '--model', model], {
107
65
  timeoutMs: options.timeoutMs ?? 15_000
108
66
  });
@@ -111,53 +69,99 @@ export async function checkModelAvailability(fmBin, model, options = {}) {
111
69
  if (result.error) {
112
70
  parsed.available = false;
113
71
  parsed.reason = result.stderr || result.error.message;
72
+ return parsed;
73
+ }
74
+ if (isUnsupportedModelError(output)) {
75
+ parsed.available = false;
76
+ parsed.unsupported = true;
77
+ const supported = known.length > 0 ? ` (supported: ${known.join(', ')})` : '';
78
+ parsed.reason = `not supported by this fm build${supported}`;
114
79
  }
115
80
  return parsed;
116
81
  }
117
82
 
83
+ /**
84
+ * Query quota information when the build exposes it.
85
+ * @returns {Promise<{ model: string, supported: boolean, ok: boolean, raw: string, reason: string }>}
86
+ */
118
87
  export async function getQuotaUsage(fmBin, model, options = {}) {
88
+ const features = options.capabilities?.features;
89
+ if (features && !features.quota) {
90
+ return {
91
+ model,
92
+ supported: false,
93
+ ok: false,
94
+ raw: '',
95
+ reason: 'unavailable: this fm build exposes no quota command'
96
+ };
97
+ }
98
+
119
99
  const result = await runProcess(fmBin, ['quota-usage', '--model', model], {
120
100
  timeoutMs: options.timeoutMs ?? 15_000
121
101
  });
122
102
  const output = stripAnsi(`${result.stdout}${result.stderr}`).trim();
123
103
  return {
124
104
  model,
105
+ supported: true,
125
106
  ok: result.code === 0,
126
107
  raw: output,
127
- unavailable: /\bunavailable\b|\bnot available\b|\berror:/i.test(output)
108
+ reason: result.code === 0 ? '' : firstLine(output)
128
109
  };
129
110
  }
130
111
 
112
+ /**
113
+ * Count tokens with whichever token-counting command this `fm` build exposes.
114
+ * Returns `ok: false` with a reason when the build cannot count tokens; it
115
+ * never invents a count.
116
+ */
131
117
  export async function countTokens(fmBin, text, options = {}) {
132
- const result = await runProcess(fmBin, ['token-count', '--quiet'], {
118
+ const command = options.capabilities?.features?.tokenCountCommand ?? 'count-tokens';
119
+ const supported = options.capabilities?.features?.tokenCounting ?? true;
120
+ if (!supported || !command) {
121
+ return {
122
+ ok: false,
123
+ count: null,
124
+ unsupported: true,
125
+ raw: '',
126
+ reason: 'token counting is unavailable in this fm build'
127
+ };
128
+ }
129
+
130
+ const result = await runProcess(fmBin, [command, '--quiet'], {
133
131
  input: text,
134
132
  timeoutMs: options.timeoutMs ?? 15_000
135
133
  });
136
134
  const output = stripAnsi(`${result.stdout}${result.stderr}`).trim();
137
- const match = output.match(/-?\d+/);
138
- if (result.code !== 0 || !match) {
135
+ const match = output.match(/\d+/);
136
+ if (result.error || result.code !== 0 || !match) {
139
137
  return {
140
138
  ok: false,
141
139
  count: null,
142
- raw: output
140
+ raw: output,
141
+ reason: result.error?.message || firstLine(output) || `fm ${command} exited with code ${result.code}`
143
142
  };
144
143
  }
145
144
  return {
146
145
  ok: true,
147
146
  count: Number.parseInt(match[0], 10),
148
- raw: output
147
+ raw: output,
148
+ reason: ''
149
149
  };
150
150
  }
151
151
 
152
152
  export async function respond(fmBin, model, prompt, options = {}) {
153
- const args = ['respond', '--model', model];
154
- const streamed = options.stream !== false;
155
-
156
- if (!streamed) args.push('--no-stream');
157
- if (options.greedy) args.push('--greedy');
158
- if (options.instructions) args.push('--instructions', options.instructions);
159
- if (options.useCase) args.push('--use-case', options.useCase);
160
- if (options.guardrails) args.push('--guardrails', options.guardrails);
153
+ const features = options.capabilities?.features;
154
+ const streamControl = features ? features.streaming : true;
155
+ const modelSelection = features ? features.modelSelection : true;
156
+ const streamed = streamControl && options.stream !== false;
157
+
158
+ const args = ['respond'];
159
+ if (modelSelection) args.push('--model', model);
160
+ if (streamControl && !streamed) args.push('--no-stream');
161
+ if (options.greedy && (features?.greedy ?? true)) args.push('--greedy');
162
+ if (options.instructions && (features?.instructions ?? true)) args.push('--instructions', options.instructions);
163
+ if (options.useCase && (features?.useCase ?? true)) args.push('--use-case', options.useCase);
164
+ if (options.guardrails && (features?.guardrails ?? true)) args.push('--guardrails', options.guardrails);
161
165
 
162
166
  const result = await runProcess(fmBin, args, {
163
167
  input: prompt,
@@ -166,8 +170,10 @@ export async function respond(fmBin, model, prompt, options = {}) {
166
170
 
167
171
  const output = stripAnsi(result.stdout).trim();
168
172
  const errorText = stripAnsi(result.stderr).trim();
173
+ const failed = result.code !== 0 || result.timedOut;
174
+
169
175
  return {
170
- ok: result.code === 0 && !result.timedOut,
176
+ ok: !failed,
171
177
  model,
172
178
  prompt,
173
179
  output,
@@ -179,11 +185,17 @@ export async function respond(fmBin, model, prompt, options = {}) {
179
185
  firstOutputMs: streamed ? result.firstStdoutMs : null,
180
186
  streamed,
181
187
  stdoutChunks: result.stdoutChunks,
182
- stdoutChunkTimesMs: streamed ? result.stdoutChunkTimesMs : []
188
+ stdoutChunkTimesMs: streamed ? result.stdoutChunkTimesMs : [],
189
+ error: failed
190
+ ? (result.timedOut
191
+ ? `timed out after ${options.timeoutMs ?? 60_000}ms`
192
+ : firstLine(errorText) || `fm exited with code ${result.code ?? result.signal}`)
193
+ : ''
183
194
  };
184
195
  }
185
196
 
186
- export async function collectEnvironment(fmBin) {
197
+ export async function collectEnvironment(fmBin, options = {}) {
198
+ const capabilities = options.capabilities;
187
199
  const swVers = await runProcess('sw_vers', [], { timeoutMs: 5_000 });
188
200
  const macOS = stripAnsi(swVers.stdout).trim() || null;
189
201
 
@@ -196,15 +208,15 @@ export async function collectEnvironment(fmBin) {
196
208
  const batteryResult = await runProcess('pmset', ['-g', 'batt'], { timeoutMs: 5_000 });
197
209
  const battery = parseBatteryOutput(`${batteryResult.stdout || ''}${batteryResult.stderr || ''}`);
198
210
 
199
- let fmHelpDigest = null;
200
- try {
201
- const help = await getFmHelp(fmBin, 10_000);
202
- const text = stripAnsi(help.text).trim();
203
- if (text) {
204
- fmHelpDigest = crypto.createHash('sha256').update(text).digest('hex').slice(0, 16);
211
+ let fmHelpDigest = capabilities?.digest ?? null;
212
+ if (fmHelpDigest == null) {
213
+ try {
214
+ const help = await getFmHelp(fmBin, 10_000);
215
+ const detected = await detectFmCapabilities(fmBin, { help: { text: help.text } });
216
+ fmHelpDigest = detected.digest;
217
+ } catch {
218
+ fmHelpDigest = null;
205
219
  }
206
- } catch {
207
- fmHelpDigest = null;
208
220
  }
209
221
 
210
222
  const memRaw = (memBytes.stdout || '').trim();
@@ -237,11 +249,3 @@ export async function collectEnvironment(fmBin) {
237
249
  : null
238
250
  };
239
251
  }
240
-
241
- function escapeRegExp(value) {
242
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
243
- }
244
-
245
- function titleCase(value) {
246
- return value.slice(0, 1).toUpperCase() + value.slice(1);
247
- }
package/src/history.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { stripAnsi } from './ansi.js';
3
4
  import { formatMs, formatNumber, formatPercent } from './table.js';
4
5
 
5
6
  export async function loadHistory(dir) {
@@ -100,7 +101,7 @@ export function renderHistoryReport(reports, options = {}) {
100
101
  }
101
102
 
102
103
  function fit(text, width) {
103
- const str = String(text ?? '');
104
+ const str = stripAnsi(String(text ?? '')).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
104
105
  if (str.length <= width) return str + ' '.repeat(width - str.length);
105
106
  return `${str.slice(0, width - 1)}…`;
106
107
  }
package/src/macos.js ADDED
@@ -0,0 +1,72 @@
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
+ 'Pass --fm-bin <path> (or set FM_BIN) to benchmark an fm binary you provide on this host.'
62
+ ].join('\n');
63
+ }
64
+
65
+ export async function detectMacosVersion(options = {}) {
66
+ const timeoutMs = options.timeoutMs ?? 5_000;
67
+ if (typeof options.swVers === 'function') {
68
+ return options.swVers();
69
+ }
70
+ const result = await runProcess('sw_vers', [], { timeoutMs });
71
+ return result.stdout || result.stderr;
72
+ }