Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 43 additions & 30 deletions scripts/postinstall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
*/
import { execSync } from "child_process";
import os from "os";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";

function hasAdb() {
try {
Expand All @@ -28,36 +30,47 @@ function hasAdb() {
}
}

if (hasAdb()) {
console.log("ADB is installed and available in your PATH.");
process.exit(0);
}
/**
* Print whether adb is available, with install instructions when it isn't.
* @returns {void}
*/
export function main() {
if (hasAdb()) {
console.log("ADB is installed and available in your PATH.");
return;
}

console.log("\nADB is not installed or not in your PATH.");
console.log("Follow these instructions to install ADB:");
console.log("\nADB is not installed or not in your PATH.");
console.log("Follow these instructions to install ADB:");

const platform = os.platform();
if (platform === "win32") {
console.log("\nWindows:");
console.log("1. Download the SDK Platform Tools for Windows: https://developer.android.com/studio/releases/platform-tools");
console.log("2. Extract the ZIP file.");
console.log("3. Add the extracted folder to your system PATH.");
console.log('4. Test by running "adb version" in Command Prompt.');
} else if (platform === "darwin") {
console.log("\nmacOS:");
console.log("1. Download the SDK Platform Tools for Mac: https://developer.android.com/studio/releases/platform-tools");
console.log("2. Extract the ZIP file.");
console.log("3. Move the extracted folder to /usr/local/bin or add it to your PATH.");
console.log('4. Test by running "adb version" in Terminal.');
} else if (platform === "linux") {
console.log("\nLinux:");
console.log("1. Download the SDK Platform Tools for Linux: https://developer.android.com/studio/releases/platform-tools");
console.log("2. Extract the ZIP file.");
console.log("3. Move the extracted folder to /usr/local/bin or add it to your PATH.");
console.log("4. Or install via your package manager:");
console.log(" - Ubuntu/Debian: sudo apt-get install android-tools-adb");
console.log(" - Fedora/Red Hat: sudo dnf install android-tools");
console.log(" - CentOS/yum: sudo yum install android-tools");
console.log('5. Test by running "adb version" in your shell.');
}
console.log("\nYou must accept the ADB authorization prompt on your Android TV device the first time you connect.");
}

const platform = os.platform();
if (platform === "win32") {
console.log("\nWindows:");
console.log("1. Download the SDK Platform Tools for Windows: https://developer.android.com/studio/releases/platform-tools");
console.log("2. Extract the ZIP file.");
console.log("3. Add the extracted folder to your system PATH.");
console.log('4. Test by running "adb version" in Command Prompt.');
} else if (platform === "darwin") {
console.log("\nmacOS:");
console.log("1. Download the SDK Platform Tools for Mac: https://developer.android.com/studio/releases/platform-tools");
console.log("2. Extract the ZIP file.");
console.log("3. Move the extracted folder to /usr/local/bin or add it to your PATH.");
console.log('4. Test by running "adb version" in Terminal.');
} else if (platform === "linux") {
console.log("\nLinux:");
console.log("1. Download the SDK Platform Tools for Linux: https://developer.android.com/studio/releases/platform-tools");
console.log("2. Extract the ZIP file.");
console.log("3. Move the extracted folder to /usr/local/bin or add it to your PATH.");
console.log("4. Or install via your package manager:");
console.log(" - Ubuntu/Debian: sudo apt-get install android-tools-adb");
console.log(" - Fedora/Red Hat: sudo dnf install android-tools");
console.log(" - CentOS/yum: sudo yum install android-tools");
console.log('5. Test by running "adb version" in your shell.');
// Run only when executed directly (npm postinstall), not when imported.
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
console.log("\nYou must accept the ADB authorization prompt on your Android TV device the first time you connect.");
65 changes: 54 additions & 11 deletions scripts/setup-device.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,24 @@
*
*/

import readline from "readline";
import AndroidTVSetup from "../src/lib/adb/setup.mjs";
import readline from "node:readline";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import createRemote from "../src/lib/android-tv-remote.mjs";

/**
* Interactive CLI for setting up a new Android TV device.
* Prompts for IP and port, then runs setup.
* Prompts for IP and port, then applies the keep-awake settings, wakes the
* device and returns it to the home screen, using the v2 remote API.
*
* Usage: npm run setup-device
*/

/**
* Ask a question on the terminal.
* @param {string} question - Prompt text.
* @param {string} [defaultValue] - Value used when the answer is empty.
* @returns {Promise<string>} The answer, or the default.
*/
async function prompt(question, defaultValue) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
Expand All @@ -30,25 +42,56 @@ async function prompt(question, defaultValue) {
});
}

async function main() {
/**
* Connect to a device and prepare it for remote control: apply the keep-awake
* settings, make sure it is awake, return to the home screen, then disconnect.
* @param {object} options - Setup options.
* @param {string} options.ip - Device IP address.
* @param {number} [options.port=5555] - ADB port.
* @param {boolean} [options.quiet=false] - Suppress progress logs.
* @param {Function} [options.create=createRemote] - Remote factory (injectable for tests).
* @param {Function} [options.log=console.log] - Log sink for progress and error events.
* @returns {Promise<void>} Resolves once setup is complete; rejects on failure.
*/
export async function setupDevice({ ip, port = 5555, quiet = false, create = createRemote, log = console.log }) {
const remote = await create({ ip, port, quiet, autoConnect: false, maintainConnection: false });
remote.on("log", (data) => log(`[${data.level}] ${data.message}`));
remote.on("error", (data) => log(`[error] ${data.message}`));

const result = await remote.connect();
if (!remote.isConnected) {
throw result instanceof Error ? result : new Error(`Could not connect to ${ip}:${port}`);
}
try {
await remote.setSettings();
await remote.ensureAwake();
await remote.press.home();
} finally {
await remote.disconnect();
}
}

/**
* CLI entry: prompt for the device address and run the setup.
* @returns {Promise<void>}
*/
export async function main() {
const ip = await prompt("Enter device IP address");
if (!ip) {
console.error("IP address is required.");
process.exit(1);
}
const port = parseInt(await prompt("Enter device port", "5555"), 10) || 5555;
const quiet = false;
const setup = new AndroidTVSetup({ ip, port, quiet });
try {
await setup.connect();
await setup.setSettings();
await setup.ensureAwake();
await setup.disconnect();
await setupDevice({ ip, port });
console.log("\nSetup complete!");
} catch (err) {
console.error("Error:", err.message || err);
process.exit(1);
}
}

main();
// Run only when executed directly (`npm run setup-device`), not when imported.
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}
62 changes: 38 additions & 24 deletions scripts/wake-tv.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,36 +18,50 @@
* Usage: node wake-tv.mjs <ip_address>
*/

import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import createRemote from "../src/lib/android-tv-remote.mjs";

// Get IP address from command line arguments
const ip = process.argv[2];
/**
* CLI entry: wake the device whose IP is given on the command line.
* @param {string[]} [argv=process.argv.slice(2)] - CLI arguments.
* @returns {Promise<void>}
*/
export async function main(argv = process.argv.slice(2)) {
// Get IP address from command line arguments
const ip = argv[0];

if (!ip) {
console.error("❌ Usage: node wake-tv.mjs <ip_address>");
console.error(" Example: node wake-tv.mjs 10.6.0.133");
process.exit(1);
}
if (!ip) {
console.error("❌ Usage: node wake-tv.mjs <ip_address>");
console.error(" Example: node wake-tv.mjs 10.6.0.133");
process.exit(1);
}

console.log(`πŸ”Œ Waking up TV at ${ip}...`);
console.log(`πŸ”Œ Waking up TV at ${ip}...`);

try {
const remote = await createRemote({
ip: ip,
autoConnect: true,
quiet: false
});
try {
const remote = await createRemote({
ip: ip,
autoConnect: true,
quiet: false
});

console.log("βœ… Connected to device");
console.log("βœ… Connected to device");

// Wake up the device
await remote.ensureAwake();
console.log("πŸŽ‰ TV should now be awake and ready!");
// Wake up the device
await remote.ensureAwake();
console.log("πŸŽ‰ TV should now be awake and ready!");

// Disconnect
await remote.disconnect();
console.log("βœ… Disconnected from device");
} catch (error) {
console.error("❌ Failed to wake up TV:", error.message);
process.exit(1);
}
}

// Disconnect
await remote.disconnect();
console.log("βœ… Disconnected from device");
} catch (error) {
console.error("❌ Failed to wake up TV:", error.message);
process.exit(1);
// Run only when executed directly, not when imported.
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}
124 changes: 124 additions & 0 deletions tests/scripts.test.vitest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
*
* @Project: @cldmv/node-android-tv-remote
* @Filename: /tests/scripts.test.vitest.mjs
* @Date: 2026-10-03T17:30:24-07:00 (1791073824)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-10-03T17:34:47-07:00 (1791074087)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*
*/

/**
* Smoke test for everything under scripts/: every script must import cleanly,
* so a dangling import (like scripts/setup-device.mjs pointing at the removed
* src/lib/adb/setup.mjs, #42) fails CI instead of shipping. Scripts only run
* their CLI when executed directly, so importing them has no side effects.
*
* Also covers the setup-device flow against a fake remote β€” no ADB or network.
*/

import { describe, test, expect, vi } from "vitest";
import { readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const scriptsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts");
const scriptFiles = readdirSync(scriptsDir).filter((f) => /\.(mjs|js|cjs)$/.test(f));

describe("scripts/ smoke test", () => {
test("finds the scripts", () => {
expect(scriptFiles.length).toBeGreaterThan(0);
});

test.each(scriptFiles)("%s imports without errors and exports main()", async (file) => {
const mod = await import(pathToFileURL(path.join(scriptsDir, file)).href);
expect(typeof mod.main).toBe("function");
});
});

/**
* Build a fake remote that records the calls made on it.
* @param {object} [opts]
* @param {boolean} [opts.connects=true] - Whether connect() succeeds.
* @param {Error} [opts.settingsError] - Error thrown by setSettings().
*/
function fakeRemote({ connects = true, settingsError } = {}) {
const calls = [];
const listeners = {};
let connected = false;
const remote = {
on(event, fn) {
listeners[event] = fn;
return remote;
},
listeners,
calls,
get isConnected() {
return connected;
},
async connect() {
calls.push("connect");
if (!connects) return new Error("connection refused");
connected = true;
},
async setSettings() {
calls.push("setSettings");
if (settingsError) throw settingsError;
},
async ensureAwake() {
calls.push("ensureAwake");
return true;
},
press: {
async home() {
calls.push("home");
}
},
async disconnect() {
calls.push("disconnect");
connected = false;
}
};
return remote;
}

describe("setup-device", () => {
test("connects, applies settings, wakes, goes home and disconnects", async () => {
const { setupDevice } = await import("../scripts/setup-device.mjs");
const remote = fakeRemote();
const create = vi.fn(async () => remote);
const log = vi.fn();

await setupDevice({ ip: "10.0.0.5", port: 5556, create, log });

expect(create).toHaveBeenCalledWith(expect.objectContaining({ ip: "10.0.0.5", port: 5556, autoConnect: false }));
expect(remote.calls).toEqual(["connect", "setSettings", "ensureAwake", "home", "disconnect"]);

// log and error events are forwarded to the log sink
remote.listeners.log({ level: "info", message: "hello" });
remote.listeners.error({ message: "boom" });
expect(log).toHaveBeenCalledWith("[info] hello");
expect(log).toHaveBeenCalledWith("[error] boom");
});

test("rejects with the connection error when the device can't be reached", async () => {
const { setupDevice } = await import("../scripts/setup-device.mjs");
const remote = fakeRemote({ connects: false });

await expect(setupDevice({ ip: "10.0.0.5", create: async () => remote, log: () => {} })).rejects.toThrow("connection refused");
expect(remote.calls).toEqual(["connect"]);
});

test("still disconnects when a setup step fails", async () => {
const { setupDevice } = await import("../scripts/setup-device.mjs");
const remote = fakeRemote({ settingsError: new Error("settings failed") });

await expect(setupDevice({ ip: "10.0.0.5", create: async () => remote, log: () => {} })).rejects.toThrow("settings failed");
expect(remote.calls).toEqual(["connect", "setSettings", "disconnect"]);
});
});
Loading