@putdotio/rokit 1.6.0 → 2.0.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 +90 -0
- package/CONTRIBUTING.md +52 -0
- package/README.md +83 -105
- package/SECURITY.md +38 -0
- package/dist/index.d.mts +20 -1
- package/dist/index.mjs +2 -2
- package/dist/rokit.mjs +798 -66
- package/dist/{roku-CXHAzYR-.mjs → roku-BxnS6Axs.mjs} +132 -8
- package/docs/DISTRIBUTION.md +60 -0
- package/docs/READINESS.md +80 -0
- package/docs/skills/rokit-harness/SKILL.md +40 -0
- package/examples/live-probe-channel/components/MainScene.xml +26 -0
- package/examples/live-probe-channel/manifest +5 -0
- package/examples/live-probe-channel/source/main.brs +14 -0
- package/package.json +15 -5
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as rokuDeploy from "roku-deploy";
|
|
2
|
-
import {
|
|
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,6 +71,9 @@ const isNamedNodeVisible = (xml, nodeName) => {
|
|
|
70
71
|
const attributes = readNamedNodeAttributes(xml, nodeName);
|
|
71
72
|
return attributes !== void 0 && !attributes.includes("visible=\"false\"");
|
|
72
73
|
};
|
|
74
|
+
const isCompleteSceneGraph = (xml) => !xml.includes("<All_Nodes>") || xml.includes("</sgnodes>") && (readSceneGraphStatus(xml).status !== void 0 || readSceneGraphFailure(xml) !== void 0);
|
|
75
|
+
const escapeXmlAttribute = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
76
|
+
const sceneGraphContainsText = (xml, text) => xml.includes(escapeXmlAttribute(text));
|
|
73
77
|
const assertSceneGraphNumberNear = (actual, expected, label, tolerance = 1) => {
|
|
74
78
|
if (actual === void 0 || Math.abs(actual - expected) > tolerance) throw new Error(`expected ${label} ${expected}, got ${actual ?? "missing"}`);
|
|
75
79
|
};
|
|
@@ -158,6 +162,50 @@ const getDeviceInfo = async (context) => await rokuDeploy.getDeviceInfo({
|
|
|
158
162
|
timeout: context.timeoutMs
|
|
159
163
|
});
|
|
160
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
|
+
});
|
|
161
209
|
const waitForActiveApp = async (context, appId, timeoutMs = 1e4) => {
|
|
162
210
|
const start = Date.now();
|
|
163
211
|
let lastApp;
|
|
@@ -188,9 +236,23 @@ const pressKey = async (context, key) => {
|
|
|
188
236
|
validateRemoteKey(key);
|
|
189
237
|
await postOk(context, ecpUrl(context, `/keypress/${encodeURIComponent(key)}`));
|
|
190
238
|
};
|
|
191
|
-
const queryEcp = async (context, path) =>
|
|
239
|
+
const queryEcp = async (context, path) => {
|
|
240
|
+
const safePath = validateEcpPath(path);
|
|
241
|
+
return await fetchText(context, safePath.startsWith("/") ? safePath : `/${safePath}`);
|
|
242
|
+
};
|
|
192
243
|
const queryMediaPlayerXml = async (context) => await queryEcp(context, "/query/media-player");
|
|
193
244
|
const queryMediaPlayer = async (context) => readMediaPlayerInfo(await queryMediaPlayerXml(context));
|
|
245
|
+
const queryMediaPlayerXmlSafe = async (context) => {
|
|
246
|
+
try {
|
|
247
|
+
return await queryMediaPlayerXml(context);
|
|
248
|
+
} catch {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const queryMediaPlayerSafe = async (context) => {
|
|
253
|
+
const xml = await queryMediaPlayerXmlSafe(context);
|
|
254
|
+
return xml === void 0 ? void 0 : readMediaPlayerInfo(xml);
|
|
255
|
+
};
|
|
194
256
|
const readMediaPlayerInfo = (xml) => {
|
|
195
257
|
const playerAttributes = /<player(?:\s+([^>]*))?>/.exec(xml)?.[1] ?? "";
|
|
196
258
|
const formatAttributes = /<format(?:\s+([^>]*))?\/>/.exec(xml)?.[1] ?? "";
|
|
@@ -216,7 +278,12 @@ const readMediaPlayerInfo = (xml) => {
|
|
|
216
278
|
const readMediaPlayerState = (xml) => readMediaPlayerInfo(xml).state;
|
|
217
279
|
const readMediaPlayerPositionMs = (xml) => readMediaPlayerInfo(xml).positionMs;
|
|
218
280
|
const readMediaPlayerContainer = (xml) => readMediaPlayerInfo(xml).container;
|
|
219
|
-
const isActiveMediaPlayerState = (state) => state === "buffer" || state === "pause" || state === "play";
|
|
281
|
+
const isActiveMediaPlayerState = (state) => state === "buffer" || state === "buffering" || state === "pause" || state === "play";
|
|
282
|
+
const assertMediaPlayerContainer = async (context, expectedContainer) => {
|
|
283
|
+
const mediaPlayer = await queryMediaPlayer(context);
|
|
284
|
+
if (mediaPlayer.container !== expectedContainer) throw new Error(`expected media-player container ${expectedContainer}, got ${mediaPlayer.container ?? "unknown"}`);
|
|
285
|
+
return mediaPlayer;
|
|
286
|
+
};
|
|
220
287
|
const waitForMediaPlayerState = async (context, expectedState, timeoutMs = 1e4) => {
|
|
221
288
|
const start = Date.now();
|
|
222
289
|
let lastState;
|
|
@@ -237,14 +304,22 @@ const waitForMediaPlayerState = async (context, expectedState, timeoutMs = 1e4)
|
|
|
237
304
|
};
|
|
238
305
|
const querySceneGraph = async (context, options = {}) => {
|
|
239
306
|
const attempts = options.attempts ?? 1;
|
|
307
|
+
const requireComplete = options.requireComplete ?? false;
|
|
240
308
|
const retryDelayMs = options.retryDelayMs ?? 500;
|
|
309
|
+
let lastXml = "";
|
|
241
310
|
let lastError;
|
|
242
|
-
for (let attempt = 0; attempt < attempts; attempt += 1)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
311
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
312
|
+
try {
|
|
313
|
+
const xml = await queryEcp(context, "/query/sgnodes/all");
|
|
314
|
+
if (!requireComplete || isCompleteSceneGraph(xml)) return xml;
|
|
315
|
+
lastXml = xml;
|
|
316
|
+
} catch (error) {
|
|
317
|
+
lastError = error;
|
|
318
|
+
lastXml = "";
|
|
319
|
+
}
|
|
246
320
|
if (attempt < attempts - 1) await sleep(retryDelayMs);
|
|
247
321
|
}
|
|
322
|
+
if (lastXml !== "") throw new Error("SceneGraph query returned incomplete XML");
|
|
248
323
|
throw new Error(`SceneGraph query failed: ${formatErrorMessage(lastError)}`);
|
|
249
324
|
};
|
|
250
325
|
const assertSceneGraphNode = async (context, nodeName, expectation) => {
|
|
@@ -305,6 +380,39 @@ const takeScreenshot = async (context, outputPath) => {
|
|
|
305
380
|
password: context.password
|
|
306
381
|
});
|
|
307
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
|
+
};
|
|
308
416
|
const validateRemoteKey = (key) => {
|
|
309
417
|
if (key.startsWith("Lit_")) return;
|
|
310
418
|
if (!remoteKeySet.has(key)) throw new Error(`unsupported remote key: ${key}`);
|
|
@@ -359,5 +467,21 @@ const sleep = (ms) => new Promise((resolve) => {
|
|
|
359
467
|
setTimeout(resolve, ms);
|
|
360
468
|
});
|
|
361
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
|
+
};
|
|
362
486
|
//#endregion
|
|
363
|
-
export {
|
|
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,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": "
|
|
3
|
+
"version": "2.0.0",
|
|
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
|
-
"
|
|
28
|
+
"docs",
|
|
29
|
+
"examples",
|
|
30
|
+
"README.md",
|
|
31
|
+
"SECURITY.md"
|
|
27
32
|
],
|
|
28
33
|
"type": "module",
|
|
29
34
|
"sideEffects": false,
|
|
@@ -43,17 +48,22 @@
|
|
|
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
|
|
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
|
|
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
|
-
"@
|
|
65
|
+
"@effect/vitest": "4.0.0-beta.66",
|
|
66
|
+
"@types/node": "^24.12.4",
|
|
57
67
|
"typescript": "^6.0.2",
|
|
58
68
|
"vite-plus": "^0.1.20",
|
|
59
69
|
"vitest": "^4.1.6"
|