verikun 0.22.1 → 0.23.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/dist/cli.js CHANGED
@@ -50,6 +50,8 @@ exports.chooseLogOpts = chooseLogOpts;
50
50
  exports.evalAssert = evalAssert;
51
51
  exports.tokenizeLine = tokenizeLine;
52
52
  exports.withBatchGlobals = withBatchGlobals;
53
+ exports.serverFromFlags = serverFromFlags;
54
+ exports.ensureDeviceTarget = ensureDeviceTarget;
53
55
  exports.terminalFailure = terminalFailure;
54
56
  exports.executeForServer = executeForServer;
55
57
  exports.run = run;
@@ -60,6 +62,7 @@ const errors_1 = require("./errors");
60
62
  const exec_1 = require("./exec");
61
63
  const drivers_1 = require("./drivers");
62
64
  const adb_1 = require("./drivers/adb");
65
+ const lifecycle_1 = require("./drivers/lifecycle");
63
66
  const settings_1 = require("./device/settings");
64
67
  const claims_1 = require("./device/claims");
65
68
  const selector_1 = require("./ui/selector");
@@ -79,9 +82,9 @@ const cache_1 = require("./agent/cache");
79
82
  const cost_1 = require("./agent/cost");
80
83
  const remote_1 = require("./agent/remote");
81
84
  const suite_1 = require("./suite");
85
+ const wait_1 = require("./wait");
82
86
  const version_1 = require("./version");
83
87
  const update_check_1 = require("./update-check");
84
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
85
88
  // Exported for src/server.ts (which resolves its own platform/device at startup).
86
89
  function platformFromFlags(flags) {
87
90
  if ((0, args_1.flagBool)(flags, 'ios'))
@@ -217,7 +220,7 @@ async function matchWaiting(ctx, sel, opts = {}) {
217
220
  const res = (0, selector_1.matchElements)(readForPoll(ctx, opts), sel);
218
221
  if (res.matches.length > 0 || Date.now() >= deadline)
219
222
  return res;
220
- await sleep(pollStep(ctx.flags, deadline));
223
+ await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
221
224
  }
222
225
  }
223
226
  /**
@@ -242,7 +245,7 @@ async function resolveOneWaiting(ctx, sel, opts = {}) {
242
245
  const waited = windowMs > 0 ? ` after ${(windowMs / 1000).toFixed(1)}s` : '';
243
246
  throw new errors_1.SelectorNotFoundError(`No element matched selector '${sel.raw}'${waited}. Run \`verikun ui\` to inspect the current screen.`);
244
247
  }
245
- await sleep(pollStep(ctx.flags, deadline));
248
+ await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
246
249
  }
247
250
  }
248
251
  // --- Auto scroll-into-view --------------------------------------------------
@@ -299,7 +302,7 @@ async function scrollIntoView(ctx, sel, target, elements, screen, opts = {}) {
299
302
  break; // in view, or no swipe big enough to be worth making
300
303
  ctx.driver.swipe(plan.from.x, plan.from.y, plan.to.x, plan.to.y, (0, viewport_1.swipeDurationMs)(plan.distance));
301
304
  swipes++;
302
- await sleep(SCROLL_SETTLE_MS);
305
+ await (0, wait_1.sleep)(SCROLL_SETTLE_MS);
303
306
  const before = current.bounds;
304
307
  snapshot = ctx.driver.getElements(opts);
305
308
  // An empty tree is a bad read, not a screen (the device returns partial dumps
@@ -312,7 +315,7 @@ async function scrollIntoView(ctx, sel, target, elements, screen, opts = {}) {
312
315
  // (half of it, to land between the two) and look once more before giving up.
313
316
  const mid = { x: Math.round((plan.from.x + plan.to.x) / 2), y: Math.round((plan.from.y + plan.to.y) / 2) };
314
317
  ctx.driver.swipe(plan.to.x, plan.to.y, mid.x, mid.y, (0, viewport_1.swipeDurationMs)(plan.distance / 2));
315
- await sleep(SCROLL_SETTLE_MS);
318
+ await (0, wait_1.sleep)(SCROLL_SETTLE_MS);
316
319
  snapshot = ctx.driver.getElements(opts);
317
320
  if ((0, selector_1.matchElements)(snapshot, sel).matches.length === 0) {
318
321
  throw new errors_1.SelectorNotFoundError(`'${sel.raw}' left the hierarchy while being scrolled into view (after ${swipes} swipe(s)) — ` +
@@ -388,10 +391,28 @@ async function resolveTappable(ctx, sel, opts = {}) {
388
391
  // ---------------------------------------------------------------------------
389
392
  // Commands
390
393
  // ---------------------------------------------------------------------------
391
- function cmdDevices(ctx) {
394
+ function cmdDevices(flags) {
395
+ const all = (0, args_1.flagBool)(flags, 'all');
392
396
  const allDevices = [];
393
397
  try {
394
- allDevices.push(...new drivers_1.AdbDriver().listDevices());
398
+ const android = new drivers_1.AdbDriver().listDevices();
399
+ // Name each running emulator by its AVD, so the listing shows the token you'd
400
+ // pass to `vk devices stop|restart`. Best-effort: the console may not answer.
401
+ for (const d of android) {
402
+ if (d.kind === 'emulator')
403
+ d.name = (0, adb_1.avdNameOf)(d.serial) || undefined;
404
+ }
405
+ allDevices.push(...android);
406
+ if (all) {
407
+ // Startable-but-not-running AVDs have no adb address yet — they are addressed
408
+ // by name, so `serial` stays empty rather than being invented.
409
+ const running = new Set(android.map((d) => (d.name ?? '').toLowerCase()).filter(Boolean));
410
+ for (const name of (0, adb_1.listAvds)()) {
411
+ if (running.has(name.toLowerCase()))
412
+ continue;
413
+ allDevices.push({ serial: '', state: 'shutdown', platform: 'android', kind: 'emulator', name });
414
+ }
415
+ }
395
416
  }
396
417
  catch (e) {
397
418
  // adb not on PATH is the common (expected) case, but surface anything else so a real
@@ -399,8 +420,10 @@ function cmdDevices(ctx) {
399
420
  (0, output_1.err)(`devices: adb backend unavailable (${e.message})`);
400
421
  }
401
422
  try {
402
- // Only include booted simulators; always include physical devices (they carry a note)
403
- allDevices.push(...new drivers_1.IdbDriver().listDevices().filter((d) => d.state === 'booted' || d.note));
423
+ // By default only booted simulators (a shutdown one isn't drivable); physical
424
+ // devices always show (they carry a note). --all lists everything startable.
425
+ const ios = new drivers_1.IdbDriver().listDevices();
426
+ allDevices.push(...(all ? ios : ios.filter((d) => d.state === 'booted' || d.note)));
404
427
  }
405
428
  catch (e) {
406
429
  (0, output_1.err)(`devices: iOS backend unavailable (${e.message})`);
@@ -414,22 +437,140 @@ function cmdDevices(ctx) {
414
437
  d.claim = claim;
415
438
  }
416
439
  }
417
- if ((0, args_1.flagBool)(ctx.flags, 'json')) {
440
+ if ((0, args_1.flagBool)(flags, 'json')) {
418
441
  (0, output_1.json)(allDevices);
419
442
  return 0;
420
443
  }
421
444
  if (!allDevices.length) {
422
- (0, output_1.err)('No devices found.');
445
+ (0, output_1.err)(all ? 'No devices or startable AVDs/simulators found.' : 'No devices found.');
423
446
  return 0;
424
447
  }
425
448
  for (const line of formatDeviceTable(allDevices))
426
449
  (0, output_1.out)(line);
427
450
  return 0;
428
451
  }
452
+ /** Aliases for the lifecycle verbs, mirroring the top-level command aliases. */
453
+ const DEVICE_VERBS = {
454
+ start: 'start',
455
+ boot: 'start',
456
+ stop: 'stop',
457
+ shutdown: 'stop',
458
+ kill: 'stop',
459
+ restart: 'restart',
460
+ reboot: 'restart',
461
+ };
462
+ /**
463
+ * `vk devices [list|start|stop|restart]`. Dispatched as a META-command (before the
464
+ * recording machinery) whenever a subcommand is present: lifecycle needs no local
465
+ * driver — it is what you run when there ISN'T one — and a `--server` invocation
466
+ * must not build one either. Bare `vk devices` keeps the ordinary path.
467
+ */
468
+ async function cmdDevicesEntry(positionals, flags) {
469
+ const sub = String(positionals[0] ?? 'list').toLowerCase();
470
+ const server = serverFromFlags(flags);
471
+ if (sub === 'list')
472
+ return server ? cmdDevicesRemoteList(server, flags) : cmdDevices(flags);
473
+ const verb = DEVICE_VERBS[sub];
474
+ if (!verb) {
475
+ throw new errors_1.CliError(`Unknown 'devices' subcommand '${sub}'. Use: list | start | stop | restart.`, 2);
476
+ }
477
+ return server
478
+ ? cmdDevicesRemote(verb, positionals[1], server, flags)
479
+ : cmdDeviceLifecycle(verb, positionals[1], flags);
480
+ }
481
+ /**
482
+ * Refuse to power-cycle a device another job is actively driving.
483
+ *
484
+ * The local counterpart of the server's `409`: you may restart YOUR OWN device, never
485
+ * someone else's. Without this, device claims and device lifecycle would each work and
486
+ * together be a hole — one job could reboot the phone another job is mid-suite on,
487
+ * which is precisely the collision claims exist to prevent.
488
+ *
489
+ * `start` is exempt: it only ever brings up something that is not running, so there is
490
+ * nothing to pull out from under anyone.
491
+ */
492
+ function assertNotClaimedByOthers(target, verb) {
493
+ if (verb === 'start' || !target.serial || !(0, claims_1.claimsEnabled)())
494
+ return;
495
+ const claim = (0, claims_1.summarize)(target.serial);
496
+ if (!claim || claim.mine)
497
+ return;
498
+ throw new errors_1.CliError(`${(0, lifecycle_1.targetLabel)(target)} (${target.serial}) is in use by ${claim.by} — ` +
499
+ `${verb} would pull the device out from under it.\n` +
500
+ `If that job is gone, hand the device back first: vk device release ${target.serial}`, 2);
501
+ }
502
+ async function cmdDeviceLifecycle(verb, rawTarget, flags) {
503
+ const wipe = (0, args_1.flagBool)(flags, 'wipe');
504
+ const wait = !(0, args_1.flagBool)(flags, 'no-wait');
505
+ const timeoutFlag = (0, args_1.flagStr)(flags, 'timeout');
506
+ const timeoutMs = timeoutFlag
507
+ ? parseDuration(timeoutFlag, 'timeout')
508
+ : verb === 'stop'
509
+ ? wait_1.DEFAULT_STOP_TIMEOUT_MS
510
+ : wait_1.DEFAULT_BOOT_TIMEOUT_MS;
511
+ // An explicit --ios/--android/--platform narrows the search; otherwise probe both
512
+ // backends, exactly as bare `vk devices` does, so `devices start Pixel_6_API_34`
513
+ // works without also naming the platform. A name that hits BOTH is still exit 2.
514
+ const explicitPlatform = (0, args_1.flagBool)(flags, 'ios') || (0, args_1.flagBool)(flags, 'android') || (0, args_1.flagStr)(flags, 'platform') !== undefined;
515
+ const lifecycles = explicitPlatform ? [(0, lifecycle_1.lifecycleFor)(platformFromFlags(flags))] : (0, lifecycle_1.allLifecycles)();
516
+ const targets = lifecycles.flatMap((l) => l.targets());
517
+ if (!rawTarget) {
518
+ const startable = targets.filter((t) => t.kind !== 'physical').map(lifecycle_1.targetLabel).filter(Boolean);
519
+ const hint = startable.length
520
+ ? `\nStartable: ${[...new Set(startable)].join(', ')}`
521
+ : '\nRun `vk devices --all` to list startable devices.';
522
+ throw new errors_1.CliError(`Usage: verikun devices ${verb} <name|serial> [--timeout <dur>]${hint}`, 2);
523
+ }
524
+ // `start` wants the one that ISN'T up; `stop`/`restart` want the live one.
525
+ const chosen = (0, lifecycle_1.chooseTarget)(targets, rawTarget, { prefer: verb === 'start' ? 'startable' : 'running' });
526
+ (0, lifecycle_1.assertActionable)(chosen, verb, { wipe });
527
+ assertNotClaimedByOthers(chosen, verb);
528
+ const lc = (0, lifecycle_1.lifecycleFor)(chosen.platform);
529
+ const onProgress = (m) => (0, output_1.err)(`[verikun] ${m}`);
530
+ const asJson = (0, args_1.flagBool)(flags, 'json');
531
+ if (verb === 'stop') {
532
+ const r = await lc.shutdown(chosen, { timeoutMs, onProgress });
533
+ if (asJson) {
534
+ (0, output_1.json)({ action: 'stop', requested: rawTarget, platform: chosen.platform, kind: chosen.kind, serial: r.serial, name: chosen.name, stopped: r.stopped });
535
+ }
536
+ else {
537
+ (0, output_1.err)(r.stopped ? `stopped ${(0, lifecycle_1.targetLabel)(chosen)}` : `${(0, lifecycle_1.targetLabel)(chosen)} was not running`);
538
+ (0, output_1.out)(r.serial);
539
+ }
540
+ return 0;
541
+ }
542
+ const opts = { timeoutMs, wait, wipe, onProgress };
543
+ const r = verb === 'restart'
544
+ ? await (0, lifecycle_1.restartTarget)(lc, chosen, opts)
545
+ : { ...(await lc.boot(chosen, opts)), stopped: false };
546
+ if (asJson) {
547
+ (0, output_1.json)({
548
+ action: verb,
549
+ requested: rawTarget,
550
+ platform: chosen.platform,
551
+ kind: chosen.kind,
552
+ serial: r.serial,
553
+ name: chosen.name,
554
+ started: r.started,
555
+ ready: r.ready,
556
+ waitedMs: r.waitedMs,
557
+ ...(r.logPath ? { logPath: r.logPath } : {}),
558
+ });
559
+ }
560
+ else {
561
+ if (verb === 'start' && !r.started)
562
+ (0, output_1.err)(`${(0, lifecycle_1.targetLabel)(chosen)} is already running`);
563
+ // stdout is the one machine-usable datum: the serial, so
564
+ // `vk -d "$(vk devices start X)" ui` composes. Under --no-wait on Android there
565
+ // is genuinely no serial yet, so print nothing rather than invent one.
566
+ (0, output_1.out)(r.serial ?? '');
567
+ }
568
+ return 0;
569
+ }
429
570
  /**
430
571
  * Render the device list as an aligned, headed table (header line first, then one
431
- * line per device). Optional columns (MODEL/PRODUCT/NOTE) are dropped when no device
432
- * populates them; every shown cell is padded to its column width so columns line up
572
+ * line per device). Optional columns (KIND/NAME/MODEL/PRODUCT/NOTE) are dropped when no
573
+ * device populates them; every shown cell is padded to its column width so columns line up
433
574
  * regardless of which cells are empty — the previous `.filter(Boolean).join('\t')`
434
575
  * dropped empty cells, sliding later cells into earlier tab stops. Exported for unit
435
576
  * testing.
@@ -437,8 +578,10 @@ function cmdDevices(ctx) {
437
578
  function formatDeviceTable(devices) {
438
579
  const columns = [
439
580
  { header: 'PLATFORM', get: (d) => d.platform },
581
+ { header: 'KIND', get: (d) => d.kind ?? '', optional: true },
440
582
  { header: 'SERIAL', get: (d) => d.serial },
441
583
  { header: 'STATE', get: (d) => d.state },
584
+ { header: 'NAME', get: (d) => d.name ?? '', optional: true },
442
585
  { header: 'MODEL', get: (d) => d.model ?? '', optional: true },
443
586
  { header: 'PRODUCT', get: (d) => d.product ?? '', optional: true },
444
587
  // Optional like the rest, which is load-bearing twice over: nothing changes for a
@@ -650,13 +793,13 @@ async function cmdText(ctx) {
650
793
  });
651
794
  ctx.driver.tap(point.x, point.y);
652
795
  // Wait for field to be focused after tap
653
- await sleep(100);
796
+ await (0, wait_1.sleep)(100);
654
797
  if ((0, args_1.flagBool)(ctx.flags, 'clear') && target.text) {
655
798
  ctx.driver.pressKey('move_end');
656
799
  for (let i = 0; i < target.text.length + 2; i++)
657
800
  ctx.driver.pressKey('del');
658
801
  // Wait for field to settle after clearing before typing
659
- await sleep(200);
802
+ await (0, wait_1.sleep)(200);
660
803
  }
661
804
  // Prime the input method with a space, then delete it, to avoid losing first character
662
805
  // (workaround for adb input text behavior where first char is sometimes lost)
@@ -887,7 +1030,7 @@ async function cmdWait(ctx) {
887
1030
  (0, output_1.out)((0, format_1.formatCompact)(matches));
888
1031
  return 0;
889
1032
  }
890
- await sleep(interval);
1033
+ await (0, wait_1.sleep)(interval);
891
1034
  }
892
1035
  ctx.record?.note({ selector: sel, message: `timeout after ${timeout}ms${gone ? ' (still present)' : ' (never appeared)'}` });
893
1036
  (0, output_1.err)(`timeout after ${timeout}ms waiting for '${sel.raw}'${gone ? ' to disappear' : ''}`);
@@ -938,7 +1081,7 @@ async function cmdAssert(ctx) {
938
1081
  const deadline = Date.now() + waitWindowMs(ctx.flags);
939
1082
  let result = evalAssert(readForPoll(ctx), sel, ctx.flags);
940
1083
  while (!result.pass && Date.now() < deadline) {
941
- await sleep(pollStep(ctx.flags, deadline));
1084
+ await (0, wait_1.sleep)(pollStep(ctx.flags, deadline));
942
1085
  result = evalAssert(readForPoll(ctx), sel, ctx.flags);
943
1086
  }
944
1087
  const { pass, reason, matches } = result;
@@ -1624,9 +1767,136 @@ async function obtainPlan(key, file, opts, cost, provider) {
1624
1767
  }
1625
1768
  return { plan: compiled.plan, cached: false };
1626
1769
  }
1770
+ /** The `--server` URL, or VERIKUN_SERVER. Exported-shape helper so `resolveBackend`
1771
+ * and `vk devices --server` can never disagree about what "remote" means. */
1772
+ function serverFromFlags(flags) {
1773
+ return (0, args_1.flagStr)(flags, 'server') || process.env.VERIKUN_SERVER || undefined;
1774
+ }
1775
+ function remoteOptsFrom(url, flags) {
1776
+ return { url, authKey: (0, args_1.flagStr)(flags, 'auth-key') || process.env.VERIKUN_SERVER_AUTH_KEY || undefined };
1777
+ }
1778
+ /**
1779
+ * `--ensure-device[=<target>]` — the requested target, or `undefined` for the bare
1780
+ * form, or `null` when the flag is absent. Exported for unit tests.
1781
+ */
1782
+ function ensureDeviceTarget(flags) {
1783
+ const raw = flags['ensure-device'];
1784
+ if (raw === undefined || raw === false)
1785
+ return null;
1786
+ if (raw === true || raw === 'true')
1787
+ return undefined;
1788
+ return String(raw);
1789
+ }
1790
+ /** The one target that is startable without guessing, else exit 2 with the options. */
1791
+ function soleStartable(targets) {
1792
+ const startable = targets.filter((t) => t.kind !== 'physical' && !(0, lifecycle_1.isRunning)(t));
1793
+ if (startable.length === 1)
1794
+ return startable[0];
1795
+ const names = [...new Set(startable.map(lifecycle_1.targetLabel).filter(Boolean))];
1796
+ throw new errors_1.CliError(startable.length === 0
1797
+ ? '--ensure-device: no startable device found. Run `vk devices --all` to check the toolchain.'
1798
+ : `--ensure-device: ${startable.length} devices could be started — name one, ` +
1799
+ `e.g. --ensure-device="${names[0]}"\n ${names.join('\n ')}`, 2);
1800
+ }
1801
+ /**
1802
+ * Boot a device before the first step, when `--ensure-device` asks for it. Runs here
1803
+ * — inside resolveBackend, before runCtx is fixed — rather than inside `vk suite`,
1804
+ * so the booted serial is the one every recorded step is attributed to. It is
1805
+ * structurally unable to turn a red test green: it happens before any step executes,
1806
+ * never between tests and never mid-run.
1807
+ */
1808
+ async function ensureLocalDevice(platform, device, flags) {
1809
+ const want = ensureDeviceTarget(flags);
1810
+ if (want === null)
1811
+ return;
1812
+ try {
1813
+ const serial = (0, drivers_1.getDriver)(platform, device).resolvedSerial();
1814
+ // `--ensure-device=X` means "boot X if nothing is usable", not "make X the
1815
+ // device" — so say so plainly when a different device is already serving,
1816
+ // rather than reporting a serial the caller didn't ask for.
1817
+ (0, output_1.err)(`[verikun] --ensure-device: ${serial} is already available${want ? ` — not booting '${want}'` : ''}`);
1818
+ return;
1819
+ }
1820
+ catch (e) {
1821
+ // Ambiguity (exit 2) is an operator error — booting another device makes it worse.
1822
+ if (!(e instanceof errors_1.CliError) || e.exitCode !== 3)
1823
+ throw e;
1824
+ }
1825
+ const lc = (0, lifecycle_1.lifecycleFor)(platform);
1826
+ const targets = lc.targets();
1827
+ const chosen = want ? (0, lifecycle_1.chooseTarget)(targets, want, { prefer: 'startable' }) : soleStartable(targets);
1828
+ (0, lifecycle_1.assertActionable)(chosen, 'start');
1829
+ (0, output_1.err)(`[verikun] --ensure-device: booting ${(0, lifecycle_1.targetLabel)(chosen)}…`);
1830
+ await lc.boot(chosen, {
1831
+ timeoutMs: wait_1.DEFAULT_BOOT_TIMEOUT_MS,
1832
+ wait: true,
1833
+ wipe: false,
1834
+ onProgress: (m) => (0, output_1.err)(`[verikun] ${m}`),
1835
+ });
1836
+ }
1837
+ /** The remote half of `--ensure-device`: ask the server to boot, then re-ping so the
1838
+ * run context carries the serial the device actually came up as. */
1839
+ async function ensureRemoteDevice(health, opts, url, flags) {
1840
+ const want = ensureDeviceTarget(flags);
1841
+ if (want === null)
1842
+ return health;
1843
+ if (health.serial) {
1844
+ (0, output_1.err)(`[verikun] --ensure-device: ${health.serial} is already bound on ${url}${want ? ` — not booting '${want}'` : ''}`);
1845
+ return health;
1846
+ }
1847
+ assertDeviceControl(health, url, want);
1848
+ (0, output_1.err)(`[verikun] --ensure-device: asking ${url} to boot a device…`);
1849
+ await (0, remote_1.remoteDeviceOp)(opts, 'start', want ? { target: want } : {});
1850
+ return (0, remote_1.pingServer)(opts);
1851
+ }
1852
+ /** Feature-detect device control from /v1/health. Never compare versions: a client
1853
+ * cannot tell "old server" from "new server, flag off", and both need this answer. */
1854
+ function assertDeviceControl(health, url, target) {
1855
+ if (!health.deviceControlEnabled) {
1856
+ throw new errors_1.CliError(`device control is not available on the verikun server at ${url} (verikun ${health.version}). ` +
1857
+ 'Restart it with --allow-device-control.', 3);
1858
+ }
1859
+ if (target && !health.deviceNamingEnabled) {
1860
+ throw new errors_1.CliError(`the server at ${url} was started with a bare --allow-device-control — it can only act on its own ` +
1861
+ `device, not '${target}'. Restart it with --allow-device-control=<names>.`, 2);
1862
+ }
1863
+ }
1864
+ async function cmdDevicesRemote(verb, target, url, flags) {
1865
+ const opts = remoteOptsFrom(url, flags);
1866
+ const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
1867
+ assertDeviceControl(health, url, target);
1868
+ const wipe = (0, args_1.flagBool)(flags, 'wipe');
1869
+ (0, output_1.err)(`[verikun] ${verb} device on ${url}${target ? ` (${target})` : ''}${wipe ? ' with WIPE' : ''}…`);
1870
+ const r = await (0, remote_1.remoteDeviceOp)(opts, verb, { ...(target ? { target } : {}), ...(wipe ? { wipe } : {}) });
1871
+ (0, output_1.err)(`[verikun] ${r.changed ? `${verb} ok` : 'already running'} · ${r.serial ?? '(none)'} · ` +
1872
+ `${(r.durationMs / 1000).toFixed(1)}s`);
1873
+ if ((0, args_1.flagBool)(flags, 'json'))
1874
+ (0, output_1.json)({ action: verb, server: url, ...r });
1875
+ else
1876
+ (0, output_1.out)(r.serial ?? '');
1877
+ return 0;
1878
+ }
1879
+ async function cmdDevicesRemoteList(url, flags) {
1880
+ const opts = remoteOptsFrom(url, flags);
1881
+ const health = await (0, remote_1.pingServer)(opts);
1882
+ assertDeviceControl(health, url);
1883
+ const r = await (0, remote_1.remoteDeviceList)(opts);
1884
+ if ((0, args_1.flagBool)(flags, 'json')) {
1885
+ (0, output_1.json)({ server: url, ...r });
1886
+ return 0;
1887
+ }
1888
+ if (r.devices.length)
1889
+ for (const line of formatDeviceTable(r.devices))
1890
+ (0, output_1.out)(line);
1891
+ else
1892
+ (0, output_1.err)('No devices visible on the server.');
1893
+ (0, output_1.err)(`[verikun] bound: ${r.bound ?? '(none)'}${r.startable.length ? ` · startable: ${r.startable.join(', ')}` : ''}`);
1894
+ return 0;
1895
+ }
1627
1896
  async function resolveBackend(platform, device, flags) {
1628
- const server = (0, args_1.flagStr)(flags, 'server') || process.env.VERIKUN_SERVER || undefined;
1897
+ const server = serverFromFlags(flags);
1629
1898
  if (!server) {
1899
+ await ensureLocalDevice(platform, device, flags);
1630
1900
  const driver = (0, drivers_1.getDriver)(platform, device);
1631
1901
  // Fail fast on a broken toolchain BEFORE any model spend — the mirror of the
1632
1902
  // remote branch's pingServer below. Without this, a missing `idb` isn't noticed
@@ -1680,9 +1950,13 @@ async function resolveBackend(platform, device, flags) {
1680
1950
  // so archive-time / vk log scoping works without a local driver.
1681
1951
  onStep: (step, artifacts, logStart) => run_1.Recorder.appendForeignStep(step, artifacts, { ...runCtx, logStart }),
1682
1952
  };
1683
- const health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
1684
- runCtx = { platform: health.platform, device: health.serial };
1685
- (0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial} · verikun ${health.version}`);
1953
+ let health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
1954
+ // `--ensure-device` boots BEFORE runCtx is fixed: resolveBackend bakes the serial
1955
+ // into the run context and the returned device, so booting afterwards would
1956
+ // attribute every spliced step to a device that didn't exist yet.
1957
+ health = await ensureRemoteDevice(health, opts, server, flags);
1958
+ runCtx = { platform: health.platform, device: health.serial ?? undefined };
1959
+ (0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial ?? '(none)'} · verikun ${health.version}`);
1686
1960
  // Say the read path once, here. Reads execute server-side, so this is the only end of the
1687
1961
  // connection that knows it — and without it a companion that had silently stood down was
1688
1962
  // indistinguishable from one that never engaged, for a whole suite (issue #77). An older
@@ -1715,7 +1989,7 @@ async function resolveBackend(platform, device, flags) {
1715
1989
  },
1716
1990
  },
1717
1991
  platform: health.platform,
1718
- device: health.serial,
1992
+ device: health.serial ?? undefined,
1719
1993
  remote: { url: server, version: health.version, reads: health.reads },
1720
1994
  };
1721
1995
  }
@@ -2094,8 +2368,6 @@ function cmdCompanion(ctx) {
2094
2368
  // ---------------------------------------------------------------------------
2095
2369
  async function executeCommand(command, ctx) {
2096
2370
  switch (command) {
2097
- case 'devices':
2098
- return cmdDevices(ctx);
2099
2371
  case 'doctor':
2100
2372
  return cmdDoctor(ctx);
2101
2373
  case 'companion':
@@ -2280,6 +2552,19 @@ async function executeParsed(command, positionals, flags) {
2280
2552
  return cmdRun(positionals, flags, platform, device);
2281
2553
  if (command === 'batch')
2282
2554
  return cmdBatch(positionals, flags);
2555
+ // `devices` is wholly a meta-command: it must not build a local driver. Lifecycle is
2556
+ // what you run when there ISN'T one, a `--server` call needs no local device at all,
2557
+ // and even the bare listing probes both backends itself rather than using ctx.driver.
2558
+ // Dispatched in exactly one place on purpose — a command split across the switch AND
2559
+ // this chain reads as two commands to anything parsing the dispatch (tests/docs-coverage).
2560
+ if (command === 'devices') {
2561
+ try {
2562
+ return await cmdDevicesEntry(positionals, flags);
2563
+ }
2564
+ catch (e) {
2565
+ return mapError(e, flags);
2566
+ }
2567
+ }
2283
2568
  // `ai`/`suite`/`install`/`server` orchestrate their own steps; map their thrown
2284
2569
  // CliErrors to exit codes here (usage 2 / env 3 / …) so they honor the exit-code
2285
2570
  // contract instead of escaping to the top-level "Fatal" handler (exit 3).
@@ -2435,6 +2720,7 @@ SUITE (run a directory of natural-language tests as one gated suite)
2435
2720
 
2436
2721
  SERVER (expose a locally-connected device to remote verikun clients)
2437
2722
  server [--bind addr] [--port n] [--auth-key k] [--allow-install]
2723
+ [--allow-device-control[=names]]
2438
2724
  [--allow-unsafe-anonymous] Serve THIS machine's device over HTTP+JSON for
2439
2725
  \`vk ai/suite/install --server <url>\`. Only
2440
2726
  verikun's validated action grammar is runnable
@@ -2446,16 +2732,31 @@ SERVER (expose a locally-connected device to remote verikun clients)
2446
2732
  Env: VERIKUN_SERVER_AUTH_KEY (keeps it off argv).
2447
2733
  Clients: pass --server <url> (or VERIKUN_SERVER) + --auth-key (or
2448
2734
  VERIKUN_SERVER_AUTH_KEY) to ai/suite/install. The server's device+platform apply.
2735
+ --allow-device-control lets a client restart/stop the server's OWN device (for a
2736
+ device gone flaky mid-suite); --allow-device-control=<avd,sim> additionally lets it
2737
+ START one of those named targets — and, with it, ERASE the device (--wipe). With
2738
+ the flag the server also starts even when no device is attached, so a client can
2739
+ boot one: \`vk devices start|stop|restart [name] --server <url>\`, or add
2740
+ --ensure-device[=name] to ai/suite/install to boot once before the first step.
2449
2741
 
2450
2742
  ENVIRONMENT
2451
- devices [--json] List attached devices/simulators, and which job is
2452
- already driving each (USED BY)
2743
+ devices [--all] [--json] List attached devices/simulators, and which job is
2744
+ already driving each (USED BY); --all also lists
2745
+ startable (not-yet-booted) AVDs/simulators
2746
+ devices start <name> [--wipe] Boot an AVD/simulator; prints its serial. Already
2747
+ [--timeout dur] [--no-wait] running = a no-op. --wipe erases its data first
2748
+ devices stop <name|serial> Shut a running emulator/simulator down
2749
+ devices restart <name> [--wipe] Stop then boot (use for a wedged/flaky device)
2453
2750
  doctor [--fix] Diagnose adb/device, and warn if this CLI or the
2454
2751
  Claude Code plugin is out of date (a warning only —
2455
2752
  it never changes the exit code). --fix disables
2456
2753
  animations. VERIKUN_NO_UPDATE_CHECK skips the check
2457
2754
  companion <status|stop> [--json] On-device hierarchy reader (Android, on by default;
2458
2755
  VERIKUN_COMPANION=0 opts out)
2756
+ Note: \`devices stop\` powers a DEVICE off; \`stop <appId>\` force-stops an APP, and
2757
+ \`device set\` (singular) changes settings on the device you are driving.
2758
+ Physical devices are never power-cycled. Env: VERIKUN_EMULATOR (path to the SDK's
2759
+ \`emulator\` binary, if it isn't on PATH or under \$ANDROID_HOME).
2459
2760
 
2460
2761
  TEST RUNS (actions are recorded; a run auto-starts on first action)
2461
2762
  run start [name] [--force] Begin a named run (else one starts implicitly)