@putdotio/rokit 1.7.0 → 2.0.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.
@@ -1,5 +1,6 @@
1
1
  import * as rokuDeploy from "roku-deploy";
2
- import { basename, dirname, extname, resolve } from "node:path";
2
+ import { createSocket } from "node:dgram";
3
+ import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
3
4
  //#region src/xml.ts
4
5
  const readXmlTag = (xml, tag) => {
5
6
  return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1]?.trim();
@@ -70,7 +71,7 @@ const isNamedNodeVisible = (xml, nodeName) => {
70
71
  const attributes = readNamedNodeAttributes(xml, nodeName);
71
72
  return attributes !== void 0 && !attributes.includes("visible=\"false\"");
72
73
  };
73
- const isCompleteSceneGraph = (xml) => !xml.includes("<All_Nodes>") || xml.includes("<App ");
74
+ const isCompleteSceneGraph = (xml) => !xml.includes("<All_Nodes>") || xml.includes("</sgnodes>") && (readSceneGraphStatus(xml).status !== void 0 || readSceneGraphFailure(xml) !== void 0);
74
75
  const escapeXmlAttribute = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
75
76
  const sceneGraphContainsText = (xml, text) => xml.includes(escapeXmlAttribute(text));
76
77
  const assertSceneGraphNumberNear = (actual, expected, label, tolerance = 1) => {
@@ -161,6 +162,50 @@ const getDeviceInfo = async (context) => await rokuDeploy.getDeviceInfo({
161
162
  timeout: context.timeoutMs
162
163
  });
163
164
  const queryActiveApp = async (context) => readActiveApp(await fetchText(context, "/query/active-app"));
165
+ const discoverRokuDevices = async (timeoutMs = 3e3) => await new Promise((resolveDevices, reject) => {
166
+ const socket = createSocket("udp4");
167
+ const devices = /* @__PURE__ */ new Map();
168
+ const request = [
169
+ "M-SEARCH * HTTP/1.1",
170
+ "HOST: 239.255.255.250:1900",
171
+ "MAN: \"ssdp:discover\"",
172
+ "MX: 1",
173
+ "ST: roku:ecp",
174
+ "",
175
+ ""
176
+ ].join("\r\n");
177
+ const finish = () => {
178
+ socket.close();
179
+ resolveDevices([...devices.values()]);
180
+ };
181
+ const timer = setTimeout(finish, timeoutMs);
182
+ socket.on("message", (message) => {
183
+ const headers = readSsdpHeaders(message.toString("utf8"));
184
+ const location = headers.get("location");
185
+ if (!location) return;
186
+ devices.set(location, {
187
+ location,
188
+ server: headers.get("server"),
189
+ target: readLocationTarget(location),
190
+ usn: headers.get("usn")
191
+ });
192
+ });
193
+ socket.on("error", (error) => {
194
+ clearTimeout(timer);
195
+ socket.close();
196
+ reject(error);
197
+ });
198
+ socket.bind(() => {
199
+ socket.setBroadcast(true);
200
+ socket.send(Buffer.from(request), 1900, "239.255.255.250", (error) => {
201
+ if (error) {
202
+ clearTimeout(timer);
203
+ socket.close();
204
+ reject(error);
205
+ }
206
+ });
207
+ });
208
+ });
164
209
  const waitForActiveApp = async (context, appId, timeoutMs = 1e4) => {
165
210
  const start = Date.now();
166
211
  let lastApp;
@@ -191,7 +236,10 @@ const pressKey = async (context, key) => {
191
236
  validateRemoteKey(key);
192
237
  await postOk(context, ecpUrl(context, `/keypress/${encodeURIComponent(key)}`));
193
238
  };
194
- const queryEcp = async (context, path) => await fetchText(context, path.startsWith("/") ? path : `/${path}`);
239
+ const queryEcp = async (context, path) => {
240
+ const safePath = validateEcpPath(path);
241
+ return await fetchText(context, safePath.startsWith("/") ? safePath : `/${safePath}`);
242
+ };
195
243
  const queryMediaPlayerXml = async (context) => await queryEcp(context, "/query/media-player");
196
244
  const queryMediaPlayer = async (context) => readMediaPlayerInfo(await queryMediaPlayerXml(context));
197
245
  const queryMediaPlayerXmlSafe = async (context) => {
@@ -271,7 +319,7 @@ const querySceneGraph = async (context, options = {}) => {
271
319
  }
272
320
  if (attempt < attempts - 1) await sleep(retryDelayMs);
273
321
  }
274
- if (lastXml !== "") return lastXml;
322
+ if (lastXml !== "") throw new Error("SceneGraph query returned incomplete XML");
275
323
  throw new Error(`SceneGraph query failed: ${formatErrorMessage(lastError)}`);
276
324
  };
277
325
  const assertSceneGraphNode = async (context, nodeName, expectation) => {
@@ -332,6 +380,39 @@ const takeScreenshot = async (context, outputPath) => {
332
380
  password: context.password
333
381
  });
334
382
  };
383
+ const packageChannel = async (outputPath, rootDir = process.cwd()) => {
384
+ const options = packageOptions(outputPath, rootDir);
385
+ await rokuDeploy.createPackage(options);
386
+ return { path: rokuDeploy.getOutputZipFilePath(options) };
387
+ };
388
+ const resolvePackageOutputPath = (outputPath, rootDir = process.cwd()) => {
389
+ const options = packageOptions(outputPath, rootDir);
390
+ return rokuDeploy.getOutputZipFilePath(options);
391
+ };
392
+ const packageOptions = (outputPath, rootDir = process.cwd()) => {
393
+ const resolvedRoot = resolve(rootDir);
394
+ const resolvedOutput = isAbsolute(outputPath) ? resolve(outputPath) : resolve(resolvedRoot, outputPath);
395
+ return {
396
+ outDir: dirname(resolvedOutput),
397
+ outFile: basename(resolvedOutput),
398
+ rootDir: resolvedRoot
399
+ };
400
+ };
401
+ const validateEcpPath = (path) => {
402
+ rejectUnsafeInput(path, "ECP path");
403
+ if (path.includes("\\")) throw new Error("ECP path must not include backslashes");
404
+ if (path.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(path)) throw new Error("ECP path must be device-relative");
405
+ if (path.includes("?") || path.includes("#")) throw new Error("ECP path must not include query strings or fragments");
406
+ if (/(^|[/\\])\.\.($|[/\\])/.test(path)) throw new Error("ECP path must not include path traversal");
407
+ if (/%(?:2e|2f|5c)/i.test(path)) throw new Error("ECP path must not include percent-encoded path segments");
408
+ return path;
409
+ };
410
+ const rejectUnsafeInput = (value, label) => {
411
+ if ([...value].some((character) => {
412
+ const code = character.charCodeAt(0);
413
+ return code < 32 || code === 127;
414
+ })) throw new Error(`${label} contains control characters`);
415
+ };
335
416
  const validateRemoteKey = (key) => {
336
417
  if (key.startsWith("Lit_")) return;
337
418
  if (!remoteKeySet.has(key)) throw new Error(`unsupported remote key: ${key}`);
@@ -386,5 +467,21 @@ const sleep = (ms) => new Promise((resolve) => {
386
467
  setTimeout(resolve, ms);
387
468
  });
388
469
  const formatErrorMessage = (error) => error instanceof Error ? error.message : String(error);
470
+ const readSsdpHeaders = (text) => {
471
+ const headers = /* @__PURE__ */ new Map();
472
+ for (const line of text.split(/\r?\n/)) {
473
+ const separator = line.indexOf(":");
474
+ if (separator <= 0) continue;
475
+ headers.set(line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim());
476
+ }
477
+ return headers;
478
+ };
479
+ const readLocationTarget = (location) => {
480
+ try {
481
+ return new URL(location).hostname;
482
+ } catch {
483
+ return;
484
+ }
485
+ };
389
486
  //#endregion
390
- export { assertNamedNodeTranslation as A, readNamedNodeTranslation as B, waitForMediaPlayerState as C, assertNamedNodeSize as D, assertNamedNode as E, parseSceneGraphNumberList as F, readXmlAttribute as G, readSceneGraphStatus as H, readNamedNodeAttribute as I, readXmlTag as K, readNamedNodeAttributes as L, escapeXmlAttribute as M, isCompleteSceneGraph as N, assertNamedNodeState as O, isNamedNodeVisible as P, readNamedNodeBounds as R, waitForActiveApp as S, waitForSceneGraphNode as T, sceneGraphContainsText as U, readSceneGraphFailure as V, readActiveApp as W, readMediaPlayerInfo as _, installPackage as a, takeScreenshot as b, pressKey as c, queryMediaPlayer as d, queryMediaPlayerSafe as f, readMediaPlayerContainer as g, querySceneGraph as h, getDeviceInfo as i, assertSceneGraphNumberNear as j, assertNamedNodeText as k, queryActiveApp as l, queryMediaPlayerXmlSafe as m, assertSceneGraphNode as n, isActiveMediaPlayerState as o, queryMediaPlayerXml as p, checkDevice as r, launchApp as s, assertMediaPlayerContainer as t, queryEcp as u, readMediaPlayerPositionMs as v, waitForSceneGraphAssertion as w, validateRemoteKey as x, readMediaPlayerState as y, readNamedNodeNumber as z };
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 };
@@ -0,0 +1,60 @@
1
+ # Distribution
2
+
3
+ `rokit` is a public npm package published as `@putdotio/rokit`.
4
+
5
+ ## Local Contract
6
+
7
+ The release path starts with the repo-local verification command:
8
+
9
+ ```bash
10
+ pnpm verify
11
+ ```
12
+
13
+ `verify` runs formatting/lint checks, TypeScript, package bundling, tests, and an npm pack dry run. GitHub Actions calls this same command before release.
14
+
15
+ ## Continuous Release
16
+
17
+ Merges to `main` are considered publishable. The CI workflow runs:
18
+
19
+ 1. `verify` on pull requests and `main` pushes.
20
+ 2. semantic-release on `main` after `verify` passes.
21
+
22
+ semantic-release analyzes conventional commits, publishes to npm, creates GitHub Releases, and writes release metadata when needed.
23
+
24
+ ## Release Credentials
25
+
26
+ The release job uses the `release` GitHub Environment with `deployment: false`.
27
+
28
+ Required protected inputs:
29
+
30
+ - `PUTIO_RELEASE_BOT_CLIENT_ID` as a repository or Environment variable
31
+ - `PUTIO_RELEASE_BOT_PRIVATE_KEY` as an Environment secret
32
+
33
+ The npm package uses Trusted Publishing from GitHub Actions. On npm, configure owner `putdotio`, repository `rokit`, workflow `ci.yml`, and Environment named `release` for the package.
34
+
35
+ During the `@semantic-release/npm` publish step, npm detects the GitHub OIDC identity, mints short-lived publish credentials, and publishes provenance for the release job.
36
+
37
+ Release writes use the `putio-release-bot` installation token. The default `GITHUB_TOKEN` remains read-only, and the release-bot remote is configured only after dependencies are installed.
38
+
39
+ ## Package Contents
40
+
41
+ The npm package includes `dist`, `README.md`, `docs`, `examples`, `AGENTS.md`,
42
+ `CONTRIBUTING.md`, and `SECURITY.md`. The docs and generic live probe are
43
+ included so agents consuming the package can inspect readiness, distribution,
44
+ security, and generic Roku proof mechanics without cloning extra private
45
+ context.
46
+
47
+ ## Release Smoke
48
+
49
+ After a release, confirm the tag and package are visible:
50
+
51
+ ```bash
52
+ gh release list --repo putdotio/rokit --limit 5
53
+ npm view @putdotio/rokit version
54
+ ```
55
+
56
+ Live Roku behavior is not required for npm release. Real-device checks are local/manual because they require a developer-enabled Roku:
57
+
58
+ ```bash
59
+ ROKIT_TARGET=<roku-ip> pnpm live:smoke
60
+ ```
@@ -0,0 +1,80 @@
1
+ # Agent Readiness
2
+
3
+ Status: current as of 2026-05-17
4
+
5
+ `rokit` is a CLI/package repo. There is no long-running app to boot; readiness is based on deterministic package verification plus optional live Roku proof.
6
+
7
+ ## Grade
8
+
9
+ Overall: B+
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 |
17
+
18
+ ## Layers
19
+
20
+ - Boot: `pnpm smoke`, `rokit describe`
21
+ - Smoke: `pnpm smoke`, `rokit check`
22
+ - Interact: `rokit press`, `rokit launch`, `rokit query`, `rokit sgnodes`
23
+ - E2e: `pnpm live:probe` for a configured developer-enabled Roku
24
+ - Enforce: GitHub Actions `verify`, optional `.git-hooks/pre-push`
25
+ - Observe: ECP responses, SceneGraph XML, `rokit snapshot`, `rokit proof`, screenshots, concise command output
26
+ - Isolate: `.rokit/` and `.env` stay local per worktree
27
+
28
+ ## Agent-Facing CLI Contract
29
+
30
+ - The CLI entrypoint runs through Effect's Node runtime, and expected CLI
31
+ failures are schema-backed errors rendered without stack traces.
32
+ - `rokit describe` exposes the machine-readable command surface, parameters,
33
+ JSON payload fields, and global options without a Roku target.
34
+ - Non-TTY command output defaults to JSON. Use `--output text` when a human
35
+ transcript is required.
36
+ - `--input-json` lets agents provide typed command payloads without translating
37
+ everything into flags.
38
+ - `--fields` trims JSON output for context-window control.
39
+ - `--dry-run` validates mutating commands without touching a device or writing
40
+ files.
41
+ - ECP paths and generated output paths are hardened against common agent
42
+ mistakes: query strings, fragments, traversal, backslashes, control
43
+ characters, encoded path segments, and writes outside the current app root.
44
+
45
+ ## Setup For Agents
46
+
47
+ ```bash
48
+ pnpm install
49
+ pnpm verify
50
+ ```
51
+
52
+ `pnpm install` runs the local Effect source setup outside CI only. When `CI` is
53
+ set to any non-empty value, `scripts/prepare-effect.sh` exits without cloning
54
+ `.repos/effect` so install, pack, publish, and CI/CD workspaces stay predictable.
55
+
56
+ Optional local hook:
57
+
58
+ ```bash
59
+ pnpm hooks:install
60
+ ```
61
+
62
+ Optional live smoke:
63
+
64
+ ```bash
65
+ ROKIT_TARGET=<roku-ip> pnpm live:smoke
66
+ ```
67
+
68
+ Full local live probe:
69
+
70
+ ```bash
71
+ cp .env.example .env
72
+ # Fill ROKIT_TARGET and ROKIT_PASSWORD.
73
+ pnpm live:probe
74
+ ```
75
+
76
+ `pnpm live:probe` reads `.env` and `.env.local`, accepts `ROKU_DEV_TARGET` and
77
+ `ROKU_DEV_PASSWORD` as fallbacks, copies `examples/live-probe-channel` into
78
+ `.rokit/live-probe/app`, packages it, installs it into the Roku developer slot,
79
+ launches `dev`, checks generic SceneGraph labels, sends `Info Back`, and writes
80
+ proof artifacts to `.rokit/live-probe/artifacts`.
@@ -0,0 +1,40 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # rokit Harness
7
+
8
+ Use this skill when a repo consumes `@putdotio/rokit` for Roku device harness
9
+ work. Keep `rokit` generic and put app journeys, product selectors, fixture
10
+ names, content IDs, account state, and product assertions in the consumer app
11
+ repo.
12
+
13
+ ## Workflow
14
+
15
+ 1. Run `rokit describe` to inspect the command surface instead of guessing
16
+ flags or `--input-json` payload fields.
17
+ 2. Use `--dry-run` before mutating commands such as `package`, `install`,
18
+ `launch`, `press`, `screenshot`, or `proof`.
19
+ 3. Prefer structured output. In automation, rely on the non-TTY JSON default or
20
+ pass `--json` explicitly.
21
+ 4. Use `--fields` to keep observations small when only a few values are needed.
22
+ 5. For live proof, use `rokit snapshot` for a quick state read,
23
+ `rokit proof <output-dir>` for review artifacts, or `pnpm live:probe` in the
24
+ rokit repo for the full generic package/install/launch/input/proof probe.
25
+ 6. Use `rokit wait-ready <app-id>` after launch when the app can race ECP or
26
+ SceneGraph readiness.
27
+ 7. Use `rokit press --until-node ...` for bounded navigation loops instead of
28
+ arbitrary sleeps.
29
+
30
+ ## Boundaries
31
+
32
+ - Device IPs, developer passwords, screenshots, packages, and proof bundles stay
33
+ local or ignored.
34
+ - `rokit` can assert generic Roku and SceneGraph state. Consumer repos own
35
+ product routes, screen names when product-specific, playback/content
36
+ expectations, and review narratives.
37
+ - ECP query paths must be plain paths. Do not include query strings, fragments,
38
+ traversal, backslashes, or encoded path segments.
39
+ - The local Effect source setup is skipped whenever `CI` is set. Do not make CI,
40
+ install, pack, or publish flows depend on cloning `.repos/effect`.
@@ -0,0 +1,26 @@
1
+ <?xml version="1.0" encoding="utf-8" ?>
2
+ <component name="MainScene" extends="Scene">
3
+ <children>
4
+ <Rectangle
5
+ id="background"
6
+ width="1280"
7
+ height="720"
8
+ color="0x101820FF" />
9
+ <Label
10
+ id="title"
11
+ text="Rokit Live Probe"
12
+ translation="[120, 140]"
13
+ width="1040"
14
+ height="80"
15
+ horizAlign="center"
16
+ vertAlign="center" />
17
+ <Label
18
+ id="status"
19
+ text="Install, launch, input, and proof OK"
20
+ translation="[120, 250]"
21
+ width="1040"
22
+ height="60"
23
+ horizAlign="center"
24
+ vertAlign="center" />
25
+ </children>
26
+ </component>
@@ -0,0 +1,5 @@
1
+ title=Rokit Live Probe
2
+ major_version=1
3
+ minor_version=0
4
+ build_version=1
5
+ ui_resolutions=hd
@@ -0,0 +1,14 @@
1
+ sub Main()
2
+ screen = CreateObject("roSGScreen")
3
+ port = CreateObject("roMessagePort")
4
+ screen.SetMessagePort(port)
5
+ screen.CreateScene("MainScene")
6
+ screen.Show()
7
+
8
+ while true
9
+ msg = Wait(0, port)
10
+ if Type(msg) = "roSGScreenEvent" and msg.IsScreenClosed() then
11
+ return
12
+ end if
13
+ end while
14
+ end sub
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@putdotio/rokit",
3
- "version": "1.7.0",
3
+ "version": "2.0.1",
4
4
  "description": "A tiny CLI companion for Roku device harness work.",
5
5
  "keywords": [
6
6
  "cli",
@@ -22,8 +22,13 @@
22
22
  "rokit": "dist/rokit.mjs"
23
23
  },
24
24
  "files": [
25
+ "AGENTS.md",
26
+ "CONTRIBUTING.md",
25
27
  "dist",
26
- "README.md"
28
+ "docs",
29
+ "examples",
30
+ "README.md",
31
+ "SECURITY.md"
27
32
  ],
28
33
  "type": "module",
29
34
  "sideEffects": false,
@@ -43,23 +48,28 @@
43
48
  "clean": "rm -rf .turbo coverage dist",
44
49
  "hooks:install": "git config core.hooksPath .git-hooks",
45
50
  "live:smoke": "vp pack src/index.ts src/rokit.ts --dts && node dist/rokit.mjs check",
51
+ "live:probe": "scripts/live-probe.sh",
52
+ "prepare": "./scripts/prepare-effect.sh",
46
53
  "prepack": "vp pack src/index.ts src/rokit.ts --dts",
47
54
  "smoke": "vp pack src/index.ts src/rokit.ts --dts && node dist/rokit.mjs --version && node dist/rokit.mjs --help >/dev/null",
48
- "test": "vp test --passWithNoTests",
55
+ "test": "vp pack src/index.ts src/rokit.ts --dts && vitest run --passWithNoTests",
49
56
  "typecheck": "tsc --noEmit",
50
- "verify": "vp check . && tsc --noEmit && vp pack src/index.ts src/rokit.ts --dts && vp test --passWithNoTests && npm pack --dry-run"
57
+ "verify": "vp check . && tsc --noEmit && vp run test && npm pack --dry-run"
51
58
  },
52
59
  "dependencies": {
60
+ "@effect/platform-node": "4.0.0-beta.66",
61
+ "effect": "4.0.0-beta.66",
53
62
  "roku-deploy": "3.17.3"
54
63
  },
55
64
  "devDependencies": {
56
- "@types/node": "^25.7.0",
65
+ "@effect/vitest": "4.0.0-beta.66",
66
+ "@types/node": "^25.8.0",
57
67
  "typescript": "^6.0.2",
58
68
  "vite-plus": "^0.1.20",
59
69
  "vitest": "^4.1.6"
60
70
  },
61
71
  "engines": {
62
- "node": ">=24.14.0 <25"
72
+ "node": ">=24.14.0"
63
73
  },
64
74
  "packageManager": "pnpm@11.0.0"
65
75
  }