Skip to content

Commit

Permalink
First commit: v0.1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
adheus committed Dec 15, 2020
1 parent 4cf23c7 commit 0758e07
Show file tree
Hide file tree
Showing 7 changed files with 442 additions and 0 deletions.
112 changes: 112 additions & 0 deletions src/commands/roku.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env node

import { createSigningCredentials,executeDeviceRekey, signPackage } from '../roku/roku-api';
import { logSuccess, logInfo, logError } from '../utils/pretty-log';

const argv = require('yargs/yargs')(process.argv.slice(2))
.command(
'sign',
'sign a roku package',
{
name: {
alias: 'n',
describe: 'application package name (without .pkg)',
default: 'app',
type: 'string'
},
path: {
alias: 'p',
demandOption: true,
describe: 'path to roku project',
normalize: true,
type: 'string'
},
signing: {
alias: 's',
demandOption: true,
describe: 'path to signing properties folder',
normalize: true,
type: 'string'
},
output: {
alias: 'o',
describe: 'path to where the package should be saved',
default: './',
normalize: true,
type: 'string'
}
}, async (argv: { name:string, path:string, signing:string, output:string, device?:string, password?:string, username:string }) => {
try {
const outputPath = await signPackage(argv.path, argv.signing, argv.output, argv.name, argv)
logSuccess(`Package signed and stored at: ${outputPath}`)
} catch (error) {
logError(error.message)
}
})
.command(
'rekey',
'rekey roku device',
{
signing: {
alias: 's',
demandOption: true,
describe: 'path to signing properties folder',
normalize: true,
type: 'string'
}
}, async (argv: { signing:string, device?:string, password?:string, username:string }) => {
try {
await executeDeviceRekey(argv.signing, argv)
logSuccess('Device rekey applied.')
} catch(error) {
console.log(error);
logError(error.message)
}
})
.command(
'create-signing-credentials',
'creates new signing properties for an app (dev_id, password, package)',
{
name: {
alias: 'n',
describe: 'output package file name',
default: 'app',
type: 'string'
},
output: {
alias: 'o',
describe: 'path to where signing properties should be saved',
default: './',
normalize: true,
type: 'string'
}
}, async (argv: { name:string, output:string, device?:string, password?:string, username:string }) => {
try {
const outputPath = await createSigningCredentials(argv.name, argv.output, argv)
logSuccess(`Signing credentials generated and stored at: ${outputPath}`)
} catch(error) {
logError(error.message)
}
})
.options({
'device': {
alias: 'd',
describe: 'network address of the roku device. also could be set set through the environment variable: ROKU_DEVICE_ADDRESS',
type: 'string'
},
'password': {
alias: 'w',
describe: 'password of the roku device. also could be set through the environment variable: ROKU_DEVICE_PASSWORD',
type: 'string'
},
'username': {
alias: 'u',
describe: 'password of the roku device. also could be set through the environment variable: ROKU_DEVICE_PASSWORD',
default: 'rokudev',
type: 'string'
}
})
.demandCommand(1)
.strict()
.help()
.argv
148 changes: 148 additions & 0 deletions src/roku/roku-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import path from 'path';
import fs from 'fs';
import { rekeyDevice, deployAndSignPackage, deleteInstalledChannel } from 'roku-deploy';

import { generateKey } from '../roku/roku-genkey';

type DeviceProperties = { device?:string, password?:string , username?:string}

const CREDENTIALS_FILENAME = 'credentials.json'
const PACKAGE_EXTENSION = '.pkg'

const RESOURCE_FOLDER_PATH = '../resources'
const SIGNING_PROJECT_PATH = 'signing-project'

const DEFAULT_OUTPUT_DIRECTORY = 'out'

export async function signPackage(projectPath: string, signingPath: string, outputPath: string, packageName:string, deviceProperties?:DeviceProperties) {
const finalDeviceProperties = getDeviceProperties(deviceProperties?.device, deviceProperties?.password, deviceProperties?.username)
// Clear current installed channel [AR]
deleteInstalledChannel({...finalDeviceProperties})

// Rekey device to application signing properties [AR]
const signingProperties = parseSigningProperties(signingPath)

await rekeyDevice({
...finalDeviceProperties,
signingPassword: signingProperties.credentials.password,
rekeySignedPackage: path.resolve(signingProperties.packageFilePath),
devId: signingProperties.credentials.dev_id
})

// Generate new package [AR]
const generatedPackagePath = await deployAndSignPackage({
...finalDeviceProperties,
project: `${projectPath}/bsconfig.json`,
rootDir: projectPath,
signingPassword: signingProperties.credentials.password,
devId: signingProperties.credentials.dev_id
});

// Create output path directory if it doesn't exist [AR]
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true })
}
// Copy generated package to output path [AR]
const packageOutputPath = path.join(outputPath, `${packageName}${PACKAGE_EXTENSION}`);
fs.copyFileSync(generatedPackagePath, packageOutputPath)


// Clear ./out directory
cleanOutDirectory()

return packageOutputPath
}


export async function createSigningCredentials(packageName: string, outputPath: string, deviceProperties?:DeviceProperties) {

const finalDeviceProperties = getDeviceProperties(deviceProperties?.device, deviceProperties?.password, deviceProperties?.username)

// Clear current installed channel [AR]
deleteInstalledChannel({...finalDeviceProperties})

const signingProperties = await generateKey(finalDeviceProperties.host)

const signingProjectPath = getResourceAt(SIGNING_PROJECT_PATH)

const packagePath = await deployAndSignPackage({
...finalDeviceProperties,
rootDir: signingProjectPath,
signingPassword: signingProperties.password,
devId: signingProperties.dev_id
});

const outputSigningPath = path.join(outputPath)
const outputPackagePath = path.join(outputSigningPath, `${packageName}${PACKAGE_EXTENSION}`)
const outputCredentialsPath = path.join(outputSigningPath, CREDENTIALS_FILENAME)

if (!fs.existsSync(outputSigningPath)) {
fs.mkdirSync(outputSigningPath, { recursive: true })
}
fs.copyFileSync(packagePath, outputPackagePath)
fs.writeFileSync(outputCredentialsPath, JSON.stringify(signingProperties))

return outputPath
}

export async function executeDeviceRekey(signingPath: string, deviceProperties?: DeviceProperties) {

const signingProperties = parseSigningProperties(signingPath)

await rekeyDevice({
...getDeviceProperties(deviceProperties?.device, deviceProperties?.password, deviceProperties?.username),
signingPassword: signingProperties.credentials.password,
rekeySignedPackage: path.resolve(signingProperties.packageFilePath),
devId: signingProperties.credentials.dev_id
})
}

function parseSigningProperties(signingPropertiesPath: string) {
var files = fs.readdirSync(signingPropertiesPath);
const packageFilename = files.find((filePath) => path.extname(filePath) == PACKAGE_EXTENSION);
if (!packageFilename) {
throw Error(`Could not find package at path: ${signingPropertiesPath}`)
}
const packageFilePath = path.join(signingPropertiesPath, packageFilename)

const credentialsFilePath = path.join(signingPropertiesPath, CREDENTIALS_FILENAME)
if (!path.resolve(credentialsFilePath)) {
throw Error(`Could not find credentials.json at path: ${credentialsFilePath}`)
}

const credentialsData = fs.readFileSync(credentialsFilePath, 'utf-8')
try {
const credentials = JSON.parse(credentialsData)
if (credentials.dev_id && credentials.password) {
return { credentials, packageFilePath }
} else {

throw Error(`Missing required keys on credentials.json: dev_id, password`)
}
} catch (error) {
throw Error(`Could not parse credentials file: ${credentialsFilePath}`)
}
}

function getResourceAt(resourcePath: string) {
const packagePath = '${__dirname}'
const resourcesFolderPath = path.join(packagePath, RESOURCE_FOLDER_PATH)
return path.resolve(path.join(resourcesFolderPath, resourcePath))
}

function getDeviceProperties(device: string | undefined = undefined, password: string | undefined = undefined, username: string | undefined = undefined) {
const env = process.env
const finalHost = device ? device : env.ROKU_DEVICE_ADDRESS
const finalUsername = username ? username : env.ROKU_DEVICE_USERNAME
const finalPassword = password ? password : env.ROKU_DEVICE_PASSWORD

if (finalHost && finalUsername && finalPassword) {
return { host:finalHost, username:finalUsername, password:finalPassword }
} else {
throw Error(`The following device properties should be set: device, password`)
}
}

function cleanOutDirectory() {
fs.rmSync(path.resolve(DEFAULT_OUTPUT_DIRECTORY), { recursive: true })
}
42 changes: 42 additions & 0 deletions src/roku/roku-genkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Telnet from 'telnet-client';


export async function generateKey(deviceAddress: string) {
let connection = new Telnet();
let params = {
host: deviceAddress,
port: 8080,
shellPrompt: '>',
timeout: 5000,
execTimeout: 5000
}

try {
await connection.connect(params);
const response = await connection.exec('genkey');
await connection.destroy();

const password = extractVariable("Password", response);
const dev_id = extractVariable("DevID", response);

if (password && dev_id) {
return { dev_id, password }
} else {
throw Error(`Could not generate key: Failed to retrieve DevID/password from Roku device[${deviceAddress}].`)
}

} catch (error) {
throw Error(`Could not generate key: Failed to connect to Roku device[${deviceAddress}].`)
}
}

function extractVariable(variableName: string, response: string): string | undefined {
const variableRegex = new RegExp(`${variableName}: ([^\r\n]*)`);
const matches = variableRegex.exec(response);
if (matches) {
const value = matches[1];
return value;
}

return undefined;
}
31 changes: 31 additions & 0 deletions src/utils/pretty-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import chalk from 'chalk';

const TOOL_PREFIX = '[roku-cli]'

type LogStyle = { prefix:string, color:chalk.Chalk }

const WARNING:LogStyle = { prefix: '[WARNING]', color: chalk.yellow };
const INFO:LogStyle = { prefix: '[INFO]', color: chalk.blue };
const SUCCESS:LogStyle = { prefix: '[SUCCESS]', color: chalk.green };
const ERROR:LogStyle = { prefix: '[ERROR]', color: chalk.red };

export function logWarning(message:string) {
prettyLog(WARNING, message)
}

export function logInfo(message:string) {
prettyLog(INFO, message)
}

export function logSuccess(message?:string) {
prettyLog(SUCCESS, message)
}

export function logError(message:string) {
prettyLog(ERROR, message)
}


function prettyLog(style: LogStyle, message?:string) {
console.log(style.color(`${TOOL_PREFIX}${style.prefix}${ message ? `: ${message}`: ''}`))
}
33 changes: 33 additions & 0 deletions tests/create-signing-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import fs from 'fs';
import path from 'path';

import { createSigningCredentials } from '../src/roku/roku-api';

const testDeviceProperties = {
device: '192.168.0.6',
username:'rokudev',
password: '4551'
}

describe('create signing credentials tests', () => {
test('create signing credentials with no device properties should fail', async () => {
try {
await createSigningCredentials('test_app', './out/tests/signing')
fail('create credentials should have failed')
} catch (error) {
expect(error.message).toEqual('The following device properties should be set: device, password')
}
});

test('create signing credentials should succeed', async () => {
try {
const appName = 'test_app'
const credentialsPath = await createSigningCredentials(appName, './out/tests/signing', testDeviceProperties)
expect(fs.existsSync(credentialsPath)).toEqual(true)
expect(fs.existsSync(path.join(credentialsPath, `${appName}.pkg`))) .toEqual(true)
expect(fs.existsSync(path.join(credentialsPath, 'credentials.json'))) .toEqual(true)
} catch(error) {
fail(error.message)
}
}, 30000);
});
Loading

0 comments on commit 0758e07

Please sign in to comment.