@putdotio/rokit 1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/rokit.mjs +341 -0
  4. package/package.json +56 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) put.io
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ <div align="center">
2
+ <p>
3
+ <img src="https://static.put.io/images/putio-boncuk.png" width="72">
4
+ </p>
5
+
6
+ <h1>rokit</h1>
7
+
8
+ <p>A tiny CLI companion for Roku device harness work.</p>
9
+
10
+ <p>
11
+ <a href="https://github.com/putdotio/rokit/actions/workflows/ci.yml?query=branch%3Amain" style="text-decoration:none;"><img src="https://img.shields.io/github/actions/workflow/status/putdotio/rokit/ci.yml?branch=main&style=flat&label=ci&colorA=000000&colorB=000000" alt="CI"></a>
12
+ <a href="https://www.npmjs.com/package/@putdotio/rokit" style="text-decoration:none;"><img src="https://img.shields.io/npm/v/%40putdotio%2Frokit?style=flat&label=npm&logo=npm&colorA=000000&colorB=000000" alt="npm version"></a>
13
+ <a href="https://github.com/putdotio/rokit/blob/main/LICENSE" style="text-decoration:none;"><img src="https://img.shields.io/github/license/putdotio/rokit?style=flat&label=license&colorA=000000&colorB=000000" alt="License"></a>
14
+ </p>
15
+ </div>
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pnpm add -D @putdotio/rokit
21
+ ```
22
+
23
+ `rokit` wraps Roku platform primitives for live-device proof loops. It does not
24
+ own app-specific journeys, content IDs, credentials, or product assertions.
25
+
26
+ ## Usage
27
+
28
+ Create `.rokit/.env` or export environment variables in the app repo:
29
+
30
+ ```bash
31
+ ROKIT_TARGET=<roku-ip>
32
+ ROKIT_PASSWORD=<developer-mode-password>
33
+ ```
34
+
35
+ Then run:
36
+
37
+ ```bash
38
+ pnpm exec rokit check
39
+ pnpm exec rokit launch dev
40
+ pnpm exec rokit press Down Select
41
+ pnpm exec rokit query /query/active-app
42
+ pnpm exec rokit screenshot artifacts/live/player.png
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ ```bash
48
+ rokit check
49
+ rokit device-info
50
+ rokit active-app
51
+ rokit launch <app-id> [--param key=value]
52
+ rokit press <key> [key...]
53
+ rokit query <ecp-path>
54
+ rokit screenshot <output-path>
55
+ rokit install <zip-path>
56
+ rokit --version
57
+ ```
58
+
59
+ - `check` confirms the Roku ECP endpoint responds and the developer installer
60
+ is reachable.
61
+ - `device-info` prints enhanced Roku device metadata as JSON.
62
+ - `active-app` prints the foreground app.
63
+ - `launch` opens an app and waits until it is active. Use repeated `--param`
64
+ values for deeplink parameters.
65
+ - `press` sends Roku remote keys through ECP.
66
+ - `query` prints a raw ECP response such as `/query/sgnodes/all`.
67
+ - `screenshot` saves a developer screenshot. It requires `ROKIT_PASSWORD`.
68
+ - `install` publishes an existing ZIP through `roku-deploy`. It requires
69
+ `ROKIT_PASSWORD`.
70
+
71
+ ## Environment
72
+
73
+ ```bash
74
+ ROKIT_TARGET=<roku-ip>
75
+ ROKIT_PASSWORD=<developer-mode-password>
76
+ ROKIT_USERNAME=rokudev
77
+ ROKIT_TIMEOUT_MS=10000
78
+ ```
79
+
80
+ `ROKU_DEV_TARGET` and `ROKU_DEV_PASSWORD` are accepted as fallbacks for app
81
+ repos that already use Roku dev naming.
82
+
83
+ Keep `.rokit/` local. Device IPs, Developer Mode passwords, signing keys, user
84
+ tokens, and app-specific media identifiers do not belong in git.
85
+
86
+ ## Boundaries
87
+
88
+ `rokit` is the generic Roku harness layer:
89
+
90
+ - device info
91
+ - install/publish
92
+ - launch and deeplink parameters
93
+ - remote keypresses
94
+ - raw ECP queries
95
+ - screenshots
96
+
97
+ App repositories should keep their own scenario commands for product behavior,
98
+ such as opening a specific route, asserting playback state, generating review
99
+ HTML, or checking app-specific UI nodes.
100
+
101
+ ## Docs
102
+
103
+ - [Contributing](./CONTRIBUTING.md)
104
+ - [Security](./SECURITY.md)
105
+
106
+ ## Repo Internals
107
+
108
+ - [Agent guide](./AGENTS.md)
109
+
110
+ ## License
111
+
112
+ [MIT](./LICENSE)
package/dist/rokit.mjs ADDED
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { existsSync, mkdirSync } from "node:fs";
4
+ import { basename, dirname, extname, join, resolve } from "node:path";
5
+ import * as rokuDeploy from "roku-deploy";
6
+ //#region src/xml.ts
7
+ const readXmlTag = (xml, tag) => {
8
+ return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1]?.trim();
9
+ };
10
+ const readXmlAttribute = (attributes, name) => {
11
+ return new RegExp(`${name}="([^"]*)"`).exec(attributes)?.[1];
12
+ };
13
+ const readActiveApp = (xml) => {
14
+ const match = /<app\s+([^>]*)>([^<]*)<\/app>/.exec(xml);
15
+ if (!match) throw new Error("active app response did not include an app node");
16
+ const attributes = match[1] ?? "";
17
+ return {
18
+ id: readXmlAttribute(attributes, "id") ?? "",
19
+ name: match[2]?.trim() ?? "",
20
+ type: readXmlAttribute(attributes, "type") ?? "",
21
+ version: readXmlAttribute(attributes, "version") ?? ""
22
+ };
23
+ };
24
+ //#endregion
25
+ //#region src/roku.ts
26
+ const ecpPort = 8060;
27
+ const remoteKeySet = new Set([
28
+ "Home",
29
+ "Rev",
30
+ "Fwd",
31
+ "Play",
32
+ "Select",
33
+ "Left",
34
+ "Right",
35
+ "Down",
36
+ "Up",
37
+ "Back",
38
+ "InstantReplay",
39
+ "Info",
40
+ "Backspace",
41
+ "Search",
42
+ "Enter",
43
+ "VolumeDown",
44
+ "VolumeMute",
45
+ "VolumeUp",
46
+ "PowerOff",
47
+ "ChannelUp",
48
+ "ChannelDown",
49
+ "InputTuner",
50
+ "InputHDMI1",
51
+ "InputHDMI2",
52
+ "InputHDMI3",
53
+ "InputHDMI4"
54
+ ]);
55
+ const checkDevice = async (context) => {
56
+ const deviceInfo = await fetchText(context, "/query/device-info");
57
+ const installerStatus = await fetchInstallerStatus(context);
58
+ return {
59
+ ecp: `http://${context.target}:${ecpPort}`,
60
+ installerStatus,
61
+ model: readXmlTag(deviceInfo, "model-name") ?? "unknown model",
62
+ name: readXmlTag(deviceInfo, "friendly-device-name") ?? readXmlTag(deviceInfo, "friendlyName") ?? "unknown"
63
+ };
64
+ };
65
+ const getDeviceInfo = async (context) => await rokuDeploy.getDeviceInfo({
66
+ enhance: true,
67
+ host: context.target,
68
+ remotePort: ecpPort,
69
+ timeout: context.timeoutMs
70
+ });
71
+ const queryActiveApp = async (context) => readActiveApp(await fetchText(context, "/query/active-app"));
72
+ const launchApp = async (context, appId, params = /* @__PURE__ */ new Map()) => {
73
+ const url = ecpUrl(context, `/launch/${encodeURIComponent(appId)}`);
74
+ for (const [key, value] of params) url.searchParams.set(key, value);
75
+ await postOk(context, url);
76
+ return await waitForActiveApp(context, appId);
77
+ };
78
+ const pressKey = async (context, key) => {
79
+ validateRemoteKey(key);
80
+ await postOk(context, ecpUrl(context, `/keypress/${encodeURIComponent(key)}`));
81
+ };
82
+ const queryEcp = async (context, path) => await fetchText(context, path.startsWith("/") ? path : `/${path}`);
83
+ const installPackage = async (context, zipPath) => {
84
+ const resolvedZip = resolve(zipPath);
85
+ const extension = extname(resolvedZip);
86
+ return (await rokuDeploy.publish({
87
+ host: context.target,
88
+ outDir: dirname(resolvedZip),
89
+ outFile: basename(resolvedZip, extension),
90
+ password: context.password,
91
+ rootDir: process.cwd(),
92
+ username: context.username
93
+ })).message;
94
+ };
95
+ const takeScreenshot = async (context, outputPath) => {
96
+ const resolvedOutput = resolve(outputPath);
97
+ const extension = extname(resolvedOutput);
98
+ return await rokuDeploy.takeScreenshot({
99
+ host: context.target,
100
+ outDir: dirname(resolvedOutput),
101
+ outFile: basename(resolvedOutput, extension),
102
+ password: context.password
103
+ });
104
+ };
105
+ const validateRemoteKey = (key) => {
106
+ if (key.startsWith("Lit_")) return;
107
+ if (!remoteKeySet.has(key)) throw new Error(`unsupported remote key: ${key}`);
108
+ };
109
+ const waitForActiveApp = async (context, appId, timeoutMs = 1e4) => {
110
+ const start = Date.now();
111
+ let lastApp;
112
+ while (Date.now() - start < timeoutMs) {
113
+ lastApp = await queryActiveApp(context);
114
+ if (lastApp.id === appId) return lastApp;
115
+ await sleep(500);
116
+ }
117
+ const last = lastApp ? `${lastApp.id} ${lastApp.name}` : "unknown";
118
+ throw new Error(`expected active app ${appId}, got ${last}`);
119
+ };
120
+ const fetchInstallerStatus = async (context) => {
121
+ return (await fetch(`http://${context.target}`, { signal: AbortSignal.timeout(context.timeoutMs) })).status;
122
+ };
123
+ const fetchText = async (context, path) => {
124
+ const response = await fetch(ecpUrl(context, path), { signal: AbortSignal.timeout(context.timeoutMs) });
125
+ if (!response.ok) throw new Error(`GET ${path} returned HTTP ${response.status}`);
126
+ return await response.text();
127
+ };
128
+ const postOk = async (context, url) => {
129
+ const response = await fetch(url, {
130
+ method: "POST",
131
+ signal: AbortSignal.timeout(context.timeoutMs)
132
+ });
133
+ if (!response.ok) throw new Error(`POST ${url.pathname} returned HTTP ${response.status}`);
134
+ };
135
+ const ecpUrl = (context, path) => new URL(path, `http://${context.target}:${ecpPort}`);
136
+ const sleep = (ms) => new Promise((resolve) => {
137
+ setTimeout(resolve, ms);
138
+ });
139
+ const envPath = join(join(process.cwd(), ".rokit"), ".env");
140
+ const loadLocalEnv = () => {
141
+ if (existsSync(envPath)) process.loadEnvFile(envPath);
142
+ };
143
+ const loadEnv = () => ({
144
+ password: process.env.ROKIT_PASSWORD ?? process.env.ROKU_DEV_PASSWORD,
145
+ target: process.env.ROKIT_TARGET ?? process.env.ROKU_DEV_TARGET,
146
+ timeoutMs: parseTimeout(process.env.ROKIT_TIMEOUT_MS),
147
+ username: process.env.ROKIT_USERNAME ?? "rokudev"
148
+ });
149
+ const requireTarget = (env) => {
150
+ const target = env.target?.trim();
151
+ if (!target) return fail("ROKIT_TARGET is not set");
152
+ return normalizeTarget(target);
153
+ };
154
+ const requirePassword = (env) => {
155
+ const password = env.password;
156
+ if (!password) return fail("ROKIT_PASSWORD is not set");
157
+ return password;
158
+ };
159
+ const fail = (message) => {
160
+ console.error(message);
161
+ process.exit(1);
162
+ };
163
+ const formatErrorMessage = (error) => {
164
+ if (error instanceof Error) return error.message;
165
+ return String(error);
166
+ };
167
+ const normalizeTarget = (target) => target.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
168
+ const parseTimeout = (value) => {
169
+ if (value === void 0) return 1e4;
170
+ const timeout = Number(value);
171
+ if (!Number.isFinite(timeout) || timeout <= 0) fail(`Invalid ROKIT_TIMEOUT_MS: ${value}`);
172
+ return timeout;
173
+ };
174
+ //#endregion
175
+ //#region src/cli.ts
176
+ const packageJson = createRequire(import.meta.url)("../package.json");
177
+ const main = async (argv = process.argv.slice(2)) => {
178
+ const firstArg = argv[0];
179
+ if (!firstArg || firstArg === "--help" || firstArg === "-h") {
180
+ printHelp();
181
+ return;
182
+ }
183
+ if (firstArg === "--version" || firstArg === "-v") {
184
+ console.log(packageJson.version);
185
+ return;
186
+ }
187
+ loadLocalEnv();
188
+ const env = loadEnv();
189
+ const target = requireTarget(env);
190
+ const context = {
191
+ password: env.password,
192
+ target,
193
+ timeoutMs: env.timeoutMs,
194
+ username: env.username
195
+ };
196
+ const command = parseCommand(argv);
197
+ try {
198
+ await runCommand(context, command);
199
+ } catch (error) {
200
+ fail(formatErrorMessage(error));
201
+ }
202
+ };
203
+ const runCommand = async (context, command) => {
204
+ if (command.name === "check") {
205
+ const summary = await checkDevice(context);
206
+ console.log(`device: ${summary.name} (${summary.model})`);
207
+ console.log(`ecp: ${summary.ecp}`);
208
+ console.log(`developer installer HTTP status: ${summary.installerStatus}`);
209
+ return;
210
+ }
211
+ if (command.name === "device-info") {
212
+ console.log(JSON.stringify(await getDeviceInfo(context), null, 2));
213
+ return;
214
+ }
215
+ if (command.name === "active-app") {
216
+ const app = await queryActiveApp(context);
217
+ console.log(`active app: ${app.id} ${app.name} ${app.version}`.trim());
218
+ return;
219
+ }
220
+ if (command.name === "launch") {
221
+ const app = await launchApp(context, command.args.appId, command.args.params);
222
+ console.log(`launched: ${app.id} ${app.name} ${app.version}`.trim());
223
+ return;
224
+ }
225
+ if (command.name === "press") {
226
+ for (const key of command.keys) {
227
+ await pressKey(context, key);
228
+ console.log(`pressed: ${key}`);
229
+ }
230
+ return;
231
+ }
232
+ if (command.name === "query") {
233
+ console.log(await queryEcp(context, command.path));
234
+ return;
235
+ }
236
+ if (command.name === "screenshot") {
237
+ const password = requirePassword(context);
238
+ mkdirSync(dirname(command.outputPath), { recursive: true });
239
+ console.log(`screenshot: ${await takeScreenshot({
240
+ ...context,
241
+ password
242
+ }, command.outputPath)}`);
243
+ return;
244
+ }
245
+ if (command.name === "install") {
246
+ const password = requirePassword(context);
247
+ console.log(await installPackage({
248
+ ...context,
249
+ password
250
+ }, command.zipPath));
251
+ }
252
+ };
253
+ const parseCommand = (argv) => {
254
+ const [name, ...args] = argv;
255
+ if (name === "check") return { name };
256
+ if (name === "device-info") return { name };
257
+ if (name === "active-app") return { name };
258
+ if (name === "launch") return {
259
+ name,
260
+ args: parseLaunchArgs(args)
261
+ };
262
+ if (name === "press") {
263
+ if (args.length === 0) fail("usage: rokit press <key> [key...]");
264
+ return {
265
+ name,
266
+ keys: args
267
+ };
268
+ }
269
+ if (name === "query") {
270
+ const path = args[0];
271
+ if (!path) fail("usage: rokit query <ecp-path>");
272
+ return {
273
+ name,
274
+ path
275
+ };
276
+ }
277
+ if (name === "screenshot") {
278
+ const outputPath = args[0];
279
+ if (!outputPath) fail("usage: rokit screenshot <output-path>");
280
+ return {
281
+ name,
282
+ outputPath
283
+ };
284
+ }
285
+ if (name === "install") {
286
+ const zipPath = args[0];
287
+ if (!zipPath) fail("usage: rokit install <zip-path>");
288
+ return {
289
+ name,
290
+ zipPath
291
+ };
292
+ }
293
+ return fail(`Unknown command: ${name ?? ""}`);
294
+ };
295
+ const parseLaunchArgs = (args) => {
296
+ const appId = args[0];
297
+ if (!appId) fail("usage: rokit launch <app-id> [--param key=value]");
298
+ const params = /* @__PURE__ */ new Map();
299
+ for (let index = 1; index < args.length; index += 1) {
300
+ const arg = args[index];
301
+ if (arg !== "--param") fail(`Unknown launch option: ${arg ?? ""}`);
302
+ const pair = args[index + 1];
303
+ if (!pair) fail("usage: rokit launch <app-id> [--param key=value]");
304
+ const equalsIndex = pair.indexOf("=");
305
+ if (equalsIndex <= 0) fail(`Invalid launch param: ${pair}`);
306
+ params.set(pair.slice(0, equalsIndex), pair.slice(equalsIndex + 1));
307
+ index += 1;
308
+ }
309
+ return {
310
+ appId,
311
+ params
312
+ };
313
+ };
314
+ const printHelp = () => {
315
+ console.log(`rokit - Roku device harness helper
316
+
317
+ usage:
318
+ rokit check
319
+ rokit device-info
320
+ rokit active-app
321
+ rokit launch <app-id> [--param key=value]
322
+ rokit press <key> [key...]
323
+ rokit query <ecp-path>
324
+ rokit screenshot <output-path>
325
+ rokit install <zip-path>
326
+ rokit --version
327
+
328
+ environment:
329
+ ROKIT_TARGET=<roku-ip>
330
+ ROKIT_PASSWORD=<developer-mode-password>
331
+ ROKIT_USERNAME=rokudev
332
+ ROKIT_TIMEOUT_MS=10000
333
+
334
+ compatibility:
335
+ ROKU_DEV_TARGET and ROKU_DEV_PASSWORD are accepted as fallbacks.`);
336
+ };
337
+ //#endregion
338
+ //#region src/rokit.ts
339
+ await main();
340
+ //#endregion
341
+ export {};
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@putdotio/rokit",
3
+ "version": "1.0.0",
4
+ "description": "A tiny CLI companion for Roku device harness work.",
5
+ "keywords": [
6
+ "cli",
7
+ "harness",
8
+ "roku",
9
+ "tv"
10
+ ],
11
+ "homepage": "https://github.com/putdotio/rokit#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/putdotio/rokit/issues"
14
+ },
15
+ "license": "MIT",
16
+ "author": "put.io <devs@put.io>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/putdotio/rokit.git"
20
+ },
21
+ "bin": {
22
+ "rokit": "dist/rokit.mjs"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "vp pack src/rokit.ts",
35
+ "check": "vp check .",
36
+ "clean": "rm -rf .turbo coverage dist",
37
+ "prepack": "vp pack src/rokit.ts",
38
+ "smoke": "vp pack src/rokit.ts && node dist/rokit.mjs --version && node dist/rokit.mjs --help >/dev/null",
39
+ "test": "vp test --passWithNoTests",
40
+ "typecheck": "tsc --noEmit",
41
+ "verify": "vp check . && tsc --noEmit && vp pack src/rokit.ts && vp test --passWithNoTests && npm pack --dry-run"
42
+ },
43
+ "dependencies": {
44
+ "roku-deploy": "3.17.3"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^25.7.0",
48
+ "typescript": "^6.0.2",
49
+ "vite-plus": "^0.1.20",
50
+ "vitest": "^4.1.6"
51
+ },
52
+ "engines": {
53
+ "node": ">=24.14.0 <25"
54
+ },
55
+ "packageManager": "pnpm@11.0.0"
56
+ }