diff --git a/README.md b/README.md index 8501c14..436e2d4 100644 --- a/README.md +++ b/README.md @@ -53,16 +53,16 @@ sample rokudeploy.json The new release has a few breaking changes that is worth going over in order to prepare developers for what they will need to change when they choose to upgrade. ### JavaScript functions don't load config files from disk -In v3, files like `roku-deploy.json` and `bsconfig.json` would be loaded anytime a rokuDeploy function was called through the NodeJS api. This functionality has been removed in v4 so that developers have more control over when the config files are loaded. If your script needs to load the config file values, you can simply call `util.getOptionsFromJson` before calling the desired rokuDeploy function. Here's an example: +In v3, files like `roku-deploy.json` and `bsconfig.json` would be loaded anytime a rokuDeploy function was called through the NodeJS api. This functionality has been removed in v4 so that developers have more control over when the config files are loaded. If your script needs to load the config file values, you can simply call `util.getOptionsFromJson` before calling the desired rokuDeploy function. This will default to load from `rokudeploy.json`. Here's an example: ```javascript const config = { //get the default options ...rokuDeploy.getOptions(), - //override with any values found in the `rokudeploy.json` file + //override with any values found in the `rokudeploy.json` file. You can specify current working directory here. ...util.getOptionsFromJson({ cwd: process.cwd() }) }; -await rokuDeploy.sideload(options); +await rokuDeploy.sideload(config); ``` ### Removed support for bsconfig.json @@ -72,11 +72,11 @@ We've removed support for loading `bsconfig.json` files. This was introduced in const config = { //get the default options ...rokuDeploy.getOptions(), - //override with any values found in + //override with any values found in config file ...util.getOptionsFromJson({ configPath: './bsconfig.json' }) }; //call some rokuDeploy function -await rokuDeploy.sideload(options); +await rokuDeploy.sideload(config); ``` ### Changed, added, or moved some functions in the main Node API @@ -104,55 +104,82 @@ Lastly, the default files array has changed. node modules and static analysis fi ## CLI Usage -### Deploy a zip package -Deploy a .zip package of your project to a roku device +### Sideload a project to your Roku device +Sideload a .zip package or directory to a roku device: ```shell -npx roku-deploy deploy --host 'ip.of.roku' --password 'password of device' --rootDir '.' --outDir './out' -``` - +# Sideload a zip file +npx roku-deploy sideload --host 'ip.of.roku' --password 'password' --zip './path/to/your/app.zip' -### Create a signed package of your project -```shell -npx roku-deploy deploy package --host 'ip.of.roku' --password 'password' --signingPassword 'signing password' +# Sideload from a directory (will be zipped first automatically) +npx roku-deploy sideload --host 'ip.of.roku' --password 'password' --rootDir './path/to/your/project' ``` -### Stage the root directory +### Create a signed package from an existing dev channel ```shell -npx roku-deploy stage --stagingDir './path/to/staging/dir' --rootDir './path/to/root/dir' +npx roku-deploy package --host 'ip.of.roku' --password 'password' --signingPassword 'signing password' --out './out/my-app.pkg' ``` -### Zip the contents of a given directory +### Stage files to a directory +Copy your project files to a staging directory: ```shell -npx roku-deploy zip --stagingDir './path/to/root/dir' --outDir './path/to/out/dir' +npx roku-deploy stage --rootDir './path/to/root/dir' --out './path/to/staging/dir' ``` -### Press the Home key +### Zip a directory +Create a zip file from a directory: ```shell -npx roku-deploy keyPress --key 'Home' --host 'ip.of.roku' --remotePort 1234 --timeout 5000 +npx roku-deploy zip --dir './path/to/directory' --out './path/to/output.zip' ``` -### Sideload a build +### Remote control commands +Send key presses to your Roku: ```shell -npx roku-deploy sideload --host 'ip.of.roku' --password 'password' --outDir './path/to/out/dir' +# Press a key +npx roku-deploy keyPress --key 'Home' --host 'ip.of.roku' + +# Hold a key down +npx roku-deploy keyDown --key 'Up' --host 'ip.of.roku' + +# Release a key +npx roku-deploy keyUp --key 'Up' --host 'ip.of.roku' + +# Send text to the device +npx roku-deploy sendText --text 'Hello World' --host 'ip.of.roku' + +# Interactive remote control mode +npx roku-deploy remote-control --host 'ip.of.roku' ``` ### Convert to SquashFS +Convert your dev channel to SquashFS format: ```shell npx roku-deploy squash --host 'ip.of.roku' --password 'password' ``` -### Create a signed package +### Device management ```shell -npx roku-deploy sign --host 'ip.of.roku' --password 'password' +# Take a screenshot +npx roku-deploy screenshot --host 'ip.of.roku' --password 'password' --out './screenshot.jpg' + +# Rekey a device with a signed package +npx roku-deploy rekey --host 'ip.of.roku' --password 'password' --pkg './path/to/signed.pkg' --signingPassword 'signing password' + +# Delete the dev channel +npx roku-deploy deleteDevChannel --host 'ip.of.roku' --password 'password' + +# Get device information +npx roku-deploy getDeviceInfo --host 'ip.of.roku' + +# Get device ID +npx roku-deploy getDevId --host 'ip.of.roku' ``` -You can view the full list of commands by running: +You can view the full list of commands and their options by running: ```shell npx roku-deploy --help ``` - ## JavaScript Usage ### Copying the files to staging @@ -242,6 +269,36 @@ rokuDeploy.createSignedPackage({ }) ``` +### Send text to device +```typescript +rokuDeploy.sendText({ + text: 'Hello World', + host: 'ip-of-roku' + //...other options if necessary +}) +``` + +### Take a screenshot +```typescript +rokuDeploy.captureScreenshot({ + host: 'ip-of-roku', + password: 'password', + screenshotDir: './screenshots/', + screenshotFile: 'screenshot.jpg' + //...other options if necessary +}) +``` + +### Rekey a device +```typescript +rokuDeploy.rekeyDevice({ + host: 'ip-of-roku', + password: 'password', + rekeySignedPackage: './path/to/signed.pkg' + //...other options if necessary +}) +``` + Can't find what you need? We offer a variety of functions available in the [RokuDeploy.ts file](https://github.com/rokucommunity/roku-deploy/blob/v4/src/RokuDeploy.ts). Here are all of the public functions: - `stage()` - `zip()` @@ -256,10 +313,11 @@ Can't find what you need? We offer a variety of functions available in the [Roku - `createSignedPackage()` - `deleteDevChannel()` - `captureScreenshot()` -- `getOptions()` -- `checkRequiredOptions()` +- `convertToSquashfs()` - `getDeviceInfo()` - `getDevId()` +- `getOptions()` +- `checkRequiredOptions()` ### Running roku-deploy as an npm script diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index a88a647..2936207 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -1393,7 +1393,6 @@ describe('index', () => { password: 'password', rekeySignedPackage: options.rekeySignedPackage, signingPassword: options.signingPassword, - rootDir: options.rootDir, devId: options.devId }); } catch (e) { @@ -1408,17 +1407,16 @@ describe('index', () => { `; mockDoPostRequest(body); try { - fsExtra.writeFileSync(s`${tempDir}/notReal.pkg`, ''); + fsExtra.writeFileSync(s`notReal.pkg`, ''); await rokuDeploy.rekeyDevice({ host: '1.2.3.4', password: 'password', - rekeySignedPackage: s`../notReal.pkg`, + rekeySignedPackage: s`notReal.pkg`, signingPassword: options.signingPassword, - rootDir: options.rootDir, devId: options.devId }); } finally { - fsExtra.removeSync(s`${tempDir}/notReal.pkg`); + fsExtra.removeSync(s`notReal.pkg`); } }); @@ -1432,7 +1430,6 @@ describe('index', () => { password: 'password', rekeySignedPackage: s`${tempDir}/testSignedPackage.pkg`, signingPassword: options.signingPassword, - rootDir: options.rootDir, devId: options.devId }); }); @@ -1447,7 +1444,6 @@ describe('index', () => { password: 'password', rekeySignedPackage: options.rekeySignedPackage, signingPassword: options.signingPassword, - rootDir: options.rootDir, devId: options.devId }); }); @@ -1462,7 +1458,6 @@ describe('index', () => { password: 'password', rekeySignedPackage: options.rekeySignedPackage, signingPassword: options.signingPassword, - rootDir: options.rootDir, devId: undefined }); }); @@ -1475,7 +1470,6 @@ describe('index', () => { password: 'password', rekeySignedPackage: options.rekeySignedPackage, signingPassword: options.signingPassword, - rootDir: options.rootDir, devId: options.devId }); } catch (e) { @@ -1516,7 +1510,6 @@ describe('index', () => { password: 'password', rekeySignedPackage: options.rekeySignedPackage, signingPassword: options.signingPassword, - rootDir: options.rootDir, devId: '45fdc2019903ac333ff624b0b2cddd2c733c3e74' }); } catch (e) { @@ -1530,7 +1523,10 @@ describe('index', () => { describe('createSignedPackage', () => { let onHandler: any; beforeEach(() => { - fsExtra.outputFileSync(`${stagingDir}/manifest`, ``); + fsExtra.outputFileSync(`${tempDir}/manifest`, ` + title=RokuDeployTestChannel + major_version=1 + minor_version=0`); sinon.stub(fsExtra, 'ensureDir').callsFake(((pth: string, callback: (err: Error) => void) => { //do nothing, assume the dir gets created }) as any); @@ -1567,7 +1563,7 @@ describe('index', () => { host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, - stagingDir: stagingDir + manifestPath: s`${tempDir}/manifest` }); } catch (e) { expect(e).to.equal(error); @@ -1583,7 +1579,7 @@ describe('index', () => { host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, - stagingDir: stagingDir + manifestPath: s`${tempDir}/manifest` }); } catch (e) { expect(e).to.be.instanceof(errors.UnparsableDeviceResponseError); @@ -1604,7 +1600,7 @@ describe('index', () => { host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, - stagingDir: stagingDir + manifestPath: s`${tempDir}/manifest` }), 'Invalid Password.' ); @@ -1622,8 +1618,8 @@ describe('index', () => { host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, - stagingDir: stagingDir, - outDir: outDir + outDir: outDir, + manifestPath: s`${tempDir}/manifest` }); expect(pkgPath).to.equal(s`${outDir}/roku-deploy.pkg`); expect(stub.getCall(0).args[0].url).to.equal('http://1.2.3.4:80/pkgs//P6953175d5df120c0069c53de12515b9a.pkg'); @@ -1636,7 +1632,7 @@ describe('index', () => { host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, - stagingDir: stagingDir + manifestPath: s`${tempDir}/manifest` }); expect(pkgPath).to.equal('pkgs/sdcard0/Pae6cec1eab06a45ca1a7f5b69edd3a20.pkg'); }); @@ -1648,7 +1644,7 @@ describe('index', () => { host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, - stagingDir: stagingDir + manifestPath: s`${tempDir}/manifest` }), 'Unknown error signing package' ); @@ -1665,13 +1661,53 @@ describe('index', () => { host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, - stagingDir: stagingDir, - devId: '123' + devId: '123', + manifestPath: s`${tempDir}/manifest` }), `Package signing cancelled: provided devId '123' does not match on-device devId '789'` ); }); + it('should return error if neither manifestPath nor appTitle and appVersion are provided', async () => { + await expectThrowsAsync( + rokuDeploy.createSignedPackage({ + host: '1.2.3.4', + password: 'password', + signingPassword: options.signingPassword, + devId: '123' + }), + `Either appTitle and appVersion or manifestPath must be provided` + ); + }); + + it('should return error if major or minor version is missing from manifest', async () => { + fsExtra.outputFileSync(`${tempDir}/manifest`, `title=AwesomeApp`); + await expectThrowsAsync( + rokuDeploy.createSignedPackage({ + host: '1.2.3.4', + password: 'password', + signingPassword: options.signingPassword, + devId: '123', + manifestPath: s`${tempDir}/manifest` + }), + `Either major or minor version is missing from the manifest` + ); + }); + + it('should return error if value for appTitle is missing from manifest', async () => { + fsExtra.outputFileSync(`${tempDir}/manifest`, `major_version=1\nminor_version=0`); + await expectThrowsAsync( + rokuDeploy.createSignedPackage({ + host: '1.2.3.4', + password: 'password', + signingPassword: options.signingPassword, + devId: '123', + manifestPath: s`${tempDir}/manifest` + }), + `Value for appTitle is missing from the manifest` + ); + }); + it('returns a pkg file path on success', async () => { //the write stream should return null, which causes a specific branch to be executed createWriteStreamStub.callsFake(() => { @@ -1698,7 +1734,7 @@ describe('index', () => { host: '1.2.3.4', password: 'password', signingPassword: options.signingPassword, - stagingDir: stagingDir + manifestPath: s`${tempDir}/manifest` }); } catch (e) { error = e as any; @@ -1723,7 +1759,7 @@ describe('index', () => { host: '1.2.3.4', password: 'aaaa', signingPassword: options.signingPassword, - stagingDir: stagingDir + manifestPath: s`${tempDir}/manifest` }), 'Some error' ); diff --git a/src/RokuDeploy.ts b/src/RokuDeploy.ts index a942737..11d179b 100644 --- a/src/RokuDeploy.ts +++ b/src/RokuDeploy.ts @@ -463,9 +463,28 @@ export class RokuDeploy { logger.info('Creating signed package'); this.checkRequiredOptions(options, ['host', 'password', 'signingPassword']); options = this.getOptions(options) as any; - let manifestPath = path.join(options.stagingDir, 'manifest'); - let parsedManifest = await this.parseManifest(manifestPath); - let appName = parsedManifest.title + '/' + parsedManifest.major_version + '.' + parsedManifest.minor_version; + + // Process options for app title and app version + if (options.appTitle || options.appVersion) { + if (!options.appTitle || !options.appVersion) { + throw new Error('Either appTitle and appVersion is missing; both must be provided, or a manifestPath can be provided instead.'); + } + } else if (options.manifestPath) { + let manifestPath = path.resolve(options.cwd, options.manifestPath); + let parsedManifest = await this.parseManifest(manifestPath); + if (parsedManifest.major_version === undefined || parsedManifest.minor_version === undefined) { + throw new Error('Either major or minor version is missing from the manifest'); + } + options.appVersion = parsedManifest.major_version + '.' + parsedManifest.minor_version; + options.appTitle = parsedManifest.title; + if (!options.appTitle) { + throw new Error('Value for appTitle is missing from the manifest'); + } + } else { + throw new Error('Either appTitle and appVersion or manifestPath must be provided'); + } + + let appName = options.appTitle + '/' + options.appVersion; //prevent devId mismatch (if devId is specified) if (options.devId) { @@ -1129,7 +1148,9 @@ export interface CreateSignedPackageOptions { host: string; password: string; signingPassword: string; - stagingDir?: string; + appTitle?: string; + appVersion?: string; + manifestPath?: string; outDir?: string; /** * If specified, signing will fail if the device's devId is different than this value diff --git a/src/cli.spec.ts b/src/cli.spec.ts index c266068..d5536b2 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -12,7 +12,6 @@ import { DeleteDevChannelCommand } from './commands/DeleteDevChannelCommand'; import { CaptureScreenshotCommand } from './commands/CaptureScreenshotCommand'; import { GetDeviceInfoCommand } from './commands/GetDeviceInfoCommand'; import { GetDevIdCommand } from './commands/GetDevIdCommand'; -import { ExecCommand } from './commands/ExecCommand'; const sinon = createSandbox(); @@ -37,11 +36,6 @@ describe('cli', () => { sinon.restore(); }); - it('Successfully bundles an app', () => { - execSync(`node ${cwd}/dist/cli.js bundle --rootDir ${rootDir} --outDir ${outDir}`); - expectPathExists(`${outDir}/roku-deploy.zip`); - }); - it('Successfully runs stage', () => { //make the files fsExtra.outputFileSync(`${rootDir}/source/main.brs`, ''); @@ -59,10 +53,13 @@ describe('cli', () => { expectPathExists(`${stagingDir}/source/main.brs`); }); - it('Publish passes proper options', async () => { - const stub = sinon.stub(rokuDeploy, 'sideload').callsFake(async () => { + it('SideloadCommand passes proper options when zip is provided', async () => { + sinon.stub(rokuDeploy, 'closeChannel').callsFake(async () => { + return Promise.resolve(); + }); + const sideloadStub = sinon.stub(rokuDeploy, 'sideload').callsFake(async () => { return Promise.resolve({ - message: 'Publish successful', + message: 'Successful sideload', results: {} }); }); @@ -71,17 +68,98 @@ describe('cli', () => { await command.run({ host: '1.2.3.4', password: '5536', - outDir: outDir, - outFile: 'rokudeploy-outfile' + zip: 'test.zip' }); expect( - stub.getCall(0).args[0] + sideloadStub.getCall(0).args[0] + ).to.eql({ + host: '1.2.3.4', + password: '5536', + outDir: cwd, + outFile: 'test.zip', + zip: 'test.zip', + retainDeploymentArchive: true + }); + }); + + it('SideloadCommand passes proper options when rootDir is provided', async () => { + sinon.stub(rokuDeploy, 'closeChannel').callsFake(async () => { + return Promise.resolve(); + }); + sinon.stub(rokuDeploy, 'zip').callsFake(async () => { + return Promise.resolve(); + }); + const sideloadStub = sinon.stub(rokuDeploy, 'sideload').callsFake(async () => { + return Promise.resolve({ + message: 'Successful sideload', + results: {} + }); + }); + + const command = new SideloadCommand(); + await command.run({ + host: '1.2.3.4', + password: '5536', + rootDir: rootDir + }); + + expect( + sideloadStub.getCall(0).args[0] ).to.eql({ host: '1.2.3.4', password: '5536', - outDir: outDir, - outFile: 'rokudeploy-outfile' + rootDir: rootDir, + retainDeploymentArchive: false + }); + }); + + it('SideloadCommand throws error when neither zip nor rootDir is provided', async () => { + const command = new SideloadCommand(); + + try { + await command.run({ + host: '1.2.3.4', + password: '5536', + noclose: true + }); + expect.fail('Expected an error to be thrown'); + } catch (error) { + expect((error as Error).message).to.equal('Either zip or rootDir must be provided for sideload command'); + } + }); + + it('SideloadCommand calls the proper methods when noclose is provided', async () => { + const closeChannelStub = sinon.stub(rokuDeploy, 'closeChannel').callsFake(async () => { + return Promise.resolve(); + }); + const sideloadStub = sinon.stub(rokuDeploy, 'sideload').callsFake(async () => { + return Promise.resolve({ + message: 'Successful sideload', + results: {} + }); + }); + + const command = new SideloadCommand(); + await command.run({ + host: '1.2.3.4', + password: '5536', + zip: 'test.zip', + noclose: true + }); + + expect(closeChannelStub.callCount).to.equal(0); + + expect( + sideloadStub.getCall(0).args[0] + ).to.eql({ + host: '1.2.3.4', + password: '5536', + noclose: true, + outDir: cwd, + outFile: 'test.zip', + retainDeploymentArchive: true, + zip: 'test.zip' }); }); @@ -263,107 +341,3 @@ describe('cli', () => { expectPathExists(`${outDir}/roku-deploy.zip`); }); }); - -describe('ExecCommand', () => { - beforeEach(() => { - fsExtra.emptyDirSync(tempDir); - //most tests depend on a manifest file existing, so write an empty one - fsExtra.outputFileSync(`${rootDir}/manifest`, ''); - sinon.restore(); - }); - afterEach(() => { - fsExtra.removeSync(tempDir); - sinon.restore(); - }); - function mockDoPostRequest(body = '', statusCode = 200) { - return sinon.stub(rokuDeploy as any, 'doPostRequest').callsFake((params) => { - let results = { response: { statusCode: statusCode }, body: body }; - rokuDeploy['checkRequest'](results); - return Promise.resolve(results); - }); - } - - it('does the whole migration', async () => { - const mock = mockDoPostRequest(); - - const options = { - host: '1.2.3.4', - password: 'abcd', - rootDir: rootDir, - stagingDir: stagingDir, - outDir: outDir - }; - await new ExecCommand('stage|zip|close|sideload', options).run(); - - expect(mock.getCall(2).args[0].url).to.equal('http://1.2.3.4:80/plugin_install'); - expectPathExists(`${outDir}/roku-deploy.zip`); - }); - - it('continues with deploy if deleteDevChannel fails', async () => { - sinon.stub(rokuDeploy, 'deleteDevChannel').returns( - Promise.reject( - new Error('failed') - ) - ); - const mock = mockDoPostRequest(); - const options = { - host: '1.2.3.4', - password: 'abcd', - rootDir: rootDir, - stagingDir: stagingDir, - outDir: outDir - }; - await new ExecCommand('stage|zip|close|sideload', options).run(); - expect(mock.getCall(0).args[0].url).to.equal('http://1.2.3.4:8060/keypress/home'); - expectPathExists(`${outDir}/roku-deploy.zip`); - }); - - it('should delete installed channel if requested', async () => { - const spy = sinon.spy(rokuDeploy, 'deleteDevChannel'); - mockDoPostRequest(); - const options = { - host: '1.2.3.4', - password: 'abcd', - rootDir: rootDir, - stagingDir: stagingDir, - outDir: outDir, - deleteDevChannel: true - }; - - await new ExecCommand('stage|zip|close|sideload', options).run(); - expect(spy.called).to.equal(true); - }); - - it('should not delete installed channel if not requested', async () => { - const spy = sinon.spy(rokuDeploy, 'deleteDevChannel'); - mockDoPostRequest(); - - const options = { - host: '1.2.3.4', - password: 'abcd', - rootDir: rootDir, - stagingDir: stagingDir, - outDir: outDir, - deleteDevChannel: false - }; - - await new ExecCommand('stage|zip|close|sideload', options).run(); - expect(spy.notCalled).to.equal(true); - }); - - it('converts to squashfs if we request it to', async () => { - let stub = sinon.stub(rokuDeploy, 'convertToSquashfs').returns(Promise.resolve(null)); - mockDoPostRequest(); - const options = { - host: '1.2.3.4', - password: 'abcd', - rootDir: rootDir, - stagingDir: stagingDir, - outDir: outDir, - deleteDevChannel: false - }; - - await new ExecCommand('close|stage|zip|close|sideload|squash', options).run(); - expect(stub.getCalls()).to.be.lengthOf(1); - }); -}); diff --git a/src/cli.ts b/src/cli.ts index 6aa8bc4..e3975ba 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import * as yargs from 'yargs'; -import { ExecCommand } from './commands/ExecCommand'; +import * as path from 'path'; import { SendTextCommand } from './commands/SendTextCommand'; import { StageCommand } from './commands/StageCommand'; import { SideloadCommand } from './commands/SideloadCommand'; @@ -19,244 +19,194 @@ import { RemoteControlCommand } from './commands/RemoteControlCommand'; void yargs - .command('bundle', 'execute build actions for bundling app', (builder) => { - return builder - .option('rootDir', { type: 'string', description: 'The selected root folder to be copied', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) - .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }); - }, (args: any) => { - return new ExecCommand( - 'stage|zip', - args - ).run(); - }) - - .command('deploy', 'execute build actions for deploying app', (builder) => { + .command('sideload', 'Sideload a pre-existing packaged zip file to a remote Roku', (builder) => { return builder - .option('rootDir', { type: 'string', description: 'The selected root folder to be copied', demandOption: false }) - .option('outDir', { type: 'number', description: 'The output directory', demandOption: false }) - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('remotePort', { type: 'number', description: 'The port to use for remote', demandOption: false }) - .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }) + .option('zip', { type: 'string', description: 'The file to be sideloaded (instead of a folder), relative to cwd.', demandOption: false }) + .option('rootDir', { type: 'string', description: 'The root folder to be sideloaded (instead of a zip file), relative to cwd.', demandOption: false }) + .option('outZip', { type: 'string', description: 'The output path to the zip file.', demandOption: false }) + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the target Roku', demandOption: false }) + .option('ecpPort', { type: 'number', description: 'The port to use for ECP commands (like pressing the home button)', demandOption: false }) + .option('packagePort', { type: 'number', description: 'The port to use for sending a packaging to the device', demandOption: false }) + .option('noclose', { type: 'boolean', description: 'Should the command not close the channel before sideloading', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for this command', demandOption: false }) .option('remoteDebug', { type: 'boolean', description: 'Should the command be run in remote debug mode', demandOption: false }) .option('remoteDebugConnectEarly', { type: 'boolean', description: 'Should the command connect to the debugger early', demandOption: false }) .option('failOnCompileError', { type: 'boolean', description: 'Should the command fail if there is a compile error', demandOption: false }) - .option('retainDeploymentArchive', { type: 'boolean', description: 'Should the deployment archive be retained', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) .option('deleteDevChannel', { type: 'boolean', description: 'Should the dev channel be deleted', demandOption: false }) .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }); }, (args: any) => { - return new ExecCommand( - 'stage|zip|close|sideload', - args - ).run(); + return new SideloadCommand().run(args); }) - .command('package', 'execute build actions for packaging app', (builder) => { + .command('package', 'Create a signed package from an existing sideloaded dev channel', (builder) => { return builder - .option('rootDir', { type: 'string', description: 'The selected root folder to be copied', demandOption: false }) - .option('outDir', { type: 'number', description: 'The output directory', demandOption: false }) - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('remotePort', { type: 'number', description: 'The port to use for remote', demandOption: false }) - .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }) - .option('remoteDebug', { type: 'boolean', description: 'Should the command be run in remote debug mode', demandOption: false }) - .option('remoteDebugConnectEarly', { type: 'boolean', description: 'Should the command connect to the debugger early', demandOption: false }) - .option('failOnCompileError', { type: 'boolean', description: 'Should the command fail if there is a compile error', demandOption: false }) - .option('retainDeploymentArchive', { type: 'boolean', description: 'Should the deployment archive be retained', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) - .option('deleteDevChannel', { type: 'boolean', description: 'Should the dev channel be deleted', demandOption: false }) + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the target Roku', demandOption: false }) .option('signingPassword', { type: 'string', description: 'The password of the signing key', demandOption: false }) - .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }) + .option('appTitle', { type: 'string', description: 'The title of the app to be signed', demandOption: false }) + .option('appVersion', { type: 'string', description: 'The version of the app to be signed', demandOption: false }) + .option('manifestPath', { type: 'string', description: 'The path to the manifest file, relative to cwd', demandOption: false }) + .option('out', { type: 'string', description: 'The location where the signed package will be saved, relative to cwd', demandOption: false, defaultDescription: './out/roku-deploy.pkg' }) + .option('devId', { type: 'string', description: 'The dev ID', demandOption: false }) .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }); }, (args: any) => { - return new ExecCommand( - 'close|rekey|stage|zip|close|sideload|squash|sign', - args - ).run(); - }) - - .command('exec', 'larger command for handling a series of smaller commands', (builder) => { - return builder - .option('actions', { type: 'string', description: 'The actions to be executed, separated by |', demandOption: true }) - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) //TODO finish this. Are all of these necessary? - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) - .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }) - .option('retainedStagingDir', { type: 'boolean', description: 'Should the staging folder be retained after the command is complete', demandOption: false }) - .option('failOnCompileError', { type: 'boolean', description: 'Should the command fail if there is a compile error', demandOption: false }) - .option('deleteDevChannel', { type: 'boolean', description: 'Should the dev channel be deleted', demandOption: false }) - .option('packagePort', { type: 'number', description: 'The port to use for packaging', demandOption: false }) - .option('remotePort', { type: 'number', description: 'The port to use for remote', demandOption: false }) - .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }) - .option('rootDir', { type: 'string', description: 'The root directory', demandOption: false }) - .option('files', { type: 'array', description: 'The files to be included in the package', demandOption: false }) - .option('username', { type: 'string', description: 'The username for the Roku', demandOption: false }) - .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }) - .usage(`Usage: npx ts-node ./src/cli.ts exec --actions 'stage|zip' --rootDir . --outDir ./out`) - .example( - `npx ts-node ./src/cli.ts exec --actions 'stage|zip' --rootDir . --outDir ./out`, - 'Stages the contents of rootDir and then zips the staged files into outDir - Will fail if there is no manifest in the staging folder' - ); - }, (args: any) => { - return new ExecCommand(args.actions, args).run(); + if (args.out) { + if (!args.out.endsWith('.pkg')) { + throw new Error('Out must end with a .pkg'); + } + args.out = path.resolve(args.cwd, args.out); + args.outDir = path.dirname(args.out); + args.outFile = path.basename(args.out); + } + return new CreateSignedPackageCommand().run(args); }) .command('keyPress', 'send keypress command', (builder) => { return builder .option('key', { type: 'string', description: 'The key to send', demandOption: true }) - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('remotePort', { type: 'number', description: 'The port to use for remote', demandOption: false }) - .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('ecpPort', { type: 'number', description: 'The port to use for ECP commands like remote key presses', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for this command', demandOption: false }); }, (args: any) => { + if (args.ecpPort) { + args.remotePort = args.ecpPort; + } return new KeyPressCommand().run(args); }) .command('keyUp', 'send keyup command', (builder) => { return builder .option('key', { type: 'string', description: 'The key to send', demandOption: true }) - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('remotePort', { type: 'number', description: 'The port to use for remote', demandOption: false }) - .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('ecpPort', { type: 'number', description: 'The port to use for ECP commands like remote key presses', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for this command', demandOption: false }); }, (args: any) => { + if (args.ecpPort) { + args.remotePort = args.ecpPort; + } return new KeyUpCommand().run(args); }) .command('keyDown', 'send keydown command', (builder) => { return builder .option('key', { type: 'string', description: 'The key to send', demandOption: true }) - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('remotePort', { type: 'number', description: 'The port to use for remote', demandOption: false }) - .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('ecpPort', { type: 'number', description: 'The port to use for ECP commands like remote key presses', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for this command', demandOption: false }); }, (args: any) => { + if (args.ecpPort) { + args.remotePort = args.ecpPort; + } return new KeyDownCommand().run(args); }) - .command(['sendText', 'text'], 'Send text command', (builder) => { + .command('sendText', 'Send text command', (builder) => { return builder .option('text', { type: 'string', description: 'The text to send', demandOption: true }) - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('remotePort', { type: 'number', description: 'The port to use for remote', demandOption: false }) - .option('timeout', { type: 'number', description: 'The timeout for the command', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('ecpPort', { type: 'number', description: 'The port to use for ECP commands like remote key presses', demandOption: false }) + .option('timeout', { type: 'number', description: 'The timeout for this command', demandOption: false }); }, (args: any) => { + if (args.ecpPort) { + args.remotePort = args.ecpPort; + } return new SendTextCommand().run(args); }) - .command(['remote-control', 'rc'], 'Provides a way to send a series of ECP key events similar to how Roku Remote Tool works but from the command line', (builder) => { + .command('remote-control', 'Provides a way to send a series of ECP key events similar to how Roku Remote Tool works but from the command line', (builder) => { return builder - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('remotePort', { type: 'number', description: 'The port to use for remote', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('ecpPort', { type: 'number', description: 'The port to use for ECP commands like remote key presses', demandOption: false }); }, (args: any) => { + if (args.ecpPort) { + args.remotePort = args.ecpPort; + } return new RemoteControlCommand().run(args); }) - .command(['stage', 'prepublishToStaging'], 'Copies all of the referenced files to the staging folder', (builder) => { + .command('stage', 'Copies all of the referenced files to the staging folder', (builder) => { return builder - .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }) + .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }) .option('rootDir', { type: 'string', description: 'The selected root folder to be copied', demandOption: false }) .option('files', { type: 'array', description: 'An array of source file paths indicating where the source files are', demandOption: false }) - .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }); + .option('out', { type: 'string', description: 'The selected staging folder where all files will be copied to', demandOption: false }); }, (args: any) => { return new StageCommand().run(args); }) - .command('sideload', 'Sideload a pre-existing packaged zip file to a remote Roku', (builder) => { - return builder - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('remoteDebug', { type: 'boolean', description: 'Should the command be run in remote debug mode', demandOption: false }) - .option('remoteDebugConnectEarly', { type: 'boolean', description: 'Should the command connect to the debugger early', demandOption: false }) - .option('failOnCompileError', { type: 'boolean', description: 'Should the command fail if there is a compile error', demandOption: false }) - .option('retainDeploymentArchive', { type: 'boolean', description: 'Should the deployment archive be retained', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) - .option('deleteDevChannel', { type: 'boolean', description: 'Should the dev channel be deleted', demandOption: false }) - .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }); - }, (args: any) => { - return new SideloadCommand().run(args); - }) - - .command(['squash', 'convertToSquashfs'], 'Convert a pre-existing packaged zip file to a squashfs file', (builder) => { + .command('squash', 'Convert a pre-existing packaged zip file to a squashfs file', (builder) => { return builder - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the target Roku', demandOption: false }); }, (args: any) => { return new ConvertToSquashfsCommand().run(args); }) - .command(['rekey', 'rekeyDevice'], 'Rekey a device', (builder) => { + .command('rekey', 'Rekey a device', (builder) => { return builder - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('rekeySignedPackage', { type: 'string', description: 'The signed package to be used for rekeying', demandOption: false }) + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the target Roku', demandOption: false }) + .option('pkg', { type: 'string', description: 'The path to the signed package to be used for rekeying, relative to cwd', demandOption: false }) .option('signingPassword', { type: 'string', description: 'The password of the signing key', demandOption: false }) - .option('rootDir', { type: 'string', description: 'The root directory', demandOption: false }) .option('devId', { type: 'string', description: 'The dev ID', demandOption: false }) .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }); }, (args: any) => { + args.rekeySignedPackage = path.resolve(args.cwd, args.pkg); return new RekeyDeviceCommand().run(args); }) - .command(['createSignedPackage', 'sign'], 'Sign a package', (builder) => { - return builder - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('signingPassword', { type: 'string', description: 'The password of the signing key', demandOption: false }) - .option('stagingDir', { type: 'string', description: 'The selected staging folder', demandOption: false }) - .option('outDir', { type: 'string', description: 'The output directory', demandOption: false }) - .option('devId', { type: 'string', description: 'The dev ID', demandOption: false }) - .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }); - }, (args: any) => { - return new CreateSignedPackageCommand().run(args); - }) - - .command(['deleteDevChannel', 'deleteInstalledChannel', 'rmdev', 'delete'], 'Delete an installed channel', (builder) => { + .command('deleteDevChannel', 'Delete an installed channel', (builder) => { return builder - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the target Roku', demandOption: false }); }, (args: any) => { return new DeleteDevChannelCommand().run(args); }) - .command(['screenshot', 'captureScreenshot'], 'Take a screenshot', (builder) => { + .command('screenshot', 'Take a screenshot', (builder) => { return builder - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }) - .option('password', { type: 'string', description: 'The password of the host Roku', demandOption: false }) - .option('screenshotDir', { type: 'string', description: 'A full path to the folder where the screenshots should be saved.', demandOption: false }) - .option('screenshotFile', { type: 'string', description: 'The base filename the image file should be given (excluding the extension). Default: screenshot-YYYY-MM-DD-HH.mm.ss.SSS.', demandOption: false }) + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }) + .option('password', { type: 'string', description: 'The password of the target Roku', demandOption: false }) + .option('out', { type: 'string', description: 'The location where the screenshot will be saved relative to cwd', demandOption: false, defaultDescription: './out/roku-deploy.jpg' }) .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }); }, (args: any) => { + if (args.out) { + args.out = path.resolve(args.cwd, args.out); + args.screenshotDir = path.dirname(args.out); + args.screenshotFile = path.basename(args.out); + } return new CaptureScreenshotCommand().run(args); }) - .command(['getDeviceInfo', 'deviceinfo'], 'Get the `device-info` response from a Roku device', (builder) => { + .command('getDeviceInfo', 'Get the `device-info` response from a Roku device', (builder) => { return builder - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }); }, (args: any) => { return new GetDeviceInfoCommand().run(args); }) - .command(['getDevId', 'devid'], 'Get Dev ID', (builder) => { + .command('getDevId', 'Get Dev ID', (builder) => { return builder - .option('host', { type: 'string', description: 'The IP Address of the host Roku', demandOption: false }); + .option('host', { type: 'string', description: 'The IP Address of the target Roku', demandOption: false }); }, (args: any) => { return new GetDevIdCommand().run(args); }) .command('zip', 'Given a path to a folder, zip up that folder and all of its contents', (builder) => { return builder - .option('stagingDir', { type: 'string', description: 'The folder that should be zipped', demandOption: false }) - .option('outDir', { type: 'string', description: 'The path to the zip that will be created. Must be .zip file name', demandOption: false }) - .option('outFile', { type: 'string', description: 'The output file', demandOption: false }) + .option('dir', { type: 'string', description: 'The folder to be zipped', demandOption: false }) + .option('out', { type: 'string', description: 'the path to the zip file that will be created, relative to cwd', demandOption: false, alias: 'outZip' }) .option('cwd', { type: 'string', description: 'The current working directory to use for relative paths', demandOption: false }); }, (args: any) => { + if (args.out) { + args.out = path.resolve(args.cwd, args.out); + args.outDir = path.dirname(args.out); + args.outFile = path.basename(args.out); + } + if (args.dir) { + args.stagingDir = path.resolve(args.cwd, args.dir); + } return new ZipCommand().run(args); }) diff --git a/src/commands/ExecCommand.ts b/src/commands/ExecCommand.ts deleted file mode 100644 index d19562e..0000000 --- a/src/commands/ExecCommand.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { util } from '../util'; -import type { RokuDeployOptions } from '../RokuDeployOptions'; -import { rokuDeploy, type CloseChannelOptions, type ConvertToSquashfsOptions, type CreateSignedPackageOptions, type DeleteDevChannelOptions, type RekeyDeviceOptions, type SideloadOptions } from '../RokuDeploy'; - -export class ExecCommand { - private actions: string[]; - - // eslint-disable-next-line @typescript-eslint/ban-types - private options: RokuDeployOptions; - - constructor(actions: string, rokuDeployOptions: RokuDeployOptions) { - this.actions = actions.split('|'); - this.options = rokuDeployOptions; - } - - async run() { - //Load options from json, and overwrite with cli options - this.options = { - ...util.getOptionsFromJson(this.options), - ...this.options - }; - - if (this.actions.includes('stage')) { - await rokuDeploy.stage(this.options); - } - - if (this.actions.includes('zip')) { - await rokuDeploy.zip(this.options); - } - - if (this.actions.includes('delete')) { - try { - await rokuDeploy.deleteDevChannel(this.options as DeleteDevChannelOptions); - } catch (e) { - // note we don't report the error; as we don't actually care that we could not delete - it's just useless noise to log it. - } - } - - if (this.actions.includes('close')) { - await rokuDeploy.closeChannel(this.options as CloseChannelOptions); - } - - if (this.actions.includes('sideload')) { - await rokuDeploy.sideload(this.options as SideloadOptions); - } - - if (this.actions.includes('rekey')) { - await rokuDeploy.rekeyDevice(this.options as RekeyDeviceOptions); - } - - if (this.actions.includes('squash')) { - await rokuDeploy.convertToSquashfs(this.options as ConvertToSquashfsOptions); - } - - if (this.actions.includes('sign')) { - await rokuDeploy.createSignedPackage(this.options as CreateSignedPackageOptions); - } - - - } -} diff --git a/src/commands/SideloadCommand.ts b/src/commands/SideloadCommand.ts index 58c2434..c488b32 100644 --- a/src/commands/SideloadCommand.ts +++ b/src/commands/SideloadCommand.ts @@ -1,4 +1,6 @@ import { rokuDeploy, util } from '../index'; +import type { CloseChannelOptions } from '../RokuDeploy'; +import * as path from 'path'; export class SideloadCommand { async run(args) { @@ -6,6 +8,40 @@ export class SideloadCommand { ...util.getOptionsFromJson(args), ...args }; - await rokuDeploy.sideload(options); + + // Process args so that they can be compatible with the RokuDeploy + args.cwd ??= process.cwd(); + if (args.zip) { + args.zip = path.resolve(args.cwd, args.zip); + options.outDir = path.dirname(args.zip); + options.outFile = path.basename(args.zip); + } + if (args.rootDir) { + options.rootDir = path.resolve(args.cwd, args.rootDir); + } + + if (args.outZip) { + options.outZip = path.resolve(args.cwd, args.outZip); + } + + if (args.ecpPort) { + options.remotePort = args.ecpPort; + } + + if (args.noclose !== true) { + await rokuDeploy.closeChannel(options as CloseChannelOptions); + } + + + if (args.zip) { + options.retainDeploymentArchive = true; + await rokuDeploy.sideload(options); + } else if (args.rootDir) { + await rokuDeploy.zip(options); + options.retainDeploymentArchive = false; + await rokuDeploy.sideload(options); + } else { + throw new Error('Either zip or rootDir must be provided for sideload command'); + } } } diff --git a/src/util.ts b/src/util.ts index e587f31..fe0aa89 100644 --- a/src/util.ts +++ b/src/util.ts @@ -444,10 +444,10 @@ export class Util { * A function to fill in any missing arguments with JSON values * Only run when CLI commands are used */ - public getOptionsFromJson(options?: { cwd?: string }) { + public getOptionsFromJson(options?: { cwd?: string; configPath?: string }) { let fileOptions: RokuDeployOptions = {}; const cwd = options?.cwd ?? process.cwd(); - const configPath = path.join(cwd, 'rokudeploy.json'); + const configPath = options?.configPath ?? path.join(cwd, 'rokudeploy.json'); if (fsExtra.existsSync(configPath)) { let configFileText = fsExtra.readFileSync(configPath).toString();