ravensight-playtest 0.2.9 → 0.3.1

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.
@@ -10,6 +10,10 @@ import { inspectGodot } from '../godot.js';
10
10
  import { CLI_VERSION, compareVersions } from '../version.js';
11
11
  import { ui } from '../ui/index.js';
12
12
  import { ExitCode } from '../errors.js';
13
+ import { completenessFor } from './brief.js';
14
+ import { WEBDRIVER_NAMES } from '../run/drivers/webdriver.js';
15
+ import { inspectNativeBuild, probeAutomation, platformWords } from '../run/drivers/webdriver-probe.js';
16
+ import path from 'node:path';
13
17
 
14
18
  export const NODE_MIN_MAJOR = 22;
15
19
  const DISK_WARN_BYTES = 2 * 1024 * 1024 * 1024;
@@ -88,6 +92,96 @@ export async function probeBrowser(importer) {
88
92
  }
89
93
  }
90
94
 
95
+ /** Said by `check` every time, in both outputs, because a customer asked. */
96
+ export const NO_CHARGE_NOTE = 'check reserves no units and starts no job: every request it makes is a free read, and the automation probe talks only to your own local server.';
97
+
98
+ /** `Jump Bunny (g1)`, or just the id when the game has no name. */
99
+ const gameLabel = game => (game.name ? `${game.name} (${game.gameId})` : game.gameId);
100
+
101
+ /**
102
+ * The driver this directory would run with, and the target it would point at,
103
+ * resolved the way `run` resolves them. The flag wins, then the repo config.
104
+ *
105
+ * @param {Object} flags
106
+ * @param {Object|null} config
107
+ * @returns {{driverName: string, native: boolean, target: string|null}}
108
+ */
109
+ export function plannedRun(flags = {}, config = null) {
110
+ const driverName = flags.driver || (config && config.driver) || 'playwright_web';
111
+ const build = (config && config.build) || {};
112
+ let target = null;
113
+ if (WEBDRIVER_NAMES.includes(driverName) || driverName === 'cli_stdio') {
114
+ target = flags.driverConfig || (build.kind === 'driver_config' ? build.ref : null) || null;
115
+ } else if (driverName === 'playwright_web') {
116
+ target = flags.buildUrl || (build.kind === 'url' ? build.ref : null) || null;
117
+ } else if (driverName === 'godot_driver') {
118
+ target = flags.godotProject || (build.kind === 'godot_project' ? build.ref : null) || null;
119
+ }
120
+ return { driverName, native: WEBDRIVER_NAMES.includes(driverName), target };
121
+ }
122
+
123
+ /**
124
+ * The automation rows: the native build, then the automation server. These
125
+ * are the rows `run` repeats as its preflight before it registers a native job,
126
+ * so they take nothing from the account and need no token.
127
+ *
128
+ * @param {{driverName: string, target: string|null, repoRoot?: string}} plan
129
+ * @param {{fetch?: typeof fetch, probeAutomation?: Function, timeouts?: Object}} [deps]
130
+ * @returns {Promise<{rows: Array, automation: Object, build: Object}>}
131
+ */
132
+ export async function automationChecks(plan, deps = {}) {
133
+ const repoRoot = plan.repoRoot || process.cwd();
134
+ const targetPath = plan.target ? path.resolve(repoRoot, plan.target) : null;
135
+ const words = platformWords(plan.driverName);
136
+ const onRow = deps.onRow || (() => {});
137
+ const rows = [];
138
+ const add = entry => {
139
+ rows.push(entry);
140
+ onRow(entry);
141
+ };
142
+
143
+ const build = await inspectNativeBuild({ target: targetPath, repoRoot, driverName: plan.driverName });
144
+ add(row('build', build.status, build.detail, build.fix));
145
+
146
+ const probe = await (deps.probeAutomation || probeAutomation)({
147
+ driverName: plan.driverName,
148
+ target: targetPath,
149
+ fetch: deps.fetch,
150
+ timeouts: deps.timeouts,
151
+ onProgress: deps.onProgress
152
+ });
153
+ const fixes = {
154
+ config: 'write a driver config with a loopback endpoint and W3C capabilities; see docs/NATIVE_RUNNER.md',
155
+ reachable: 'start your local W3C automation server (see docs/NATIVE_RUNNER.md) and confirm the endpoint in the driver config',
156
+ session: words.session,
157
+ session_timeout: 'restart the automation server, or wait for it to finish, then run check again before starting a run',
158
+ app: words.app
159
+ };
160
+ if (probe.failed) {
161
+ add(row('automation server', 'fail', probe.detail, fixes[probe.failed]));
162
+ } else if (probe.closed === false) {
163
+ add(row(
164
+ 'automation server',
165
+ 'warn',
166
+ probe.detail,
167
+ 'the probe session could not be closed; restart the automation server if the device stays busy'
168
+ ));
169
+ } else {
170
+ add(row('automation server', 'pass', probe.detail));
171
+ }
172
+
173
+ return {
174
+ rows,
175
+ build: { ref: build.ref, status: build.status },
176
+ automation: {
177
+ endpoint: probe.endpoint,
178
+ reachable: probe.reachable,
179
+ session: probe.session,
180
+ app: probe.app
181
+ }
182
+ };
183
+ }
184
+
91
185
  /**
92
186
  * Every environment check, as data.
93
187
  *
@@ -96,30 +190,50 @@ export async function probeBrowser(importer) {
96
190
  * in process server with no terminal involved.
97
191
  *
98
192
  * Status meanings are load bearing. `fail` is "this command cannot work", and
99
- * it is what sets exit code 3. `warn` is "something optional is missing", and
100
- * CI must not fail on it: ffmpeg and Godot are genuinely optional, and a run
101
- * that never touches them should not be blocked because they are absent.
193
+ * it is what sets exit code 3. `warn` is "something optional is missing, or
194
+ * could not be checked", and CI must not fail on it: ffmpeg and Godot are
195
+ * genuinely optional, and a run that never touches them should not be blocked
196
+ * because they are absent. `pass` is checked and fine.
197
+ *
198
+ * The setup rows are separate on purpose: CLI version, login, project binding,
199
+ * brief, build and (for a native driver) the automation server. A customer was
200
+ * told login was still needed when the login was fine and the missing piece
201
+ * was the automation server; each row now answers one question.
202
+ *
203
+ * Nothing here reserves units or starts a job. The estimate, whoami, brief and
204
+ * pack requests are reads.
102
205
  *
103
206
  * @param {Object} [flags]
104
207
  * @param {string} [flags.apiUrl]
105
208
  * @param {string} [flags.game]
209
+ * @param {string} [flags.driver]
210
+ * @param {string} [flags.driverConfig]
106
211
  * @param {string[]} [flags.modules]
107
212
  * @param {string[]} [flags.personas]
108
213
  * @param {string} [flags.repoRoot]
109
214
  * @param {boolean} [flags.web] probe the browser whatever the config says
110
215
  * @param {Object} [deps]
111
- * @returns {Promise<{rows: Array, ok: boolean}>}
216
+ * @returns {Promise<{rows: Array, ok: boolean, cli: Object, login: Object, project: Object, brief: Object, build: Object, automation: Object|null}>}
112
217
  */
113
218
  export async function runChecks(flags = {}, deps = {}) {
114
219
  const apiUrl = flags.apiUrl || resolveApiUrl();
220
+ const repoRoot = flags.repoRoot || process.cwd();
115
221
  const rows = [];
222
+ const facts = {
223
+ cli: { installed: CLI_VERSION, latest: null, min_supported: null },
224
+ login: { present: false, valid: false, games: [] },
225
+ project: { game: null, environment: null, in_scope: null },
226
+ brief: { present: null, complete: null, missing: [] },
227
+ build: { driver: null, ref: null },
228
+ automation: null
229
+ };
116
230
 
117
231
  /* ------------------------------------------------------------- runtime */
118
232
 
119
233
  const nodeMajor = Number(process.versions.node.split('.')[0]);
120
234
  rows.push(row(
121
235
  'node',
122
- nodeMajor >= NODE_MIN_MAJOR ? 'ok' : 'fail',
236
+ nodeMajor >= NODE_MIN_MAJOR ? 'pass' : 'fail',
123
237
  `v${process.versions.node}`,
124
238
  `install Node ${NODE_MIN_MAJOR} or newer`
125
239
  ));
@@ -130,7 +244,7 @@ export async function runChecks(flags = {}, deps = {}) {
130
244
  let version = null;
131
245
  try {
132
246
  version = await api.cli.version();
133
- rows.push(row('api reachable', 'ok', `${apiUrl} (schema ${version.schema_version})`));
247
+ rows.push(row('api reachable', 'pass', `${apiUrl} (schema ${version.schema_version})`));
134
248
  } catch (error) {
135
249
  rows.push(row(
136
250
  'api reachable',
@@ -140,15 +254,27 @@ export async function runChecks(flags = {}, deps = {}) {
140
254
  ));
141
255
  }
142
256
 
257
+ /* --------------------------------------------------------- cli version */
258
+
143
259
  if (version) {
260
+ facts.cli.latest = version.latest || null;
261
+ facts.cli.min_supported = version.min_supported || null;
144
262
  const tooOld = compareVersions(CLI_VERSION, version.min_supported) < 0;
145
263
  const behind = compareVersions(CLI_VERSION, version.latest) < 0;
264
+ const ahead = compareVersions(CLI_VERSION, version.latest) > 0;
146
265
  rows.push(row(
147
266
  'cli version',
148
- tooOld ? 'fail' : behind ? 'warn' : 'ok',
149
- `${CLI_VERSION} (server wants at least ${version.min_supported}, latest ${version.latest})`,
267
+ tooOld ? 'fail' : behind ? 'warn' : 'pass',
268
+ `installed ${CLI_VERSION}; server latest ${version.latest}, min_supported ${version.min_supported}${ahead ? ' (installed is newer than the server lists)' : ''}`,
150
269
  tooOld || behind ? 'npm install -g ravensight-playtest@latest' : null
151
270
  ));
271
+ } else {
272
+ rows.push(row(
273
+ 'cli version',
274
+ 'warn',
275
+ `installed ${CLI_VERSION}; the server's latest and min_supported could not be read`,
276
+ 'fix api reachable, then run check again'
277
+ ));
152
278
  }
153
279
 
154
280
  /* ------------------------------------------------------- the credential */
@@ -169,138 +295,223 @@ export async function runChecks(flags = {}, deps = {}) {
169
295
  };
170
296
  rows.push(row(
171
297
  'credential store',
172
- 'ok',
298
+ 'pass',
173
299
  storeDetail(),
174
300
  source === 'file' || (source === null && backend === 'file')
175
301
  ? 'install libsecret for a real keychain on Linux'
176
302
  : null
177
303
  ));
178
304
 
305
+ /* ---------------------------------------------------------------- login */
306
+
307
+ facts.login.present = Boolean(token);
308
+ let whoami = null;
309
+ let authed = null;
179
310
  if (looksLikeIngestKey(token)) {
180
311
  rows.push(row(
181
- 'token valid',
312
+ 'login',
182
313
  'fail',
183
314
  'that is an ingest key, not a CLI token',
184
315
  'run login, or set RAVENSIGHT_TOKEN to a gt_cli_ token'
185
316
  ));
186
317
  } else if (looksLikeMcpToken(token)) {
187
318
  rows.push(row(
188
- 'token valid',
319
+ 'login',
189
320
  'fail',
190
321
  'This is an MCP personal access token (gt_mcp_). The CLI needs its own token (gt_cli_): run `ravensight-playtest login`, or set RAVENSIGHT_TOKEN to a gt_cli_ token in CI.',
191
322
  'ravensight-playtest login'
192
323
  ));
193
324
  } else if (!token) {
194
- rows.push(row('token valid', 'fail', `not logged in to ${host}`, 'ravensight-playtest login'));
195
- }
196
-
197
- let whoami = null;
198
- if (token && !looksLikeIngestKey(token) && !looksLikeMcpToken(token)) {
199
- const authed = deps.authedApi || createClient({ apiUrl, token });
325
+ rows.push(row('login', 'fail', `not logged in to ${host}`, 'ravensight-playtest login'));
326
+ } else {
327
+ authed = deps.authedApi || createClient({ apiUrl, token });
200
328
  try {
201
329
  whoami = await authed.cli.whoami();
330
+ const games = whoami.games || [];
331
+ facts.login.valid = true;
332
+ facts.login.games = games.map(game => ({ gameId: game.gameId, name: game.name || null }));
333
+ const who = whoami.user.email || whoami.user.name || whoami.user.id;
202
334
  rows.push(row(
203
- 'token valid',
204
- 'ok',
205
- `${whoami.user.email || whoami.user.name || whoami.user.id}, scopes ${whoami.scopes.join(' ')}`
335
+ 'login',
336
+ games.length > 0 ? 'pass' : 'warn',
337
+ games.length > 0
338
+ ? `${who}, valid, scoped to ${plural(games.length, 'game')}: ${games.map(gameLabel).join(', ')}`
339
+ : `${who}, valid, but scoped to no games`,
340
+ games.length > 0 ? null : 'run ravensight-playtest login again and approve the game for this token'
206
341
  ));
207
342
  } catch (error) {
208
343
  const message = error instanceof ApiError ? error.message : String(error && error.message);
209
- rows.push(row('token valid', 'fail', message, 'ravensight-playtest login'));
344
+ // Only a 401 or 403 is the server refusing the token. A network error or
345
+ // a 5xx says nothing about the token, so it must not send anyone to log
346
+ // in again over a login that may be fine.
347
+ const refused = error instanceof ApiError && (error.status === 401 || error.status === 403);
348
+ facts.login.valid = refused ? false : null;
349
+ rows.push(refused
350
+ ? row('login', 'fail', `a token is stored but the server refused it: ${message}`, 'ravensight-playtest login')
351
+ : row('login', 'warn', `a token is stored, and the server could not verify it right now: ${message}`, 'check api reachable, then run check again'));
210
352
  }
353
+ }
211
354
 
212
- /* ------------------------------------------------------------ the game */
213
-
214
- const config = flags.config === undefined
215
- ? await loadConfig({ repoRoot: flags.repoRoot })
216
- : flags.config;
217
- const gameId = flags.game || (config && config.game_id) || null;
355
+ /* ------------------------------------------------------ project binding */
218
356
 
219
- if (!gameId) {
357
+ const config = flags.config === undefined
358
+ ? await loadConfig({ repoRoot: flags.repoRoot })
359
+ : flags.config;
360
+ const gameId = flags.game || (config && config.game_id) || null;
361
+ let inScope = null;
362
+ if (!gameId) {
363
+ rows.push(row(
364
+ 'project binding',
365
+ 'warn',
366
+ 'this directory is not linked to a game',
367
+ 'ravensight-playtest init --game <gameId>'
368
+ ));
369
+ } else {
370
+ const scoped = whoami ? (whoami.games || []).find(game => game.gameId === gameId) : null;
371
+ const environment = (scoped && scoped.environment) || (config && config.environment) || null;
372
+ facts.project.game = gameId;
373
+ facts.project.environment = environment;
374
+ const linked = `${scoped ? gameLabel(scoped) : gameId}${environment ? `, environment ${environment}` : ''}`;
375
+ if (!whoami) {
220
376
  rows.push(row(
221
- 'game linked',
377
+ 'project binding',
222
378
  'warn',
223
- 'this directory is not linked to a game',
224
- 'ravensight-playtest init --game <gameId>'
379
+ `linked to ${linked}; whether the token can reach it is unknown until login passes`,
380
+ 'fix the login row, then run check again'
225
381
  ));
226
- } else if (whoami) {
227
- const visible = (whoami.games || []).some(game => game.gameId === gameId);
382
+ } else {
383
+ inScope = Boolean(scoped);
384
+ facts.project.in_scope = inScope;
228
385
  rows.push(row(
229
- 'game visible',
230
- visible ? 'ok' : 'fail',
231
- visible ? gameId : `${gameId} is not visible to this token`,
232
- visible ? null : 'approve this game for the token, or run login again'
386
+ 'project binding',
387
+ inScope ? 'pass' : 'fail',
388
+ inScope ? `linked to ${linked}, in the token's scope` : `linked to ${linked}, which is not in this token's scope`,
389
+ inScope ? null : 'run ravensight-playtest login again and approve this game, or relink with init --game <gameId> --force'
233
390
  ));
391
+ }
392
+ }
393
+
394
+ /* ---------------------------------------------------------------- brief */
234
395
 
235
- /* -------------------------------------------------- balance vs price */
236
-
237
- if (visible) {
238
- const modules = flags.modules || (config && config.modules) || ['game_profile', 'persona_playtest'];
239
- const personas = flags.personas || (config && config.personas) || [];
240
- try {
241
- const estimate = await authed.jobs.estimate(gameId, { modules, personas });
242
- if (estimate.balance_cents === null || estimate.balance_cents === undefined) {
243
- rows.push(row(
244
- 'balance',
245
- 'warn',
246
- `estimate ${ui.money(estimate.estimate_cents)}, balance hidden by your role`,
247
- 'ask an owner or admin to check the balance'
248
- ));
249
- } else {
250
- rows.push(row(
251
- 'balance',
252
- estimate.ok ? 'ok' : 'fail',
253
- `estimate ${ui.money(estimate.estimate_cents)}, available ${ui.money(estimate.balance_cents)}`,
254
- estimate.ok ? null : `top up at ${estimate.topup_url}`
255
- ));
256
- }
257
- // Not a failure and not hidden by role: a job this size is still
258
- // allowed, it may just outrun its wall clock, which is worth seeing
259
- // here rather than only after `run` has already asked for a price.
260
- if (estimate.wall_warning) {
261
- rows.push(row('persona wall clock', 'warn', estimate.wall_warning));
262
- }
263
- } catch (error) {
264
- rows.push(row(
265
- 'balance',
266
- 'warn',
267
- error instanceof ApiError ? error.message : String(error && error.message)
268
- ));
269
- }
270
-
271
- /* ------------------------------------------------------ pack cache */
272
-
273
- try {
274
- const pack = await packs.get({ api: authed, gameId, modules });
275
- const manifest = (await authed.packs.manifest()).body;
276
- const verified = packs.verifyAgainstManifest(manifest, pack.bundle);
277
- rows.push(row(
278
- 'content pack',
279
- verified.ok ? 'ok' : 'fail',
280
- verified.ok
281
- ? `${plural(pack.bundle.personas.length, 'persona')}, ${plural(verified.checked, 'component')} verified${pack.stale ? ' (cached)' : ''}`
282
- : `these components do not match the manifest: ${verified.mismatched.join(', ')}`,
283
- verified.ok ? null : `delete ${paths.packCacheDir(apiUrl)} and run check again`
284
- ));
285
- } catch (error) {
286
- rows.push(row(
287
- 'content pack',
288
- 'fail',
289
- error instanceof ApiError ? error.message : String(error && error.message),
290
- 'the CLI cannot run without personas and schemas'
291
- ));
292
- }
396
+ if (gameId && whoami && inScope) {
397
+ try {
398
+ const answer = await authed.brief.get(gameId);
399
+ const current = (answer && answer.brief) || null;
400
+ if (!current) {
401
+ facts.brief = { present: false, complete: false, missing: [] };
402
+ rows.push(row('brief', 'fail', 'this game has no expectations brief, and a persona run needs one', 'ravensight-playtest brief'));
403
+ } else {
404
+ const completeness = completenessFor(current.fields || {});
405
+ // The server's status is what `run` and `POST /jobs` act on. The local
406
+ // field check only explains a draft, or flags a brief marked complete
407
+ // whose fields a persona needs are empty.
408
+ const complete = current.status === 'complete';
409
+ facts.brief = { present: true, complete, missing: completeness.missing };
410
+ const empty = completeness.missing.length ? `; empty: ${completeness.missing.join(', ')}` : '';
411
+ rows.push(row(
412
+ 'brief',
413
+ !complete ? 'fail' : completeness.missing.length ? 'warn' : 'pass',
414
+ complete
415
+ ? `version ${current.version}, complete${empty || ' (goals, success criteria, controls, how to start)'}`
416
+ : `version ${current.version}, ${current.status}${empty}`,
417
+ complete && !completeness.missing.length ? null : 'ravensight-playtest brief'
418
+ ));
293
419
  }
420
+ } catch (error) {
421
+ rows.push(row(
422
+ 'brief',
423
+ 'warn',
424
+ `could not read the brief: ${error instanceof ApiError ? error.message : String(error && error.message)}`,
425
+ 'run check again; a run refuses to start without a complete brief'
426
+ ));
427
+ }
428
+ } else {
429
+ rows.push(row('brief', 'warn', 'not checked: it needs a valid login and a linked game in scope', 'fix the login and project binding rows first'));
430
+ }
431
+
432
+ /* ---------------------------------------------- build and automation */
433
+
434
+ const plan = plannedRun(flags, config);
435
+ facts.build.driver = plan.driverName;
436
+ if (plan.native) {
437
+ const native = await automationChecks({ ...plan, repoRoot }, deps);
438
+ rows.push(...native.rows);
439
+ facts.build.ref = native.build.ref;
440
+ facts.automation = native.automation;
441
+ } else {
442
+ facts.build.ref = plan.target;
443
+ rows.push(row(
444
+ 'build',
445
+ plan.target ? 'pass' : 'warn',
446
+ plan.target ? `${plan.driverName}: ${plan.target}` : `${plan.driverName}: no build linked yet`,
447
+ plan.target ? null : 'pass --build-url, --godot-project or --driver-config to run, or set build in .ravensight/config.json'
448
+ ));
449
+ }
450
+
451
+ /* -------------------------------------------------- balance and pack */
452
+
453
+ if (gameId && whoami && inScope) {
454
+ const modules = flags.modules || (config && config.modules) || ['game_profile', 'persona_playtest'];
455
+ const personas = flags.personas || (config && config.personas) || [];
456
+ try {
457
+ const estimate = await authed.jobs.estimate(gameId, { modules, personas });
458
+ if (estimate.balance_cents === null || estimate.balance_cents === undefined) {
459
+ rows.push(row(
460
+ 'balance',
461
+ 'warn',
462
+ `estimate ${ui.money(estimate.estimate_cents)}, balance hidden by your role`,
463
+ 'ask an owner or admin to check the balance'
464
+ ));
465
+ } else {
466
+ rows.push(row(
467
+ 'balance',
468
+ estimate.ok ? 'pass' : 'fail',
469
+ `estimate ${ui.money(estimate.estimate_cents)}, available ${ui.money(estimate.balance_cents)}`,
470
+ estimate.ok ? null : `top up at ${estimate.topup_url}`
471
+ ));
472
+ }
473
+ // Not a failure and not hidden by role: a job this size is still
474
+ // allowed, it may just outrun its wall clock, which is worth seeing
475
+ // here rather than only after `run` has already asked for a price.
476
+ if (estimate.wall_warning) {
477
+ rows.push(row('persona wall clock', 'warn', estimate.wall_warning));
478
+ }
479
+ } catch (error) {
480
+ rows.push(row(
481
+ 'balance',
482
+ 'warn',
483
+ error instanceof ApiError ? error.message : String(error && error.message)
484
+ ));
485
+ }
486
+
487
+ try {
488
+ const pack = await packs.get({ api: authed, gameId, modules });
489
+ const manifest = (await authed.packs.manifest()).body;
490
+ const verified = packs.verifyAgainstManifest(manifest, pack.bundle);
491
+ rows.push(row(
492
+ 'content pack',
493
+ verified.ok ? 'pass' : 'fail',
494
+ verified.ok
495
+ ? `${plural(pack.bundle.personas.length, 'persona')}, ${plural(verified.checked, 'component')} verified${pack.stale ? ' (cached)' : ''}`
496
+ : `these components do not match the manifest: ${verified.mismatched.join(', ')}`,
497
+ verified.ok ? null : `delete ${paths.packCacheDir(apiUrl)} and run check again`
498
+ ));
499
+ } catch (error) {
500
+ rows.push(row(
501
+ 'content pack',
502
+ 'fail',
503
+ error instanceof ApiError ? error.message : String(error && error.message),
504
+ 'the CLI cannot run without personas and schemas'
505
+ ));
294
506
  }
295
507
  }
296
508
 
297
509
  /* --------------------------------------------------------- the machine */
298
510
 
299
- const repoRoot = flags.repoRoot || process.cwd();
300
511
  const free = await freeBytes(repoRoot);
301
512
  rows.push(row(
302
513
  'disk',
303
- free === null ? 'warn' : free >= DISK_WARN_BYTES ? 'ok' : 'warn',
514
+ free === null ? 'warn' : free >= DISK_WARN_BYTES ? 'pass' : 'warn',
304
515
  free === null ? 'could not read free space' : `${ui.bytes(free)} free in ${repoRoot}`,
305
516
  'screenshots and a video take 50 to 300 MB per run'
306
517
  ));
@@ -308,14 +519,14 @@ export async function runChecks(flags = {}, deps = {}) {
308
519
  const machine = recommendConcurrency(deps.machine);
309
520
  rows.push(row(
310
521
  'cpu and memory',
311
- 'ok',
522
+ 'pass',
312
523
  `${machine.cpus} logical cpus, ${machine.freeMemGb} GB free, use concurrency ${machine.recommended}`
313
524
  ));
314
525
 
315
526
  const ffmpeg = await (deps.detectFfmpeg || detectFfmpeg)();
316
527
  rows.push(row(
317
528
  'ffmpeg (optional)',
318
- ffmpeg.path ? 'ok' : 'warn',
529
+ ffmpeg.path ? 'pass' : 'warn',
319
530
  ffmpeg.path ? `${ffmpeg.path}: ${ffmpeg.version}` : 'not found',
320
531
  'needed only to re-encode a session video before upload'
321
532
  ));
@@ -325,7 +536,7 @@ export async function runChecks(flags = {}, deps = {}) {
325
536
  const probe = await probeBrowser(deps.importer);
326
537
  rows.push(row(
327
538
  'browser',
328
- probe.ok ? 'ok' : browserNeed,
539
+ probe.ok ? 'pass' : browserNeed,
329
540
  probe.detail,
330
541
  probe.ok ? null : BROWSER_FIX
331
542
  ));
@@ -337,7 +548,7 @@ export async function runChecks(flags = {}, deps = {}) {
337
548
  );
338
549
  rows.push(row(
339
550
  'godot (optional)',
340
- godot.binary.path ? 'ok' : 'warn',
551
+ godot.binary.path ? 'pass' : 'warn',
341
552
  godot.binary.path
342
553
  ? `${godot.binary.path}${godot.binary.version ? `: ${godot.binary.version}` : ''}`
343
554
  // The detector's own message says WHY: nothing found at all, versus a
@@ -359,7 +570,7 @@ export async function runChecks(flags = {}, deps = {}) {
359
570
  };
360
571
  rows.push(row(
361
572
  'godot project',
362
- godot.matches === false || godot.unknown ? 'warn' : 'ok',
573
+ godot.matches === false || godot.unknown ? 'warn' : 'pass',
363
574
  godot.matches === false
364
575
  ? `project asks for ${godot.projectVersion}, found ${godot.binary.version}`
365
576
  : godot.unknown
@@ -373,7 +584,7 @@ export async function runChecks(flags = {}, deps = {}) {
373
584
  ));
374
585
  }
375
586
 
376
- return { rows, ok: rows.every(entry => entry.status !== 'fail') };
587
+ return { rows, ok: rows.every(entry => entry.status !== 'fail'), ...facts };
377
588
  }
378
589
 
379
590
  /**
@@ -383,10 +594,22 @@ export async function runChecks(flags = {}, deps = {}) {
383
594
  * @returns {Promise<number>} exit code, 3 when anything failed
384
595
  */
385
596
  export async function check(flags = {}, deps = {}) {
386
- const { rows, ok } = await runChecks(flags, deps);
597
+ const progress = flags.json ? {} : { onProgress: line => ui.info(line) };
598
+ const { rows, ok, cli, login, project, brief, build, automation } = await runChecks(flags, { ...progress, ...deps });
387
599
 
388
600
  if (flags.json) {
389
- ui.json({ ok, checks: rows });
601
+ ui.json({
602
+ ok,
603
+ reserves_units: false,
604
+ note: NO_CHARGE_NOTE,
605
+ cli,
606
+ login,
607
+ project,
608
+ brief,
609
+ build,
610
+ automation,
611
+ checks: rows
612
+ });
390
613
  return ok ? ExitCode.OK : ExitCode.ENVIRONMENT;
391
614
  }
392
615
 
@@ -397,9 +620,10 @@ export async function check(flags = {}, deps = {}) {
397
620
  ]);
398
621
  ui.blank();
399
622
  for (const entry of rows) {
400
- if (entry.status !== 'ok' && entry.fix) ui.info(`${entry.name}: ${entry.fix}`);
623
+ if (entry.status !== 'pass' && entry.fix) ui.info(`${entry.name}: ${entry.fix}`);
401
624
  }
402
625
  ui.blank();
626
+ ui.info(NO_CHARGE_NOTE);
403
627
  if (ok) ui.ok('Ready.');
404
628
  else ui.error('Some checks failed. Fix the ones above and run check again.');
405
629
  return ok ? ExitCode.OK : ExitCode.ENVIRONMENT;