@putdotio/rokit 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -48,12 +48,12 @@ Keep it platform-focused, typed, and useful for both humans and agents.
48
48
  - Missing config/env and child-command failures should not print stack traces.
49
49
  - `ROKIT_PASSWORD` is required only for developer-installer operations such as
50
50
  install and screenshot.
51
- - `ROKU_DEV_TARGET` and `ROKU_DEV_PASSWORD` are compatibility fallbacks, not the
52
- primary public contract.
51
+ - `ROKU_DEV_TARGET` and `ROKU_DEV_PASSWORD` are optional fallback aliases, not
52
+ the primary public contract.
53
53
  - Avoid sleeps in generic commands. App repos can add meaningful wait/assert
54
54
  loops around `rokit` primitives.
55
- - Release details live in `docs/DISTRIBUTION.md`; readiness details live in
56
- `docs/READINESS.md`.
55
+ - Release details live in `docs/DISTRIBUTION.md`; debugging details live in
56
+ `docs/DEBUGGING.md`; readiness details live in `docs/READINESS.md`.
57
57
 
58
58
  ## When Contracts Change
59
59
 
package/README.md CHANGED
@@ -17,12 +17,12 @@
17
17
 
18
18
  ## Install
19
19
 
20
+ Requires Node `>=24.14.0`; install it in a consumer repo with:
21
+
20
22
  ```bash
21
23
  pnpm add -D @putdotio/rokit
22
24
  ```
23
25
 
24
- Node `>=24.14`
25
-
26
26
  ## Quick Start
27
27
 
28
28
  Set the target Roku in the app repo that consumes `rokit`:
@@ -40,13 +40,13 @@ pnpm exec rokit package --out artifacts/live/channel.zip
40
40
  pnpm exec rokit install artifacts/live/channel.zip
41
41
  pnpm exec rokit launch dev
42
42
  pnpm exec rokit press Down Select
43
+ pnpm exec rokit console artifacts/live/console.log --duration-ms 30000
43
44
  pnpm exec rokit proof artifacts/live/proof --screenshot
44
45
  ```
45
46
 
46
47
  `ROKIT_PASSWORD` is required for developer-installer operations such as
47
48
  `install` and `screenshot`. `ROKU_DEV_TARGET` and `ROKU_DEV_PASSWORD` are
48
- accepted as compatibility fallbacks for app repos that already use Roku dev
49
- naming.
49
+ accepted as optional aliases when the `ROKIT_*` names are unset.
50
50
 
51
51
  ## Automation
52
52
 
@@ -76,6 +76,8 @@ Common commands:
76
76
  | `discover` | Find Roku ECP devices with SSDP |
77
77
  | `device-info` | Read Roku device metadata |
78
78
  | `active-app` | Read the foreground app |
79
+ | `console <output-path>` | Capture BrightScript console output from `8085` |
80
+ | `debug-command <command> [args...]` | Run an allowlisted Roku debug command |
79
81
  | `media-player` | Read parsed `/query/media-player` state |
80
82
  | `snapshot` | Read a compact state snapshot |
81
83
  | `proof <output-dir>` | Write reviewable local proof artifacts |
@@ -138,6 +140,7 @@ await waitForSceneGraphAssertion(context, "player ready", (xml) => {
138
140
 
139
141
  - package, install, launch, deeplink params, and remote keypresses
140
142
  - raw ECP queries and parsed media-player state
143
+ - BrightScript console capture and allowlisted debug-server commands
141
144
  - SceneGraph state queries and named-node assertions
142
145
  - timestamped screenshots, snapshots, and proof artifacts
143
146
 
@@ -148,6 +151,7 @@ artifacts.
148
151
  ## Docs
149
152
 
150
153
  - [Contributing](./CONTRIBUTING.md)
154
+ - [Roku debugging](./docs/DEBUGGING.md)
151
155
  - [Distribution](./docs/DISTRIBUTION.md)
152
156
  - [Agent readiness](./docs/READINESS.md)
153
157
  - [Security](./SECURITY.md)
@@ -1,6 +1,37 @@
1
+ import { createConnection } from "node:net";
2
+ import { Schema } from "effect";
3
+ import { existsSync } from "node:fs";
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
1
5
  import * as rokuDeploy from "roku-deploy";
2
6
  import { createSocket } from "node:dgram";
3
- import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
7
+ //#region src/errors.ts
8
+ var InvalidInput = class extends Schema.TaggedErrorClass()("InvalidInput", { message: Schema.String }) {};
9
+ var MissingTarget = class extends Schema.TaggedErrorClass()("MissingTarget", {}) {
10
+ get message() {
11
+ return "ROKIT_TARGET is not set";
12
+ }
13
+ };
14
+ var MissingPassword = class extends Schema.TaggedErrorClass()("MissingPassword", {}) {
15
+ get message() {
16
+ return "ROKIT_PASSWORD is not set";
17
+ }
18
+ };
19
+ var DebugPortUnavailable = class extends Schema.TaggedErrorClass()("DebugPortUnavailable", {
20
+ detail: Schema.String,
21
+ port: Schema.Number
22
+ }) {
23
+ get message() {
24
+ return `Roku debug port ${this.port} unavailable: ${this.detail}`;
25
+ }
26
+ };
27
+ var UnexpectedRokitFailure = class extends Schema.TaggedErrorClass()("UnexpectedRokitFailure", { message: Schema.String }) {};
28
+ const renderError = (error) => error.message;
29
+ const normalizeError = (error) => {
30
+ if (isRokitError(error)) return error;
31
+ return UnexpectedRokitFailure.make({ message: error instanceof Error ? error.message : String(error) });
32
+ };
33
+ const isRokitError = (error) => error instanceof DebugPortUnavailable || error instanceof InvalidInput || error instanceof MissingPassword || error instanceof MissingTarget || error instanceof UnexpectedRokitFailure;
34
+ //#endregion
4
35
  //#region src/xml.ts
5
36
  const readXmlTag = (xml, tag) => {
6
37
  return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1]?.trim();
@@ -215,7 +246,7 @@ const waitForActiveApp = async (context, appId, timeoutMs = 1e4) => {
215
246
  lastApp = await queryActiveApp(context);
216
247
  lastError = void 0;
217
248
  } catch (error) {
218
- lastError = formatErrorMessage(error);
249
+ lastError = formatErrorMessage$1(error);
219
250
  await sleep(500);
220
251
  continue;
221
252
  }
@@ -295,7 +326,7 @@ const waitForMediaPlayerState = async (context, expectedState, timeoutMs = 1e4)
295
326
  lastError = void 0;
296
327
  if (mediaPlayer.state === expectedState) return mediaPlayer;
297
328
  } catch (error) {
298
- lastError = formatErrorMessage(error);
329
+ lastError = formatErrorMessage$1(error);
299
330
  }
300
331
  await sleep(500);
301
332
  }
@@ -320,7 +351,7 @@ const querySceneGraph = async (context, options = {}) => {
320
351
  if (attempt < attempts - 1) await sleep(retryDelayMs);
321
352
  }
322
353
  if (lastXml !== "") throw new Error("SceneGraph query returned incomplete XML");
323
- throw new Error(`SceneGraph query failed: ${formatErrorMessage(lastError)}`);
354
+ throw new Error(`SceneGraph query failed: ${formatErrorMessage$1(lastError)}`);
324
355
  };
325
356
  const assertSceneGraphNode = async (context, nodeName, expectation) => {
326
357
  assertNamedNode(await querySceneGraph(context), nodeName, expectation);
@@ -351,7 +382,7 @@ const waitForSceneGraphAssertion = async (context, description, assertXml, optio
351
382
  assertXml(xml);
352
383
  return xml;
353
384
  } catch (error) {
354
- lastError = formatErrorMessage(error);
385
+ lastError = formatErrorMessage$1(error);
355
386
  }
356
387
  await sleep(pollIntervalMs);
357
388
  }
@@ -399,7 +430,7 @@ const packageOptions = (outputPath, rootDir = process.cwd()) => {
399
430
  };
400
431
  };
401
432
  const validateEcpPath = (path) => {
402
- rejectUnsafeInput(path, "ECP path");
433
+ rejectUnsafeInput$1(path, "ECP path");
403
434
  if (path.includes("\\")) throw new Error("ECP path must not include backslashes");
404
435
  if (path.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(path)) throw new Error("ECP path must be device-relative");
405
436
  if (path.includes("?") || path.includes("#")) throw new Error("ECP path must not include query strings or fragments");
@@ -407,7 +438,7 @@ const validateEcpPath = (path) => {
407
438
  if (/%(?:2e|2f|5c)/i.test(path)) throw new Error("ECP path must not include percent-encoded path segments");
408
439
  return path;
409
440
  };
410
- const rejectUnsafeInput = (value, label) => {
441
+ const rejectUnsafeInput$1 = (value, label) => {
411
442
  if ([...value].some((character) => {
412
443
  const code = character.charCodeAt(0);
413
444
  return code < 32 || code === 127;
@@ -440,7 +471,7 @@ const postLaunchMaybeAccepted = async (context, url) => {
440
471
  });
441
472
  if (!response.ok && response.status !== 503) throw new Error(`POST ${url.pathname} returned HTTP ${response.status}`);
442
473
  } catch (error) {
443
- const message = formatErrorMessage(error).toLowerCase();
474
+ const message = formatErrorMessage$1(error).toLowerCase();
444
475
  if (!message.includes("abort") && !message.includes("timeout") && !message.includes("fetch failed")) throw error;
445
476
  }
446
477
  };
@@ -466,7 +497,7 @@ const readXmlNumberAttribute = (attributes, name) => {
466
497
  const sleep = (ms) => new Promise((resolve) => {
467
498
  setTimeout(resolve, ms);
468
499
  });
469
- const formatErrorMessage = (error) => error instanceof Error ? error.message : String(error);
500
+ const formatErrorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
470
501
  const readSsdpHeaders = (text) => {
471
502
  const headers = /* @__PURE__ */ new Map();
472
503
  for (const line of text.split(/\r?\n/)) {
@@ -484,4 +515,236 @@ const readLocationTarget = (location) => {
484
515
  }
485
516
  };
486
517
  //#endregion
487
- export { assertNamedNode as A, readNamedNodeAttribute as B, takeScreenshot as C, waitForMediaPlayerState as D, waitForActiveApp as E, assertSceneGraphNumberNear as F, readSceneGraphFailure as G, readNamedNodeBounds as H, escapeXmlAttribute as I, readActiveApp as J, readSceneGraphStatus as K, isCompleteSceneGraph as L, assertNamedNodeState as M, assertNamedNodeText as N, waitForSceneGraphAssertion as O, assertNamedNodeTranslation as P, isNamedNodeVisible as R, resolvePackageOutputPath as S, validateRemoteKey as T, readNamedNodeNumber as U, readNamedNodeAttributes as V, readNamedNodeTranslation as W, readXmlTag as X, readXmlAttribute as Y, querySceneGraph as _, getDeviceInfo as a, readMediaPlayerPositionMs as b, launchApp as c, queryActiveApp as d, queryEcp as f, queryMediaPlayerXmlSafe as g, queryMediaPlayerXml as h, discoverRokuDevices as i, assertNamedNodeSize as j, waitForSceneGraphNode as k, packageChannel as l, queryMediaPlayerSafe as m, assertSceneGraphNode as n, installPackage as o, queryMediaPlayer as p, sceneGraphContainsText as q, checkDevice as r, isActiveMediaPlayerState as s, assertMediaPlayerContainer as t, pressKey as u, readMediaPlayerContainer as v, validateEcpPath as w, readMediaPlayerState as x, readMediaPlayerInfo as y, parseSceneGraphNumberList as z };
518
+ //#region src/runtime.ts
519
+ const appDir = process.cwd();
520
+ const envPath = join(join(appDir, ".rokit"), ".env");
521
+ const loadLocalEnv = () => {
522
+ if (existsSync(envPath)) process.loadEnvFile(envPath);
523
+ };
524
+ const loadEnv = () => ({
525
+ password: process.env.ROKIT_PASSWORD ?? process.env.ROKU_DEV_PASSWORD,
526
+ target: process.env.ROKIT_TARGET ?? process.env.ROKU_DEV_TARGET,
527
+ timeoutMs: parseTimeout(process.env.ROKIT_TIMEOUT_MS),
528
+ username: process.env.ROKIT_USERNAME ?? "rokudev"
529
+ });
530
+ const requireTarget = (env) => {
531
+ const target = env.target?.trim();
532
+ if (!target) throw MissingTarget.make({});
533
+ return normalizeTarget(target);
534
+ };
535
+ const requirePassword = (env) => {
536
+ const password = env.password;
537
+ if (!password) throw MissingPassword.make({});
538
+ return password;
539
+ };
540
+ const resolveOutputPath = (path, label) => {
541
+ rejectUnsafeInput(path, label);
542
+ const resolved = resolve(appDir, path);
543
+ const relativePath = relative(appDir, resolved);
544
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) fail(`${label} must stay within the current working directory`);
545
+ return resolved;
546
+ };
547
+ const resolveFileOutputPath = (path, label) => {
548
+ const resolved = resolveOutputPath(path, label);
549
+ if (resolved === appDir) fail(`${label} must name a file within the current working directory`);
550
+ return resolved;
551
+ };
552
+ const rejectUnsafeInput = (value, label) => {
553
+ if ([...value].some((character) => {
554
+ const code = character.charCodeAt(0);
555
+ return code < 32 || code === 127;
556
+ })) fail(`${label} contains control characters`);
557
+ };
558
+ const rejectUnsafeEcpPath = (value) => {
559
+ try {
560
+ validateEcpPath(value);
561
+ } catch (error) {
562
+ fail(formatErrorMessage(error));
563
+ }
564
+ };
565
+ const fail = (message) => {
566
+ throw InvalidInput.make({ message });
567
+ };
568
+ const formatErrorMessage = (error) => {
569
+ if (error instanceof Error) return error.message;
570
+ return String(error);
571
+ };
572
+ const normalizeTarget = (target) => target.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
573
+ const parseTimeout = (value) => {
574
+ if (value === void 0) return 1e4;
575
+ const timeout = Number(value);
576
+ if (!Number.isFinite(timeout) || timeout <= 0) fail(`Invalid ROKIT_TIMEOUT_MS: ${value}`);
577
+ return timeout;
578
+ };
579
+ //#endregion
580
+ //#region src/debug.ts
581
+ const debugServerPort = 8080;
582
+ const brightScriptConsolePort = 8085;
583
+ const buildDebugCommand = (command, args) => {
584
+ validateDebugToken(command, "debug command");
585
+ for (const arg of args) rejectUnsafeInput(arg, "debug command argument");
586
+ if (command === "chanperf") {
587
+ validateChanperfArgs(args);
588
+ return debugServerCommand(command, args);
589
+ }
590
+ if (command === "brightscript_warnings") {
591
+ validateOptionalNonNegativeInteger(args, "brightscript_warnings");
592
+ return debugServerCommand(command, args);
593
+ }
594
+ if (command === "free" || command === "loaded_textures" || command === "r2d2_bitmaps") {
595
+ validateNoArgs(command, args);
596
+ return debugServerCommand(command, args);
597
+ }
598
+ if (command === "sgnodes") return debugServerCommand(command, normalizeSceneGraphNodeArgs(args));
599
+ if (command === "bsc" || command === "bscs" || command === "bt" || command === "classes" || command === "help" || command === "last" || command === "list") {
600
+ validateNoArgs(command, args);
601
+ return brightScriptConsoleCommand(command, args);
602
+ }
603
+ if (command === "threads" || command === "ths") {
604
+ validateOptionalNonNegativeInteger(args, command);
605
+ return brightScriptConsoleCommand(command, args);
606
+ }
607
+ return fail(`Unsupported Roku debug command: ${command}`);
608
+ };
609
+ const runDebugCommand = async (context, command, durationMs, idleTimeoutMs) => {
610
+ const safeCommand = buildDebugCommand(command.command, command.args);
611
+ const startedAt = Date.now();
612
+ const port = resolveDebugPort(context, safeCommand.port);
613
+ const body = await readDebugSocket(context, port, {
614
+ durationMs,
615
+ idleTimeoutMs,
616
+ request: safeCommand.request
617
+ });
618
+ const elapsedMs = Date.now() - startedAt;
619
+ return {
620
+ args: safeCommand.args,
621
+ body,
622
+ bytes: Buffer.byteLength(body),
623
+ command: safeCommand.command,
624
+ elapsedMs,
625
+ port
626
+ };
627
+ };
628
+ const captureDebugConsole = async (context, durationMs) => {
629
+ const startedAt = Date.now();
630
+ const port = resolveDebugPort(context, brightScriptConsolePort);
631
+ const body = await readDebugSocket(context, port, { durationMs });
632
+ const elapsedMs = Date.now() - startedAt;
633
+ return {
634
+ body,
635
+ bytes: Buffer.byteLength(body),
636
+ durationMs,
637
+ elapsedMs,
638
+ port
639
+ };
640
+ };
641
+ const debugServerCommand = (command, args) => ({
642
+ args,
643
+ command,
644
+ port: debugServerPort,
645
+ request: formatDebugRequest(command, args)
646
+ });
647
+ const brightScriptConsoleCommand = (command, args) => ({
648
+ args,
649
+ command,
650
+ port: brightScriptConsolePort,
651
+ request: formatDebugRequest(command, args)
652
+ });
653
+ const readDebugSocket = async (context, port, options) => await new Promise((resolve, reject) => {
654
+ let body = "";
655
+ let durationTimer;
656
+ let idleTimer;
657
+ let settled = false;
658
+ const socket = createConnection({
659
+ host: context.target,
660
+ port
661
+ });
662
+ const clearTimers = () => {
663
+ if (durationTimer !== void 0) clearTimeout(durationTimer);
664
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
665
+ };
666
+ const finish = () => {
667
+ if (settled) return;
668
+ settled = true;
669
+ clearTimers();
670
+ socket.destroy();
671
+ resolve(body);
672
+ };
673
+ const failRead = (detail) => {
674
+ if (settled) return;
675
+ settled = true;
676
+ clearTimers();
677
+ socket.destroy();
678
+ reject(DebugPortUnavailable.make({
679
+ detail,
680
+ port
681
+ }));
682
+ };
683
+ const scheduleIdleTimer = () => {
684
+ if (options.idleTimeoutMs === void 0) return;
685
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
686
+ idleTimer = setTimeout(finish, options.idleTimeoutMs);
687
+ };
688
+ socket.setEncoding("utf8");
689
+ socket.setTimeout(context.timeoutMs);
690
+ socket.once("connect", () => {
691
+ socket.setTimeout(0);
692
+ durationTimer = setTimeout(finish, options.durationMs);
693
+ if (options.request !== void 0) socket.write(options.request);
694
+ });
695
+ socket.on("data", (chunk) => {
696
+ body = `${body}${chunk}`;
697
+ scheduleIdleTimer();
698
+ });
699
+ socket.once("timeout", () => {
700
+ failRead("connection timed out");
701
+ });
702
+ socket.once("error", (error) => {
703
+ if (body.length > 0) {
704
+ finish();
705
+ return;
706
+ }
707
+ failRead(error.message);
708
+ });
709
+ socket.once("close", finish);
710
+ });
711
+ const formatDebugRequest = (command, args) => `${[command, ...args].join(" ")}\r\n`;
712
+ const resolveDebugPort = (context, port) => port === debugServerPort ? context.debugServerPort ?? debugServerPort : context.debugConsolePort ?? brightScriptConsolePort;
713
+ const validateDebugToken = (value, label) => {
714
+ rejectUnsafeInput(value, label);
715
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) fail(`${label} contains unsupported characters`);
716
+ };
717
+ const validateChanperfArgs = (args) => {
718
+ if (args.length === 0) return;
719
+ if (args[0] === "-r") fail("chanperf -r writes to the BrightScript console; use rokit console for capture");
720
+ fail("usage: rokit debug-command chanperf");
721
+ };
722
+ const normalizeSceneGraphNodeArgs = (args) => {
723
+ if (args.length === 1 && (args[0] === "roots" || args[0] === "all")) return args;
724
+ if (args.length === 1) {
725
+ validateDebugToken(args[0] ?? "", "sgnodes id");
726
+ return args;
727
+ }
728
+ if (args.length === 2 && args[0] === "id") {
729
+ validateDebugToken(args[1] ?? "", "sgnodes id");
730
+ return [args[1] ?? ""];
731
+ }
732
+ return fail("usage: rokit debug-command sgnodes <roots|all|node-id|id node-id>");
733
+ };
734
+ const validateNoArgs = (command, args) => {
735
+ if (args.length > 0) fail(`usage: rokit debug-command ${command}`);
736
+ };
737
+ const validateOptionalNonNegativeInteger = (args, command) => {
738
+ if (args.length === 0) return;
739
+ if (args.length === 1) {
740
+ validateNonNegativeInteger(args[0] ?? "", `${command} argument`);
741
+ return;
742
+ }
743
+ fail(`usage: rokit debug-command ${command} [id]`);
744
+ };
745
+ const validateNonNegativeInteger = (value, label) => {
746
+ const parsed = Number(value);
747
+ if (!Number.isInteger(parsed) || parsed < 0) fail(`Invalid ${label}: ${value}`);
748
+ };
749
+ //#endregion
750
+ export { readNamedNodeBounds as $, readMediaPlayerInfo as A, waitForSceneGraphNode as B, queryEcp as C, queryMediaPlayerXmlSafe as D, queryMediaPlayerXml as E, validateEcpPath as F, assertNamedNodeTranslation as G, assertNamedNodeSize as H, validateRemoteKey as I, isCompleteSceneGraph as J, assertSceneGraphNumberNear as K, waitForActiveApp as L, readMediaPlayerState as M, resolvePackageOutputPath as N, querySceneGraph as O, takeScreenshot as P, readNamedNodeAttributes as Q, waitForMediaPlayerState as R, queryActiveApp as S, queryMediaPlayerSafe as T, assertNamedNodeState as U, assertNamedNode as V, assertNamedNodeText as W, parseSceneGraphNumberList as X, isNamedNodeVisible as Y, readNamedNodeAttribute as Z, installPackage as _, loadEnv as a, readActiveApp as at, packageChannel as b, requirePassword as c, normalizeError as ct, resolveOutputPath as d, readNamedNodeNumber as et, assertMediaPlayerContainer as f, getDeviceInfo as g, discoverRokuDevices as h, fail as i, sceneGraphContainsText as it, readMediaPlayerPositionMs as j, readMediaPlayerContainer as k, requireTarget as l, renderError as lt, checkDevice as m, captureDebugConsole as n, readSceneGraphFailure as nt, loadLocalEnv as o, readXmlAttribute as ot, assertSceneGraphNode as p, escapeXmlAttribute as q, runDebugCommand as r, readSceneGraphStatus as rt, rejectUnsafeEcpPath as s, readXmlTag as st, buildDebugCommand as t, readNamedNodeTranslation as tt, resolveFileOutputPath as u, isActiveMediaPlayerState as v, queryMediaPlayer as w, pressKey as x, launchApp as y, waitForSceneGraphAssertion as z };
package/dist/index.d.mts CHANGED
@@ -49,6 +49,8 @@ declare const readActiveApp: (xml: string) => ActiveApp;
49
49
  declare const remoteKeys: readonly ["Home", "Rev", "Fwd", "Play", "Select", "Left", "Right", "Down", "Up", "Back", "InstantReplay", "Info", "Backspace", "Search", "Enter", "VolumeDown", "VolumeMute", "VolumeUp", "PowerOff", "ChannelUp", "ChannelDown", "InputTuner", "InputHDMI1", "InputHDMI2", "InputHDMI3", "InputHDMI4"];
50
50
  type RemoteKey = (typeof remoteKeys)[number] | `Lit_${string}`;
51
51
  type RokuContext = {
52
+ readonly debugConsolePort?: number;
53
+ readonly debugServerPort?: number;
52
54
  readonly password?: string;
53
55
  readonly target: string;
54
56
  readonly timeoutMs: number;
@@ -130,4 +132,31 @@ declare const packageChannel: (outputPath: string, rootDir?: string) => Promise<
130
132
  declare const validateEcpPath: (path: string) => string;
131
133
  declare const validateRemoteKey: (key: string) => void;
132
134
  //#endregion
133
- export { type ActiveApp, type DeviceSummary, type DiscoveredRokuDevice, type MediaPlayerInfo, type MediaPlayerState, type NodeExpectation, type NodeState, type PackageResult, type RemoteKey, type RetryOptions, type RokuContext, type SceneGraphAssertion, type SceneGraphBounds, type SceneGraphPoint, type SceneGraphStatus, type WaitForSceneGraphAssertionOptions, assertMediaPlayerContainer, assertNamedNode, assertNamedNodeSize, assertNamedNodeState, assertNamedNodeText, assertNamedNodeTranslation, assertSceneGraphNode, assertSceneGraphNumberNear, checkDevice, discoverRokuDevices, escapeXmlAttribute, getDeviceInfo, installPackage, isActiveMediaPlayerState, isCompleteSceneGraph, isNamedNodeVisible, launchApp, packageChannel, parseSceneGraphNumberList, pressKey, queryActiveApp, queryEcp, queryMediaPlayer, queryMediaPlayerSafe, queryMediaPlayerXml, queryMediaPlayerXmlSafe, querySceneGraph, readActiveApp, readMediaPlayerContainer, readMediaPlayerInfo, readMediaPlayerPositionMs, readMediaPlayerState, readNamedNodeAttribute, readNamedNodeAttributes, readNamedNodeBounds, readNamedNodeNumber, readNamedNodeTranslation, readSceneGraphFailure, readSceneGraphStatus, readXmlAttribute, readXmlTag, sceneGraphContainsText, takeScreenshot, validateEcpPath, validateRemoteKey, waitForActiveApp, waitForMediaPlayerState, waitForSceneGraphAssertion, waitForSceneGraphNode };
135
+ //#region src/debug.d.ts
136
+ type RokuDebugPort = 8080 | 8085;
137
+ type RokuDebugCommand = {
138
+ readonly args: readonly string[];
139
+ readonly command: string;
140
+ readonly port: RokuDebugPort;
141
+ readonly request: string;
142
+ };
143
+ type DebugCommandResult = {
144
+ readonly args: readonly string[];
145
+ readonly body: string;
146
+ readonly bytes: number;
147
+ readonly command: string;
148
+ readonly elapsedMs: number;
149
+ readonly port: number;
150
+ };
151
+ type DebugConsoleCapture = {
152
+ readonly body: string;
153
+ readonly bytes: number;
154
+ readonly durationMs: number;
155
+ readonly elapsedMs: number;
156
+ readonly port: number;
157
+ };
158
+ declare const buildDebugCommand: (command: string, args: readonly string[]) => RokuDebugCommand;
159
+ declare const runDebugCommand: (context: RokuContext, command: RokuDebugCommand, durationMs: number, idleTimeoutMs: number) => Promise<DebugCommandResult>;
160
+ declare const captureDebugConsole: (context: RokuContext, durationMs: number) => Promise<DebugConsoleCapture>;
161
+ //#endregion
162
+ export { type ActiveApp, type DebugCommandResult, type DebugConsoleCapture, type DeviceSummary, type DiscoveredRokuDevice, type MediaPlayerInfo, type MediaPlayerState, type NodeExpectation, type NodeState, type PackageResult, type RemoteKey, type RetryOptions, type RokuContext, type RokuDebugCommand, type RokuDebugPort, type SceneGraphAssertion, type SceneGraphBounds, type SceneGraphPoint, type SceneGraphStatus, type WaitForSceneGraphAssertionOptions, assertMediaPlayerContainer, assertNamedNode, assertNamedNodeSize, assertNamedNodeState, assertNamedNodeText, assertNamedNodeTranslation, assertSceneGraphNode, assertSceneGraphNumberNear, buildDebugCommand, captureDebugConsole, checkDevice, discoverRokuDevices, escapeXmlAttribute, getDeviceInfo, installPackage, isActiveMediaPlayerState, isCompleteSceneGraph, isNamedNodeVisible, launchApp, packageChannel, parseSceneGraphNumberList, pressKey, queryActiveApp, queryEcp, queryMediaPlayer, queryMediaPlayerSafe, queryMediaPlayerXml, queryMediaPlayerXmlSafe, querySceneGraph, readActiveApp, readMediaPlayerContainer, readMediaPlayerInfo, readMediaPlayerPositionMs, readMediaPlayerState, readNamedNodeAttribute, readNamedNodeAttributes, readNamedNodeBounds, readNamedNodeNumber, readNamedNodeTranslation, readSceneGraphFailure, readSceneGraphStatus, readXmlAttribute, readXmlTag, runDebugCommand, sceneGraphContainsText, takeScreenshot, validateEcpPath, validateRemoteKey, waitForActiveApp, waitForMediaPlayerState, waitForSceneGraphAssertion, waitForSceneGraphNode };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { A as assertNamedNode, B as readNamedNodeAttribute, C as takeScreenshot, D as waitForMediaPlayerState, E as waitForActiveApp, F as assertSceneGraphNumberNear, G as readSceneGraphFailure, H as readNamedNodeBounds, I as escapeXmlAttribute, J as readActiveApp, K as readSceneGraphStatus, L as isCompleteSceneGraph, M as assertNamedNodeState, N as assertNamedNodeText, O as waitForSceneGraphAssertion, P as assertNamedNodeTranslation, R as isNamedNodeVisible, T as validateRemoteKey, U as readNamedNodeNumber, V as readNamedNodeAttributes, W as readNamedNodeTranslation, X as readXmlTag, Y as readXmlAttribute, _ as querySceneGraph, a as getDeviceInfo, b as readMediaPlayerPositionMs, c as launchApp, d as queryActiveApp, f as queryEcp, g as queryMediaPlayerXmlSafe, h as queryMediaPlayerXml, i as discoverRokuDevices, j as assertNamedNodeSize, k as waitForSceneGraphNode, l as packageChannel, m as queryMediaPlayerSafe, n as assertSceneGraphNode, o as installPackage, p as queryMediaPlayer, q as sceneGraphContainsText, r as checkDevice, s as isActiveMediaPlayerState, t as assertMediaPlayerContainer, u as pressKey, v as readMediaPlayerContainer, w as validateEcpPath, x as readMediaPlayerState, y as readMediaPlayerInfo, z as parseSceneGraphNumberList } from "./roku-BxnS6Axs.mjs";
2
- export { assertMediaPlayerContainer, assertNamedNode, assertNamedNodeSize, assertNamedNodeState, assertNamedNodeText, assertNamedNodeTranslation, assertSceneGraphNode, assertSceneGraphNumberNear, checkDevice, discoverRokuDevices, escapeXmlAttribute, getDeviceInfo, installPackage, isActiveMediaPlayerState, isCompleteSceneGraph, isNamedNodeVisible, launchApp, packageChannel, parseSceneGraphNumberList, pressKey, queryActiveApp, queryEcp, queryMediaPlayer, queryMediaPlayerSafe, queryMediaPlayerXml, queryMediaPlayerXmlSafe, querySceneGraph, readActiveApp, readMediaPlayerContainer, readMediaPlayerInfo, readMediaPlayerPositionMs, readMediaPlayerState, readNamedNodeAttribute, readNamedNodeAttributes, readNamedNodeBounds, readNamedNodeNumber, readNamedNodeTranslation, readSceneGraphFailure, readSceneGraphStatus, readXmlAttribute, readXmlTag, sceneGraphContainsText, takeScreenshot, validateEcpPath, validateRemoteKey, waitForActiveApp, waitForMediaPlayerState, waitForSceneGraphAssertion, waitForSceneGraphNode };
1
+ import { $ as readNamedNodeBounds, A as readMediaPlayerInfo, B as waitForSceneGraphNode, C as queryEcp, D as queryMediaPlayerXmlSafe, E as queryMediaPlayerXml, F as validateEcpPath, G as assertNamedNodeTranslation, H as assertNamedNodeSize, I as validateRemoteKey, J as isCompleteSceneGraph, K as assertSceneGraphNumberNear, L as waitForActiveApp, M as readMediaPlayerState, O as querySceneGraph, P as takeScreenshot, Q as readNamedNodeAttributes, R as waitForMediaPlayerState, S as queryActiveApp, T as queryMediaPlayerSafe, U as assertNamedNodeState, V as assertNamedNode, W as assertNamedNodeText, X as parseSceneGraphNumberList, Y as isNamedNodeVisible, Z as readNamedNodeAttribute, _ as installPackage, at as readActiveApp, b as packageChannel, et as readNamedNodeNumber, f as assertMediaPlayerContainer, g as getDeviceInfo, h as discoverRokuDevices, it as sceneGraphContainsText, j as readMediaPlayerPositionMs, k as readMediaPlayerContainer, m as checkDevice, n as captureDebugConsole, nt as readSceneGraphFailure, ot as readXmlAttribute, p as assertSceneGraphNode, q as escapeXmlAttribute, r as runDebugCommand, rt as readSceneGraphStatus, st as readXmlTag, t as buildDebugCommand, tt as readNamedNodeTranslation, v as isActiveMediaPlayerState, w as queryMediaPlayer, x as pressKey, y as launchApp, z as waitForSceneGraphAssertion } from "./debug-af6vvS9T.mjs";
2
+ export { assertMediaPlayerContainer, assertNamedNode, assertNamedNodeSize, assertNamedNodeState, assertNamedNodeText, assertNamedNodeTranslation, assertSceneGraphNode, assertSceneGraphNumberNear, buildDebugCommand, captureDebugConsole, checkDevice, discoverRokuDevices, escapeXmlAttribute, getDeviceInfo, installPackage, isActiveMediaPlayerState, isCompleteSceneGraph, isNamedNodeVisible, launchApp, packageChannel, parseSceneGraphNumberList, pressKey, queryActiveApp, queryEcp, queryMediaPlayer, queryMediaPlayerSafe, queryMediaPlayerXml, queryMediaPlayerXmlSafe, querySceneGraph, readActiveApp, readMediaPlayerContainer, readMediaPlayerInfo, readMediaPlayerPositionMs, readMediaPlayerState, readNamedNodeAttribute, readNamedNodeAttributes, readNamedNodeBounds, readNamedNodeNumber, readNamedNodeTranslation, readSceneGraphFailure, readSceneGraphStatus, readXmlAttribute, readXmlTag, runDebugCommand, sceneGraphContainsText, takeScreenshot, validateEcpPath, validateRemoteKey, waitForActiveApp, waitForMediaPlayerState, waitForSceneGraphAssertion, waitForSceneGraphNode };
package/dist/rokit.mjs CHANGED
@@ -1,94 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { C as takeScreenshot, D as waitForMediaPlayerState, E as waitForActiveApp, G as readSceneGraphFailure, K as readSceneGraphStatus, S as resolvePackageOutputPath, T as validateRemoteKey, _ as querySceneGraph, a as getDeviceInfo, c as launchApp, d as queryActiveApp, f as queryEcp, i as discoverRokuDevices, k as waitForSceneGraphNode, l as packageChannel, n as assertSceneGraphNode, o as installPackage, p as queryMediaPlayer, r as checkDevice, u as pressKey, w as validateEcpPath } from "./roku-BxnS6Axs.mjs";
2
+ import { B as waitForSceneGraphNode, C as queryEcp, I as validateRemoteKey, L as waitForActiveApp, N as resolvePackageOutputPath, O as querySceneGraph, P as takeScreenshot, R as waitForMediaPlayerState, S as queryActiveApp, _ as installPackage, a as loadEnv, b as packageChannel, c as requirePassword, ct as normalizeError, d as resolveOutputPath, g as getDeviceInfo, h as discoverRokuDevices, i as fail, l as requireTarget, lt as renderError, m as checkDevice, n as captureDebugConsole, nt as readSceneGraphFailure, o as loadLocalEnv, p as assertSceneGraphNode, r as runDebugCommand, rt as readSceneGraphStatus, s as rejectUnsafeEcpPath, t as buildDebugCommand, u as resolveFileOutputPath, w as queryMediaPlayer, x as pressKey, y as launchApp } from "./debug-af6vvS9T.mjs";
3
3
  import { createRequire } from "node:module";
4
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { Effect } from "effect";
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { basename, dirname, extname, join } from "node:path";
5
7
  import { NodeRuntime } from "@effect/platform-node";
6
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
- import { Effect, Schema } from "effect";
8
- //#region src/errors.ts
9
- var InvalidInput = class extends Schema.TaggedErrorClass()("InvalidInput", { message: Schema.String }) {};
10
- var MissingTarget = class extends Schema.TaggedErrorClass()("MissingTarget", {}) {
11
- get message() {
12
- return "ROKIT_TARGET is not set";
13
- }
14
- };
15
- var MissingPassword = class extends Schema.TaggedErrorClass()("MissingPassword", {}) {
16
- get message() {
17
- return "ROKIT_PASSWORD is not set";
18
- }
19
- };
20
- var UnexpectedRokitFailure = class extends Schema.TaggedErrorClass()("UnexpectedRokitFailure", { message: Schema.String }) {};
21
- const renderError = (error) => error.message;
22
- const normalizeError = (error) => {
23
- if (isRokitError(error)) return error;
24
- return UnexpectedRokitFailure.make({ message: error instanceof Error ? error.message : String(error) });
25
- };
26
- const isRokitError = (error) => error instanceof InvalidInput || error instanceof MissingPassword || error instanceof MissingTarget || error instanceof UnexpectedRokitFailure;
27
- //#endregion
28
- //#region src/runtime.ts
29
- const appDir = process.cwd();
30
- const envPath = join(join(appDir, ".rokit"), ".env");
31
- const loadLocalEnv = () => {
32
- if (existsSync(envPath)) process.loadEnvFile(envPath);
33
- };
34
- const loadEnv = () => ({
35
- password: process.env.ROKIT_PASSWORD ?? process.env.ROKU_DEV_PASSWORD,
36
- target: process.env.ROKIT_TARGET ?? process.env.ROKU_DEV_TARGET,
37
- timeoutMs: parseTimeout(process.env.ROKIT_TIMEOUT_MS),
38
- username: process.env.ROKIT_USERNAME ?? "rokudev"
39
- });
40
- const requireTarget = (env) => {
41
- const target = env.target?.trim();
42
- if (!target) throw MissingTarget.make({});
43
- return normalizeTarget(target);
44
- };
45
- const requirePassword = (env) => {
46
- const password = env.password;
47
- if (!password) throw MissingPassword.make({});
48
- return password;
49
- };
50
- const resolveOutputPath = (path, label) => {
51
- rejectUnsafeInput(path, label);
52
- const resolved = resolve(appDir, path);
53
- const relativePath = relative(appDir, resolved);
54
- if (relativePath.startsWith("..") || isAbsolute(relativePath)) fail(`${label} must stay within the current working directory`);
55
- return resolved;
56
- };
57
- const resolveFileOutputPath = (path, label) => {
58
- const resolved = resolveOutputPath(path, label);
59
- if (resolved === appDir) fail(`${label} must name a file within the current working directory`);
60
- return resolved;
61
- };
62
- const rejectUnsafeInput = (value, label) => {
63
- if ([...value].some((character) => {
64
- const code = character.charCodeAt(0);
65
- return code < 32 || code === 127;
66
- })) fail(`${label} contains control characters`);
67
- };
68
- const rejectUnsafeEcpPath = (value) => {
69
- try {
70
- validateEcpPath(value);
71
- } catch (error) {
72
- fail(formatErrorMessage(error));
73
- }
74
- };
75
- const fail = (message) => {
76
- throw InvalidInput.make({ message });
77
- };
78
- const formatErrorMessage = (error) => {
79
- if (error instanceof Error) return error.message;
80
- return String(error);
81
- };
82
- const normalizeTarget = (target) => target.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
83
- const parseTimeout = (value) => {
84
- if (value === void 0) return 1e4;
85
- const timeout = Number(value);
86
- if (!Number.isFinite(timeout) || timeout <= 0) fail(`Invalid ROKIT_TIMEOUT_MS: ${value}`);
87
- return timeout;
88
- };
89
- //#endregion
90
8
  //#region src/cli.ts
91
9
  const packageJson = createRequire(import.meta.url)("../package.json");
10
+ const defaultConsoleDurationMs = 3e4;
11
+ const defaultDebugCommandDurationMs = 3e3;
12
+ const defaultDebugCommandIdleTimeoutMs = 500;
92
13
  const mainEffect = Effect.fn("mainEffect")(function* (argv = process.argv.slice(2)) {
93
14
  let outputMode = inferOutputMode(argv);
94
15
  let fields = [];
@@ -177,6 +98,30 @@ const runCommand = async (context, command, dryRun) => {
177
98
  status: "ok"
178
99
  };
179
100
  }
101
+ if (command.name === "console") {
102
+ const path = timestampOutputPath(resolveFileOutputPath(command.args.outputPath, "console output path"));
103
+ const capture = await captureDebugConsole(deviceContext, command.args.durationMs);
104
+ mkdirSync(dirname(path), { recursive: true });
105
+ writeFileSync(path, capture.body);
106
+ return {
107
+ command: command.name,
108
+ data: {
109
+ ...capture,
110
+ path
111
+ },
112
+ message: `console: ${path}`,
113
+ status: "ok"
114
+ };
115
+ }
116
+ if (command.name === "debug-command") {
117
+ const result = await runDebugCommand(deviceContext, command.args.command, command.args.durationMs, command.args.idleTimeoutMs);
118
+ return {
119
+ command: command.name,
120
+ data: result,
121
+ message: result.body,
122
+ status: "ok"
123
+ };
124
+ }
180
125
  if (command.name === "device-info") return {
181
126
  command: command.name,
182
127
  data: await getDeviceInfo(deviceContext),
@@ -518,7 +463,7 @@ const commandNeedsTarget = (command, dryRun) => {
518
463
  if (command.name === "describe" || command.name === "discover" || command.name === "package") return false;
519
464
  return !(dryRun && commandSupportsDryRun(command));
520
465
  };
521
- const commandSupportsDryRun = (command) => command.name === "install" || command.name === "launch" || command.name === "package" || command.name === "press" || command.name === "proof" || command.name === "screenshot";
466
+ const commandSupportsDryRun = (command) => command.name === "console" || command.name === "debug-command" || command.name === "install" || command.name === "launch" || command.name === "package" || command.name === "press" || command.name === "proof" || command.name === "screenshot";
522
467
  const dryRunData = (command) => {
523
468
  if (command.name === "launch") return {
524
469
  appId: command.args.appId,
@@ -533,6 +478,19 @@ const dryRunData = (command) => {
533
478
  until: command.args.until ? formatNodeData(command.args.until) : void 0
534
479
  };
535
480
  }
481
+ if (command.name === "console") return {
482
+ durationMs: command.args.durationMs,
483
+ path: timestampOutputPath(resolveFileOutputPath(command.args.outputPath, "console output path")),
484
+ port: 8085
485
+ };
486
+ if (command.name === "debug-command") return {
487
+ args: command.args.command.args,
488
+ command: command.args.command.command,
489
+ durationMs: command.args.durationMs,
490
+ idleTimeoutMs: command.args.idleTimeoutMs,
491
+ port: command.args.command.port,
492
+ request: command.args.command.request.trim()
493
+ };
536
494
  if (command.name === "screenshot") return { path: timestampOutputPath(resolveFileOutputPath(command.outputPath, "screenshot output path")) };
537
495
  if (command.name === "proof") return {
538
496
  outputDir: resolveOutputPath(command.outputDir, "proof output directory"),
@@ -628,6 +586,14 @@ const parseCommand = (options) => {
628
586
  const [name, ...args] = options.args;
629
587
  if (name === "describe") return { name };
630
588
  if (name === "check") return { name };
589
+ if (name === "console") return {
590
+ name,
591
+ args: parseConsoleArgs(args)
592
+ };
593
+ if (name === "debug-command") return {
594
+ name,
595
+ args: parseDebugCommandArgs(args)
596
+ };
631
597
  if (name === "discover") return {
632
598
  name,
633
599
  timeoutMs: parseOptionalTimeout(args, "rokit discover")
@@ -784,6 +750,52 @@ const parseOutputPath = (args, usage) => {
784
750
  if (args.length === 2 && args[0] === "--out" && args[1] !== void 0) return args[1];
785
751
  return fail(`usage: ${usage}`);
786
752
  };
753
+ const parseConsoleArgs = (args) => {
754
+ let outputPath;
755
+ let durationMs = defaultConsoleDurationMs;
756
+ for (let index = 0; index < args.length; index += 1) {
757
+ const arg = args[index];
758
+ if (arg === "--duration-ms") {
759
+ durationMs = parsePositiveInteger(args[index + 1] ?? "", "duration");
760
+ index += 1;
761
+ continue;
762
+ }
763
+ if (arg?.startsWith("--")) fail(`Unknown console option: ${arg}`);
764
+ if (outputPath !== void 0 || arg === void 0) fail("usage: rokit console <output-path> [--duration-ms <ms>]");
765
+ outputPath = arg;
766
+ }
767
+ if (outputPath !== void 0) return {
768
+ durationMs,
769
+ outputPath
770
+ };
771
+ return fail("usage: rokit console <output-path> [--duration-ms <ms>]");
772
+ };
773
+ const parseDebugCommandArgs = (args) => {
774
+ let durationMs = defaultDebugCommandDurationMs;
775
+ let idleTimeoutMs = defaultDebugCommandIdleTimeoutMs;
776
+ const commandArgs = [];
777
+ for (let index = 0; index < args.length; index += 1) {
778
+ const arg = args[index];
779
+ if (arg === "--duration-ms") {
780
+ durationMs = parsePositiveInteger(args[index + 1] ?? "", "duration");
781
+ index += 1;
782
+ continue;
783
+ }
784
+ if (arg === "--idle-timeout-ms") {
785
+ idleTimeoutMs = parsePositiveInteger(args[index + 1] ?? "", "idle timeout");
786
+ index += 1;
787
+ continue;
788
+ }
789
+ if (arg !== void 0) commandArgs.push(arg);
790
+ }
791
+ const [command, ...debugArgs] = commandArgs;
792
+ if (command === void 0) fail("usage: rokit debug-command <command> [args...] [--duration-ms <ms>]");
793
+ return {
794
+ command: buildDebugCommand(command, debugArgs),
795
+ durationMs,
796
+ idleTimeoutMs
797
+ };
798
+ };
787
799
  const parseProofArgs = (args) => {
788
800
  let outputDir;
789
801
  let screenshot = false;
@@ -855,6 +867,21 @@ const parseInputJson = (value) => {
855
867
  timeoutMs: readOptionalNumber(parsed, "timeoutMs")
856
868
  };
857
869
  if (command === "check" || command === "device-info" || command === "active-app") return { name: command };
870
+ if (command === "console") return {
871
+ args: {
872
+ durationMs: readOptionalNumber(parsed, "durationMs") ?? defaultConsoleDurationMs,
873
+ outputPath: readString(parsed, "outputPath")
874
+ },
875
+ name: "console"
876
+ };
877
+ if (command === "debug-command") return {
878
+ args: {
879
+ command: buildDebugCommand(readString(parsed, "debugCommand"), readOptionalStringArray(parsed, "args") ?? []),
880
+ durationMs: readOptionalNumber(parsed, "durationMs") ?? defaultDebugCommandDurationMs,
881
+ idleTimeoutMs: readOptionalNumber(parsed, "idleTimeoutMs") ?? defaultDebugCommandIdleTimeoutMs
882
+ },
883
+ name: "debug-command"
884
+ };
858
885
  if (command === "media-player" || command === "sgnodes" || command === "snapshot") return { name: command };
859
886
  if (command === "wait-active") return {
860
887
  appId: readString(parsed, "appId"),
@@ -962,6 +989,12 @@ const readStringArray = (record, key) => {
962
989
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return fail(`input JSON field must be a string array: ${key}`);
963
990
  return value.map((item) => String(item));
964
991
  };
992
+ const readOptionalStringArray = (record, key) => {
993
+ const value = record[key];
994
+ if (value === void 0) return;
995
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return fail(`input JSON field must be a string array: ${key}`);
996
+ return value.map((item) => String(item));
997
+ };
965
998
  const readStringMap = (record, key) => {
966
999
  const value = record[key];
967
1000
  if (value === void 0) return /* @__PURE__ */ new Map();
@@ -1091,6 +1124,13 @@ const describeCli = () => ({
1091
1124
  commands: [
1092
1125
  commandSchema("describe", "Print machine-readable command schemas.", false, false, []),
1093
1126
  commandSchema("check", "Check ECP and developer installer reachability.", true, false, []),
1127
+ commandSchema("console", "Capture BrightScript console output from port 8085.", true, true, [argumentField("outputPath", "path", "Console log output path inside the current app root."), optionField("durationMs", "positive-integer", "Capture duration in milliseconds.")]),
1128
+ commandSchema("debug-command", "Run an allowlisted Roku debug command.", true, true, [
1129
+ argumentField("debugCommand", "string", "Allowlisted Roku debug command."),
1130
+ argumentField("args", "string[]", "Debug command arguments.", false, true),
1131
+ optionField("durationMs", "positive-integer", "Maximum read duration in milliseconds."),
1132
+ optionField("idleTimeoutMs", "positive-integer", "Stop reading after this many idle milliseconds.")
1133
+ ]),
1094
1134
  commandSchema("discover", "Discover Roku ECP devices with SSDP.", false, false, [optionField("timeoutMs", "positive-integer", "Discovery timeout in milliseconds.")]),
1095
1135
  commandSchema("device-info", "Read enhanced Roku device metadata.", true, false, []),
1096
1136
  commandSchema("active-app", "Read the foreground app.", true, false, []),
@@ -1187,6 +1227,8 @@ const printHelp = () => {
1187
1227
  usage:
1188
1228
  rokit describe
1189
1229
  rokit check
1230
+ rokit console <output-path> [--duration-ms <ms>]
1231
+ rokit debug-command <command> [args...] [--duration-ms <ms>] [--idle-timeout-ms <ms>]
1190
1232
  rokit discover [--timeout-ms <ms>]
1191
1233
  rokit device-info
1192
1234
  rokit active-app
@@ -1220,8 +1262,8 @@ environment:
1220
1262
  ROKIT_USERNAME=rokudev
1221
1263
  ROKIT_TIMEOUT_MS=10000
1222
1264
 
1223
- compatibility:
1224
- ROKU_DEV_TARGET and ROKU_DEV_PASSWORD are accepted as fallbacks.`);
1265
+ aliases:
1266
+ ROKU_DEV_TARGET and ROKU_DEV_PASSWORD are accepted when ROKIT_* names are unset.`);
1225
1267
  };
1226
1268
  //#endregion
1227
1269
  //#region src/rokit.ts
@@ -0,0 +1,75 @@
1
+ # Roku Debugging
2
+
3
+ `rokit` can wrap Roku debugging surfaces when they expose generic device or
4
+ runtime state. Keep product journeys, selectors, content IDs, account state, and
5
+ assertions in consumer app repos.
6
+
7
+ ## Official Surfaces
8
+
9
+ Roku documents the developer debug console as a telnet connection to port
10
+ `8085`. It emits app runtime output, compilation errors, crash line numbers,
11
+ stack traces, and variable state when an app fails. Open it before sideloading or
12
+ reproducing a startup crash so the first failure is captured.
13
+
14
+ Roku also documents debug-server utilities on port `8080`. Useful generic
15
+ commands include:
16
+
17
+ | Command | Port | Use |
18
+ | ------------------- | ------ | ------------------------------------------- |
19
+ | `chanperf` | `8080` | Current app memory and CPU snapshot |
20
+ | `free` | `8080` | Device memory snapshot |
21
+ | `sgnodes roots` | `8080` | Top-level SceneGraph nodes |
22
+ | `sgnodes all` | `8080` | Full SceneGraph node listing |
23
+ | `sgnodes <node_ID>` | `8080` | SceneGraph nodes with a matching `id` field |
24
+ | `loaded_textures` | `8080` | Loaded texture diagnostics |
25
+ | `r2d2_bitmaps` | `8080` | Bitmap diagnostics |
26
+
27
+ The BrightScript debug protocol is a separate binary protocol for interactive
28
+ debugger clients. It can inspect variables, stack traces, breakpoints, and
29
+ stepping state after a launch request enables remote debugging. It is not the
30
+ first `rokit` target because crash capture needs append-only text artifacts more
31
+ than an IDE-style session.
32
+
33
+ Sources:
34
+
35
+ - [Roku debugging](https://developer.roku.com/docs/developer-program/debugging/debugging-channels.md)
36
+ - [BrightScript debug protocol](https://developer.roku.com/docs/developer-program/debugging/socket-based-debugger.md)
37
+ - [Roku developer tools](https://developer.roku.com/docs/developer-program/dev-tools/tools-overview.md)
38
+
39
+ ## `rokit` Shape
40
+
41
+ The implemented first slice covers text telnet capture and allowlisted
42
+ debug-server commands:
43
+
44
+ - `rokit console <output-path> [--duration-ms <ms>]` captures port `8085`
45
+ output to a timestamped local log.
46
+ - `rokit debug-command <command> [args...]` sends one allowlisted command to
47
+ port `8080` or `8085` and prints the response.
48
+
49
+ `debug-command` is described as mutating for agent safety because some
50
+ allowlisted Roku commands can affect profiling/debugger state.
51
+
52
+ Roku's `chanperf -r <seconds>` command writes repeated samples to the
53
+ BrightScript console on `8085`, not the `8080` socket that receives the command.
54
+ `rokit debug-command` intentionally rejects that form until a coordinated
55
+ capture command can open the console, start sampling, stop sampling, and write
56
+ one proof bundle.
57
+
58
+ The natural next command is `rokit crash-watch <app-id> <output-dir>`, which
59
+ would connect to the console, launch an app, capture logs, and write normal
60
+ proof artifacts after the capture window.
61
+
62
+ Use Node's `node:net` module directly instead of shelling out to a local telnet
63
+ binary. This keeps the CLI cross-platform, typed, and easier to test.
64
+
65
+ ## Limits
66
+
67
+ - Live verification requires a developer-enabled Roku with reachable debug
68
+ ports. Unit tests can cover parsing, command validation, output paths, and
69
+ socket behavior with fake TCP servers.
70
+ - The console should be connected before the repro. It cannot recover log lines
71
+ emitted before the socket was open.
72
+ - Another tool, such as an IDE or Roku plugin, may already own the console
73
+ connection.
74
+ - If the device hard-reboots, `rokit` can only preserve bytes received before
75
+ the socket disconnects.
package/docs/READINESS.md CHANGED
@@ -8,12 +8,12 @@ Status: current as of 2026-05-17
8
8
 
9
9
  Overall: B+
10
10
 
11
- | Dimension | Status | Evidence | Gap |
12
- | ---------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
13
- | bootable | pass | `pnpm smoke` builds the CLI and checks `--version` plus `--help` | no server boot surface, by design |
14
- | testable | pass | `pnpm verify` runs TypeScript, bundle, unit tests, and npm pack dry run | live Roku checks require local hardware |
15
- | observable | pass | CLI commands print active app, device info, raw ECP state, SceneGraph XML, snapshots, proof bundles, screenshots, compact assertion failures, and JSON output by default in non-TTY runs | no CI hardware lane |
16
- | verifiable | pass | CI runs `pnpm verify`; `pnpm live:smoke` proves a configured Roku responds; `pnpm live:probe` packages, installs, launches, drives, asserts, screenshots, and proofs a generic channel | no CI hardware lane |
11
+ | Dimension | Status | Evidence | Gap |
12
+ | ---------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
13
+ | bootable | pass | `pnpm smoke` builds the CLI and checks `--version` plus `--help` | no server boot surface, by design |
14
+ | testable | pass | `pnpm verify` runs TypeScript, bundle, unit tests, and npm pack dry run | live Roku checks require local hardware |
15
+ | observable | pass | CLI commands print active app, device info, raw ECP state, SceneGraph XML, BrightScript console logs, debug-server output, snapshots, proof bundles, screenshots, compact assertion failures, and JSON output by default in non-TTY runs | no CI hardware lane |
16
+ | verifiable | pass | CI runs `pnpm verify`; `pnpm live:smoke` proves a configured Roku responds; `pnpm live:probe` packages, installs, launches, drives, asserts, screenshots, and proofs a generic channel | no CI hardware lane |
17
17
 
18
18
  ## Layers
19
19
 
@@ -22,7 +22,7 @@ Overall: B+
22
22
  - Interact: `rokit press`, `rokit launch`, `rokit query`, `rokit sgnodes`
23
23
  - E2e: `pnpm live:probe` for a configured developer-enabled Roku
24
24
  - Enforce: GitHub Actions `verify`, optional `.git-hooks/pre-push`
25
- - Observe: ECP responses, SceneGraph XML, `rokit snapshot`, `rokit proof`, screenshots, concise command output
25
+ - Observe: ECP responses, SceneGraph XML, `rokit console`, `rokit debug-command`, `rokit snapshot`, `rokit proof`, screenshots, concise command output
26
26
  - Isolate: `.rokit/` and `.env` stay local per worktree
27
27
 
28
28
  ## Agent-Facing CLI Contract
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: rokit-harness
3
- description: Use rokit as a generic Roku harness adapter for packaging, installing, launching, key input, ECP/SceneGraph/media-player observation, readiness waits, screenshots, and proof bundles.
3
+ description: Use rokit as a generic Roku harness adapter for packaging, installing, launching, key input, ECP/SceneGraph/media-player observation, readiness waits, debug console capture, screenshots, and proof bundles.
4
4
  ---
5
5
 
6
6
  # rokit Harness
@@ -25,9 +25,12 @@ repo.
25
25
  6. For live proof, use `rokit snapshot` for a quick state read,
26
26
  `rokit proof <output-dir>` for review artifacts, or `pnpm live:probe` in the
27
27
  rokit repo for the full generic package/install/launch/input/proof probe.
28
- 7. Use `rokit wait-ready <app-id>` after launch when the app can race ECP or
28
+ 7. For crash/debug proof, start `rokit console <output-path>` before
29
+ reproducing the problem so startup errors and crash traces are captured.
30
+ Use `rokit debug-command <command>` only for generic Roku debug commands.
31
+ 8. Use `rokit wait-ready <app-id>` after launch when the app can race ECP or
29
32
  SceneGraph readiness.
30
- 8. Use `rokit press --until-node ...` for bounded navigation loops instead of
33
+ 9. Use `rokit press --until-node ...` for bounded navigation loops instead of
31
34
  arbitrary sleeps.
32
35
 
33
36
  ## Boundaries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@putdotio/rokit",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "A tiny CLI companion for Roku device harness work.",
5
5
  "keywords": [
6
6
  "cli",