diff --git a/.eslintrc.js b/.eslintrc.js index 98fed75..bd06f0f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -173,7 +173,8 @@ module.exports = { 'camelcase': 'off', 'dot-notation': 'off', 'new-cap': 'off', - 'no-shadow': 'off' + 'no-shadow': 'off', + 'prefer-promise-reject-errors': 'off' } } ], diff --git a/.github/workflows/make-release-artifacts.yml b/.github/workflows/make-release-artifacts.yml index ac6756f..48f9f5c 100644 --- a/.github/workflows/make-release-artifacts.yml +++ b/.github/workflows/make-release-artifacts.yml @@ -6,15 +6,27 @@ on: - reopened - opened - synchronize + workflow_dispatch: + inputs: + tag: + type: string + description: 'The release tag that should be referenced (i.e. `v1.2.3`)' + required: true + force: + type: boolean + description: 'Force the release artifacts to be created and uploaded' + required: false + default: false jobs: run: - if: startsWith( github.head_ref, 'release/') + if: github.event_name == 'workflow_dispatch' || startsWith( github.head_ref, 'release/') uses: rokucommunity/workflows/.github/workflows/make-release-artifacts.yml@master with: - branch: ${{ github.event.pull_request.head.ref }} + branch: ${{ github.event.inputs.tag || github.event.pull_request.head.ref }} node-version: "16.20.2" artifact-paths: "./*.tgz" # "*.vsix" + force: ${{ github.event.inputs.force == 'true' }} secrets: inherit success-or-skip: @@ -23,3 +35,4 @@ jobs: runs-on: ubuntu-latest steps: - run: if [ "${{ needs.run.result }}" = "failure" ]; then exit 1; fi + diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 1d6887d..1d1ff42 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -3,16 +3,27 @@ name: Publish Release on: pull_request: types: - - closed + - closed paths: - - 'package.json' - - 'package-lock.json' + - "package.json" + - "package-lock.json" + workflow_dispatch: + inputs: + tag: + type: string + description: "The release tag that should be published (i.e. `v1.2.3`)" + required: true jobs: run: - if: startsWith( github.head_ref, 'release/') && (github.event.pull_request.merged == true) + if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release/') uses: rokucommunity/workflows/.github/workflows/publish-release.yml@master + permissions: + id-token: write + contents: write + pull-requests: write with: - release-type: "npm" # "vsce" - ref: ${{ github.event.pull_request.merge_commit_sha }} + release-type: "npm" # or "vsce" + ref: ${{ github.event.inputs.tag || github.event.pull_request.merge_commit_sha }} secrets: inherit + diff --git a/CHANGELOG.md b/CHANGELOG.md index 7311d6b..f5b1fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [3.16.1](https://github.com/rokucommunity/roku-deploy/compare/3.16.0...v3.16.1) - 2025-12-05 +### Added + - Add ecpSettingMode to device-info interface ([#225](https://github.com/rokucommunity/roku-deploy/pull/225)) + + + +## [3.16.0](https://github.com/rokucommunity/roku-deploy/compare/3.15.0...v3.16.0) - 2025-12-05 +### Added + - Add support for detecting ecpNetworkAccessMode ([#223](https://github.com/rokucommunity/roku-deploy/pull/223)) + + + +## [3.15.0](https://github.com/rokucommunity/roku-deploy/compare/3.14.4...v3.15.0) - 2025-11-17 +### Added + - Support installing and deleting component libraries ([#220](https://github.com/rokucommunity/roku-deploy/pull/220)) + + + +## [3.14.4](https://github.com/rokucommunity/roku-deploy/compare/3.14.3...v3.14.4) - 2025-10-30 +### Changed +chore: Update package.json repository to support provenance (#218) + + + +## [3.14.3](https://github.com/rokucommunity/roku-deploy/compare/3.14.2...v3.14.3) - 2025-10-30 +### Added + - Add specific error classes for reboot and check for updates functions ([#216](https://github.com/rokucommunity/roku-deploy/pull/216)) + + + +## [3.14.2](https://github.com/rokucommunity/roku-deploy/compare/3.14.1...v3.14.2) - 2025-10-30 +### Changed + - chore: add pull-request write permissions ([#214](https://github.com/rokucommunity/roku-deploy/pull/214)) + + + +## [3.14.1](https://github.com/rokucommunity/roku-deploy/compare/3.14.0...v3.14.1) - 2025-10-29 +### Changed + - chore: add OIDC permissions to the dispatching workflow ([#211](https://github.com/rokucommunity/roku-deploy/pull/211)) + + + +## [3.14.0](https://github.com/rokucommunity/roku-deploy/compare/3.13.0...v3.14.0) - 2025-10-28 +### Added + - Add 'rebootDevice' and 'checkForUpdate' functionality for supported OS versions ([#208](https://github.com/rokucommunity/roku-deploy/pull/208)) + + + +## [3.13.0](https://github.com/rokucommunity/roku-deploy/compare/3.12.6...v3.13.0) - 2025-08-04 +### Added + - Add standards-compliant User-Agent header ([#203](https://github.com/rokucommunity/roku-deploy/pull/203)) + + + ## [3.12.6](https://github.com/rokucommunity/roku-deploy/compare/3.12.5...v3.12.6) - 2025-06-03 ### Changed - chore: upgrade to the `undent` package instead of `dedent` ([#192](https://github.com/rokucommunity/roku-deploy/pull/196)) @@ -478,3 +532,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0](https://github.com/RokuCommunity/roku-deploy/compare/v0.2.1...v1.0.0) - 2018-12-18 ### Added - support for negated globs + + + + diff --git a/README.md b/README.md index 269c2c1..fc34116 100644 --- a/README.md +++ b/README.md @@ -388,9 +388,23 @@ Here are the available options. The defaults are shown to the right of the optio If true the previously installed dev channel will be deleted before installing the new one -Click [here](https://github.com/rokucommunity/roku-deploy/blob/8e1cbdfcccb38dad4a1361277bdaf5484f1c2bcd/src/RokuDeploy.ts#L897) to see the typescript interface for these options +Click [here](https://github.com/rokucommunity/roku-deploy/blob/master/src/RokuDeployOptions.ts) to see the typescript interface for these options +## User-agent +roku-deploy includes a User-Agent header to help you filter out unwanted network traffic from your network monitoring software of choice. The User-Agent header will be the name of the tool (`roku-deploy`) followed by the full version of the tool. If you're using a prerelease or temporary testing version, that information will also be included. Here are some examples: +``` +User-Agent: roku-deploy/3.12.6 +User-Agent: roku-deploy/4.0.0-alpha.12 +User-Agent: roku-deploy/3.12.7-some-branch-name-123.3958293294854 + +``` + +If for some strange reason we were unable to find the version number, then the `User-Agent` will be: +``` +User-Agent: roku-deploy/unknown +``` + ## Troubleshooting - if you see a `ESOCKETTIMEDOUT` error during deployment, this can be caused by an antivirus blocking network traffic, so consider adding a special exclusion for your Roku device. diff --git a/package-lock.json b/package-lock.json index bea2777..11f92e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roku-deploy", - "version": "3.12.6", + "version": "3.16.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "roku-deploy", - "version": "3.12.6", + "version": "3.16.1", "license": "MIT", "dependencies": { "@types/request": "^2.47.0", @@ -23,6 +23,7 @@ "moment": "^2.29.1", "parse-ms": "^2.1.0", "postman-request": "^2.88.1-postman.40", + "semver": "^7.7.3", "temp-dir": "^2.0.0", "xml2js": "^0.5.0" }, @@ -38,6 +39,7 @@ "@types/mocha": "^9.0.0", "@types/node": "^16.11.3", "@types/q": "^1.5.8", + "@types/semver": "^7.7.1", "@types/sinon": "^10.0.4", "@types/xml2js": "^0.4.5", "@typescript-eslint/eslint-plugin": "5.1.0", @@ -910,6 +912,13 @@ "form-data": "^2.5.0" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sinon": { "version": "10.0.4", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.4.tgz", @@ -1426,9 +1435,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001579", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", - "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "dev": true, "funding": [ { @@ -1443,7 +1452,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/caseless": { "version": "0.12.0", @@ -3129,9 +3139,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" }, "node_modules/lodash.flattendeep": { "version": "4.4.0", @@ -3246,18 +3256,6 @@ "node": ">=8" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -4423,13 +4421,10 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5144,12 +5139,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -5924,6 +5913,12 @@ "form-data": "^2.5.0" } }, + "@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true + }, "@types/sinon": { "version": "10.0.4", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.4.tgz", @@ -6282,9 +6277,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001579", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", - "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "dev": true }, "caseless": { @@ -7557,9 +7552,9 @@ } }, "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" }, "lodash.flattendeep": { "version": "4.4.0", @@ -7646,15 +7641,6 @@ } } }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -8517,13 +8503,9 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==" }, "serialize-javascript": { "version": "6.0.0", @@ -9068,12 +9050,6 @@ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/package.json b/package.json index 4e236c9..cb8ffe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roku-deploy", - "version": "3.12.6", + "version": "3.16.1", "description": "Package and publish a Roku application using Node.js", "main": "dist/index.js", "scripts": { @@ -32,6 +32,7 @@ "moment": "^2.29.1", "parse-ms": "^2.1.0", "postman-request": "^2.88.1-postman.40", + "semver": "^7.7.3", "temp-dir": "^2.0.0", "xml2js": "^0.5.0" }, @@ -44,6 +45,7 @@ "@types/mocha": "^9.0.0", "@types/node": "^16.11.3", "@types/q": "^1.5.8", + "@types/semver": "^7.7.1", "@types/sinon": "^10.0.4", "@types/xml2js": "^0.4.5", "@typescript-eslint/eslint-plugin": "5.1.0", @@ -77,7 +79,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/RokuCommunity/roku-deploy.git" + "url": "https://github.com/rokucommunity/roku-deploy" }, "author": "RokuCommunity", "license": "MIT", diff --git a/src/DeviceInfo.ts b/src/DeviceInfo.ts index cc18f8e..c78cf8d 100644 --- a/src/DeviceInfo.ts +++ b/src/DeviceInfo.ts @@ -1,5 +1,7 @@ //there are 2 copies of this interface in here. If you add a new field, be sure to add it to both +import type { EcpNetworkAccessMode } from './RokuDeploy'; + export interface DeviceInfo { udn?: string; serialNumber?: string; @@ -31,6 +33,7 @@ export interface DeviceInfo { softwareVersion?: string; softwareBuild?: number; secureDevice?: boolean; + ecpSettingMode?: EcpNetworkAccessMode; language?: string; country?: string; locale?: string; @@ -104,6 +107,7 @@ export interface DeviceInfoRaw { 'software-version'?: string; 'software-build'?: string; 'secure-device'?: string; + 'ecp-setting-mode'?: EcpNetworkAccessMode; 'language'?: string; 'country'?: string; 'locale'?: string; diff --git a/src/Errors.ts b/src/Errors.ts index 1ca78cc..2d2d835 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -15,6 +15,13 @@ export class UnauthorizedDeviceResponseError extends Error { } } +export class EcpNetworkAccessModeDisabledError extends Error { + constructor(message: string, public results?: any) { + super(message); + Object.setPrototypeOf(this, EcpNetworkAccessModeDisabledError.prototype); + } +} + export class UnparsableDeviceResponseError extends Error { constructor(message: string, public results?: any) { super(message); @@ -57,6 +64,13 @@ export class MissingRequiredOptionError extends Error { } } +export class UnsupportedFirmwareVersionError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, UnsupportedFirmwareVersionError.prototype); + } +} + /** * This error is thrown when a Roku device refuses to accept connections because it requires the user to check for updates (even if no updates are actually available). */ diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index b50cd6b..6a755fd 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -21,7 +21,7 @@ const request = r as typeof requestType; const sinon = createSandbox(); -describe('index', () => { +describe('RokuDeploy', () => { let rokuDeploy: RokuDeploy; let options: RokuDeployOptions; @@ -61,15 +61,20 @@ describe('index', () => { }); afterEach(() => { - if (createWriteStreamStub.called && !writeStreamDeferred.isComplete) { - writeStreamDeferred.reject('Deferred was never resolved...so rejecting in the afterEach'); - } + try { + if (createWriteStreamStub.called && !writeStreamDeferred.isComplete) { + writeStreamDeferred.reject('Deferred was never resolved...so rejecting in the afterEach'); + } - sinon.restore(); - //restore the original working directory - process.chdir(cwd); - //delete all temp files - fsExtra.emptyDirSync(tempDir); + sinon.restore(); + //restore the original working directory + process.chdir(cwd); + //delete all temp files + fsExtra.emptyDirSync(tempDir); + } catch (e) { + //not sure why this test fails sometimes in github actions, but hopefully this will mitigate the issue. + console.error('Error in afterEach:', e); + } }); after(() => { @@ -112,7 +117,7 @@ describe('index', () => { return {} as any; }); - let results = await rokuDeploy['doPostRequest']({}, true); + let results = await rokuDeploy['doPostRequest']({} as any, true); expect(results.body).to.equal(body); }); @@ -124,7 +129,7 @@ describe('index', () => { }); try { - await rokuDeploy['doPostRequest']({}, true); + await rokuDeploy['doPostRequest']({} as any, true); } catch (e) { expect(e).to.equal(error); return; @@ -140,7 +145,7 @@ describe('index', () => { }); try { - await rokuDeploy['doPostRequest']({}, true); + await rokuDeploy['doPostRequest']({} as any, true); } catch (e) { expect(e).to.be.instanceof(errors.InvalidDeviceResponseCodeError); return; @@ -155,7 +160,7 @@ describe('index', () => { return {} as any; }); - let results = await rokuDeploy['doPostRequest']({}, false); + let results = await rokuDeploy['doPostRequest']({} as any, false); expect(results.body).to.equal(body); }); }); @@ -558,6 +563,130 @@ describe('index', () => { } assert.fail('Exception should have been thrown'); }); + + it('handles all error scenarios in catch block', async () => { + const doGetRequestStub = sinon.stub(rokuDeploy as any, 'doGetRequest'); + + doGetRequestStub.rejects({ results: { response: { headers: { server: 'Roku' } } } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.be.instanceof(errors.EcpNetworkAccessModeDisabledError); + } + + doGetRequestStub.rejects({ results: { response: { headers: { server: 'Apache' } } } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response.headers.server).to.equal('Apache'); + } + + doGetRequestStub.rejects({ results: { response: { headers: {} } } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response.headers.server).to.be.undefined; + } + + doGetRequestStub.rejects({ results: { response: { headers: { server: null } } } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response.headers.server).to.be.null; + } + + doGetRequestStub.rejects({ results: { response: {} } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response.headers).to.be.undefined; + } + + doGetRequestStub.rejects({ results: {} }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response).to.be.undefined; + } + + doGetRequestStub.rejects({}); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results).to.be.undefined; + } + + const err = new Error('Network error'); + doGetRequestStub.rejects(err); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.equal(err); + } + + // eslint-disable-next-line prefer-promise-reject-errors + doGetRequestStub.callsFake(() => Promise.reject(null)); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.be.null; + } + }); + }); + + describe('getEcpNetworkAccessMode', () => { + it('returns ecpSettingMode from device info', async () => { + sinon.stub(rokuDeploy, 'getDeviceInfo').resolves({ ecpSettingMode: 'enabled' }); + const result = await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' }); + expect(result).to.equal('enabled'); + }); + + it(`returns 'disabled' when response header had Roku in it`, async () => { + const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo'); + getDeviceInfoStub.rejects({ results: { response: { headers: { server: 'Roku' } } } }); + expect(await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' })).to.equal('disabled'); + }); + + it('handles all error scenarios in catch block', async () => { + const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo'); + async function doTest(rejectionValue: any) { + getDeviceInfoStub.rejects(rejectionValue); + try { + await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.be.instanceof(errors.UnknownDeviceResponseError); + } + } + + await doTest({ results: { response: { headers: { server: 'Apache' } } } }); + await doTest({ results: { response: { headers: {} } } }); + await doTest({ results: { response: { headers: { server: null } } } }); + await doTest({ results: { response: {} } }); + await doTest({ results: {} }); + await doTest({}); + await doTest(new Error('Network error')); + }); + + it('handles null error from rejected promise', async () => { + const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo'); + getDeviceInfoStub.callsFake(() => Promise.reject(null)); + try { + await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.be.instanceof(errors.UnknownDeviceResponseError); + } + }); }); describe('normalizeDeviceInfoFieldValue', () => { @@ -721,7 +850,7 @@ describe('index', () => { options.files = files; await rokuDeploy.createPackage(options); const data = fsExtra.readFileSync(rokuDeploy.getOutputZipFilePath(options)); - const zip = await JSZip.loadAsync(data); + const zip = await JSZip.loadAsync(data as any); for (const file of files) { const zipFileContents = await zip.file(file.toString()).async('string'); @@ -745,7 +874,7 @@ describe('index', () => { }); const data = fsExtra.readFileSync(rokuDeploy.getOutputZipFilePath(options)); - const zip = await JSZip.loadAsync(data); + const zip = await JSZip.loadAsync(data as any); for (const file of filePaths) { const zipFileContents = await zip.file(file.toString())?.async('string'); @@ -1203,6 +1332,42 @@ describe('index', () => { }); }); + it('does not set appType if not explicitly defined', async () => { + delete options.appType; + const stub = mockDoPostRequest(); + + const result = await rokuDeploy.publish(options); + expect(result.message).to.equal('Successful deploy'); + expect(stub.getCall(0).args[0].formData.app_type).to.be.undefined; + }); + + it('does not set appType if not appType is set to null or undefined', async () => { + options.appType = null; + const stub = mockDoPostRequest(); + + const result = await rokuDeploy.publish(options); + expect(result.message).to.equal('Successful deploy'); + expect(stub.getCall(0).args[0].formData.app_type).to.be.undefined; + }); + + it('sets appType="channel" when defined', async () => { + options.appType = 'channel'; + const stub = mockDoPostRequest(); + + const result = await rokuDeploy.publish(options); + expect(result.message).to.equal('Successful deploy'); + expect(stub.getCall(0).args[0].formData.app_type).to.eql('channel'); + }); + + it('sets appType="channel" when defined', async () => { + options.appType = 'dcl'; + const stub = mockDoPostRequest(); + + const result = await rokuDeploy.publish(options); + expect(result.message).to.equal('Successful deploy'); + expect(stub.getCall(0).args[0].formData.app_type).to.eql('dcl'); + }); + it('Does not reject when response contains compile error wording but config is set to ignore compile warnings', () => { options.failOnCompileError = false; @@ -1447,12 +1612,10 @@ describe('index', () => { `; mockDoPostRequest(body); options.rekeySignedPackage = s`../notReal.pkg`; - try { - fsExtra.writeFileSync(s`${tempDir}/notReal.pkg`, ''); - await rokuDeploy.rekeyDevice(options); - } finally { - fsExtra.removeSync(s`${tempDir}/notReal.pkg`); - } + fsExtra.outputFileSync(s`${tempDir}/notReal.pkg`, ''); + //small sleep to ensure the file exists (hack for testing!) + await util.sleep(10); + await rokuDeploy.rekeyDevice(options); }); it('should work with absolute path', async () => { @@ -1912,11 +2075,11 @@ describe('index', () => { symlinkIt('copies files from subdirs of symlinked folders', async () => { fsExtra.ensureDirSync(s`${tempDir}/baseProject/source/lib/promise`); - fsExtra.writeFileSync(s`${tempDir}/baseProject/source/lib/lib.brs`, `'lib.brs`); - fsExtra.writeFileSync(s`${tempDir}/baseProject/source/lib/promise/promise.brs`, `'q.brs`); + fsExtra.outputFileSync(s`${tempDir}/baseProject/source/lib/lib.brs`, `'lib.brs`); + fsExtra.outputFileSync(s`${tempDir}/baseProject/source/lib/promise/promise.brs`, `'q.brs`); fsExtra.ensureDirSync(s`${tempDir}/mainProject/source`); - fsExtra.writeFileSync(s`${tempDir}/mainProject/source/main.brs`, `'main.brs`); + fsExtra.outputFileSync(s`${tempDir}/mainProject/source/main.brs`, `'main.brs`); //symlink the baseProject lib folder into the mainProject fsExtra.symlinkSync(s`${tempDir}/baseProject/source/lib`, s`${tempDir}/mainProject/source/lib`); @@ -2130,6 +2293,59 @@ describe('index', () => { }); }); + describe('plugin_swup', () => { + function mockGetDeviceInfo(swVersion: string) { + sinon.stub(rokuDeploy as any, 'getDeviceInfo').callsFake((params) => { + return { 'software-version': swVersion }; + }); + } + it('should send a request to the plugin_swup endpoint for a reboot', async () => { + mockGetDeviceInfo('15.0.4'); + let stub = mockDoPostRequest(); + let result = await rokuDeploy.rebootDevice(options); + expect(result).not.to.be.undefined; + expect(stub.args[0][0].url).to.include(`/plugin_swup`); + expect(stub.args[0][0].formData.mysubmit).to.include('Reboot'); + }); + + it('should send a request to the plugin_swup endpoint to check for update', async () => { + mockGetDeviceInfo('15.0.4'); + let stub = mockDoPostRequest(); + let result = await rokuDeploy.checkForUpdate(options); + expect(result).not.to.be.undefined; + expect(stub.args[0][0].url).to.include(`/plugin_swup`); + expect(stub.args[0][0].formData.mysubmit).to.include('CheckUpdate'); + }); + + it('should fail to reboot when sw version is just below minimum (15.0.3)', async () => { + mockGetDeviceInfo('15.0.3'); + await assertThrowsAsync(async () => { + await rokuDeploy.rebootDevice(options); + }); + }); + + it('should fail to reboot when software-version is null', async () => { + mockGetDeviceInfo(null); + await assertThrowsAsync(async () => { + await rokuDeploy.rebootDevice(options); + }); + }); + + it('should fail to check for updates when sw version is just below minimum (15.0.3)', async () => { + mockGetDeviceInfo('15.0.3'); + await assertThrowsAsync(async () => { + await rokuDeploy.checkForUpdate(options); + }); + }); + + it('should fail to check for updates when software-version is null', async () => { + mockGetDeviceInfo(null); + await assertThrowsAsync(async () => { + await rokuDeploy.checkForUpdate(options); + }); + }); + }); + describe('deleteInstalledChannel', () => { it('attempts to delete any installed dev channel on the device', async () => { mockDoPostRequest(); @@ -2429,7 +2645,7 @@ describe('index', () => { }); const data = fsExtra.readFileSync(outputZipPath); - const zip = await JSZip.loadAsync(data); + const zip = await JSZip.loadAsync(data as any); for (const file of files) { const zipFileContents = await zip.file(file.toString()).async('string'); const sourcePath = path.join(options.rootDir, file); @@ -2457,7 +2673,7 @@ describe('index', () => { await rokuDeploy.zipFolder(stagingDir, outputZipPath, null, ['**/*', '!**/*.map']); const data = fsExtra.readFileSync(outputZipPath); - const zip = await JSZip.loadAsync(data); + const zip = await JSZip.loadAsync(data as any); //the .map files should be missing expect( Object.keys(zip.files).sort() @@ -3113,10 +3329,10 @@ describe('index', () => { fsExtra.ensureDirSync(s`${thisRootDir}/components`); fsExtra.ensureDirSync(s`${thisRootDir}/../.tmp`); - fsExtra.writeFileSync(s`${thisRootDir}/source/main.brs`, ''); - fsExtra.writeFileSync(s`${thisRootDir}/components/MainScene.brs`, ''); - fsExtra.writeFileSync(s`${thisRootDir}/components/MainScene.xml`, ''); - fsExtra.writeFileSync(s`${thisRootDir}/../.tmp/MainScene.brs`, ''); + fsExtra.outputFileSync(s`${thisRootDir}/source/main.brs`, ''); + fsExtra.outputFileSync(s`${thisRootDir}/components/MainScene.brs`, ''); + fsExtra.outputFileSync(s`${thisRootDir}/components/MainScene.xml`, ''); + fsExtra.outputFileSync(s`${thisRootDir}/../.tmp/MainScene.brs`, ''); let files = [ '**/*.xml', @@ -3755,6 +3971,72 @@ describe('index', () => { }); }); + describe('setUserAgentIfMissing', () => { + const currentVersion = fsExtra.readJsonSync(`${__dirname}/../package.json`).version; + + it('getUserAgent caches package version', () => { + const spy = sinon.spy(fsExtra, 'readJsonSync'); + rokuDeploy['_packageVersion'] = undefined; + expect(rokuDeploy['getUserAgent']()).to.eql(`roku-deploy/${currentVersion}`); + expect(rokuDeploy['getUserAgent']()).to.eql(`roku-deploy/${currentVersion}`); + + expect(spy.callCount).to.equal(1); + }); + + it('getUserAgent caches failed package.json read', () => { + const stub = sinon.stub(fsExtra, 'readJsonSync').throws(new Error('Unable to read package.json')); + rokuDeploy['_packageVersion'] = undefined; + expect(rokuDeploy['getUserAgent']()).to.eql(`roku-deploy/unknown`); + expect(rokuDeploy['getUserAgent']()).to.eql(`roku-deploy/unknown`); + + expect(stub.callCount).to.equal(1); + rokuDeploy['_packageVersion'] = null; + }); + + it('currentVersion is valid', () => { + expect(currentVersion).to.exist.and.to.match(/^\d+\.\d+\.\d+.*/); + }); + + it('works when params is undefined', () => { + //undefined + expect( + rokuDeploy['setUserAgentIfMissing'](undefined) + ).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); + }); + + it('works when params has no header container', () => { + expect( + rokuDeploy['setUserAgentIfMissing']({} as any) + ).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); + }); + + it('works when params has empty header container', () => { + expect( + rokuDeploy['setUserAgentIfMissing']({} as any) + ).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); + }); + + it('works when params has existing header container with no user agent', () => { + expect( + rokuDeploy['setUserAgentIfMissing']({ headers: {} } as any) + ).to.eql({ headers: { 'User-Agent': `roku-deploy/${currentVersion}` } }); + }); + + it('works when params has existing header container with user agent', () => { + expect( + rokuDeploy['setUserAgentIfMissing']({ headers: { 'User-Agent': 'some-other-user-agent' } } as any) + ).to.eql({ headers: { 'User-Agent': 'some-other-user-agent' } }); + }); + + it('works when we fail to load package version', () => { + sinon.stub(fsExtra, 'readJsonSync').throws(new Error('Unable to read package.json')); + rokuDeploy['_packageVersion'] = undefined; + expect( + rokuDeploy['setUserAgentIfMissing']({} as any) + ).to.eql({ headers: { 'User-Agent': 'roku-deploy/unknown' } }); + }); + }); + describe('isUpdateCheckRequiredResponse', () => { it('matches on actual response from device', () => { const response = `\n\n \n \n Roku Development Kit \n\n \n\n\n
\n\n
\n\n \n \n\n\n
\n\n Please make sure that your Roku device is connected to internet, and running most recent software version (d=953108)\n\n
\n\n\n\n`; @@ -3787,7 +4069,7 @@ describe('index', () => { it('returns false on missing results', () => { expect( - rokuDeploy['isUpdateRequiredError']({ }) + rokuDeploy['isUpdateRequiredError']({}) ).to.be.false; }); @@ -3808,9 +4090,215 @@ describe('index', () => { rokuDeploy['isUpdateRequiredError']({ results: { response: { statusCode: 500 }, body: false } }) ).to.be.false; }); + }); + + describe('getInstalledPackages', () => { + it('sends the dcl_enabled qs flag', async () => { + const stub = mockDoGetRequest(); + sinon.stub(rokuDeploy as any, 'getPackagesFromResponseBody').returns([]); + const result = await rokuDeploy['getInstalledPackages']({} as any); + expect(stub.getCall(0).args[0].qs.dcl_enabled).to.eql('1'); + expect(result).to.eql([]); + }); + + it('augments if qs is already defined', async () => { + sinon.stub(rokuDeploy as any, 'generateBaseRequestOptions').returns({ + qs: { + existing: 'value' + } + } as any); + const stub = mockDoGetRequest(); + sinon.stub(rokuDeploy as any, 'getPackagesFromResponseBody').returns([]); + const result = await rokuDeploy['getInstalledPackages']({} as any); + expect(stub.getCall(0).args[0].qs).to.eql({ + existing: 'value', + dcl_enabled: '1' + }); + expect(result).to.eql([]); + }); + + it('properly parses the response', async () => { + const stub = mockDoGetRequest(` + var params = JSON.parse('{"messages":null,"metadata":{"dev_id":"12345","dev_key":true,"voice_sdk":false},"packages":[{"appType":"channel","archiveFileName":"roku-deploy.zip","fileType":"zip","id":"0","location":"nvram","md5":"a8d2f9974e2736174c1033b8a7183288","pkgPath":"","size":"2267547"}]}'); + `); + const result = await rokuDeploy['getInstalledPackages']({} as any); + expect(stub.getCall(0).args[0].qs.dcl_enabled).to.eql('1'); + expect(result).to.eql([{ + appType: 'channel', + archiveFileName: 'roku-deploy.zip', + fileType: 'zip', + id: '0', + location: 'nvram', + md5: 'a8d2f9974e2736174c1033b8a7183288', + pkgPath: '', + size: '2267547' + }]); + }); + + it('handles when packages is not an array', async () => { + mockDoGetRequest(` + var params = JSON.parse('{"messages":null,"metadata":{"dev_id":"12345","dev_key":true,"voice_sdk":false},"packages": 123}'); + `); + const result = await rokuDeploy['getInstalledPackages']({} as any); + expect(result).to.eql([]); + }); + + it('handles when the item is not an object', async () => { + mockDoGetRequest(` + var params = JSON.parse('123'); + `); + const result = await rokuDeploy['getInstalledPackages']({} as any); + expect(result).to.eql([]); + }); + }); + + describe('deleteComponentLibrary', () => { + it('does not crash if qs is undefined', async () => { + const stub = mockDoPostRequest(); + + sinon.stub(rokuDeploy as any, 'generateBaseRequestOptions').returns({} as any); + await rokuDeploy.deleteComponentLibrary({} as any); + + expect(stub.getCall(0).args[0].qs.dcl_enabled).to.eql('1'); + }); + + it('augments if qs is already defined', async () => { + sinon.stub(rokuDeploy as any, 'generateBaseRequestOptions').returns({ + qs: { + existing: 'value' + } + } as any); + const stub = mockDoPostRequest(); + + await rokuDeploy.deleteComponentLibrary({} as any); + + expect(stub.getCall(0).args[0].qs).to.eql({ + existing: 'value', + dcl_enabled: '1' + }); + }); + it('deletes the component library', async () => { + options.failOnCompileError = true; + options.remoteDebug = true; + options.remoteDebugConnectEarly = true; + const stub = mockDoPostRequest(); + + await rokuDeploy.deleteComponentLibrary({ + host: '0.0.0.0', + password: 'aaaa', + fileName: 'fakeFile.zip' + }); + + //ensure we're sending the correct form inputs + expect(stub.getCall(0).args[0].formData).to.eql({ + mysubmit: 'Delete', + app_type: 'dcl', + fileName: 'fakeFile.zip' + }); + //also set the query string parameter that enables DCL behaviors (this seems to be important as well for some reason...) + expect(stub.getCall(0).args[0].qs.dcl_enabled).to.eql('1'); + }); + }); + + describe('deleteAllComponentLibraries', () => { + it('sends no requests if there are no DCLs to delete', async () => { + //return 0 packages + sinon.stub(rokuDeploy as any, 'getInstalledPackages').returns(Promise.resolve([])); + const stub = sinon.stub(rokuDeploy, 'deleteComponentLibrary').returns(Promise.resolve()); + await rokuDeploy.deleteAllComponentLibraries({} as any); + expect(stub.called).to.be.false; + }); + + it('sends no requests if there are no DCLs to delete', async () => { + //return 1 channel package + sinon.stub(rokuDeploy as any, 'getInstalledPackages').returns(Promise.resolve([{ + appType: 'channel', + archiveFileName: 'roku-deploy.zip', + fileType: 'zip', + id: '0', + location: 'nvram', + md5: 'a8d2f9974e2736174c1033b8a7183288', + pkgPath: '', + size: '2267547' + }])); + const stub = sinon.stub(rokuDeploy, 'deleteComponentLibrary').returns(Promise.resolve()); + await rokuDeploy.deleteAllComponentLibraries({} as any); + expect(stub.called).to.be.false; + }); + + it('sends single request if only have one DCL to delete', async () => { + //return 1 channel package + sinon.stub(rokuDeploy as any, 'getInstalledPackages').returns(Promise.resolve([{ + appType: 'channel', + archiveFileName: 'roku-deploy.zip', + fileType: 'zip', + id: '0', + location: 'nvram', + md5: 'a8d2f9974e2736174c1033b8a7183288', + pkgPath: '', + size: '2267547' + }, { + appType: 'dcl', + archiveFileName: 'lib.zip', + fileType: 'zip', + id: '0', + location: 'nvram', + md5: '7221a9bfb63be42f4fc6b0de22584af6', + pkgPath: '', + size: '1231' + }])); + const stub = sinon.stub(rokuDeploy, 'deleteComponentLibrary').returns(Promise.resolve()); + await rokuDeploy.deleteAllComponentLibraries({} as any); + expect(stub.getCall(0).args[0]).to.eql({ + fileName: 'lib.zip' + }); + }); + + it('sends one request for each DCL', async () => { + //return 1 channel package + sinon.stub(rokuDeploy as any, 'getInstalledPackages').returns(Promise.resolve([{ + appType: 'dcl', + archiveFileName: 'lib1.zip', + fileType: 'zip', + id: '0', + location: 'nvram', + md5: '7221a9bfb63be42f4fc6b0de22584af6', + pkgPath: '', + size: '1231' + }, { + appType: 'channel', + archiveFileName: 'roku-deploy.zip', + fileType: 'zip', + id: '0', + location: 'nvram', + md5: 'a8d2f9974e2736174c1033b8a7183288', + pkgPath: '', + size: '226754' + }, { + appType: 'dcl', + archiveFileName: 'lib2.zip', + fileType: 'zip', + id: '0', + location: 'nvram', + md5: '7221a9bfb63be42f4fc6b0de22584af6', + pkgPath: '', + size: '1231' + }])); + const stub = sinon.stub(rokuDeploy, 'deleteComponentLibrary').returns(Promise.resolve()); + await rokuDeploy.deleteAllComponentLibraries({} as any); + expect(stub.getCalls().map(x => x.args)).to.eql([ + [{ + fileName: 'lib1.zip' + }], + [{ + fileName: 'lib2.zip' + }] + ]); + }); }); + function mockDoGetRequest(body = '', statusCode = 200) { return sinon.stub(rokuDeploy as any, 'doGetRequest').callsFake((params) => { let results = { response: { statusCode: statusCode }, body: body }; diff --git a/src/RokuDeploy.ts b/src/RokuDeploy.ts index 26ec742..cdc7106 100644 --- a/src/RokuDeploy.ts +++ b/src/RokuDeploy.ts @@ -18,6 +18,7 @@ import * as tempDir from 'temp-dir'; import * as dayjs from 'dayjs'; import * as lodash from 'lodash'; import type { DeviceInfo, DeviceInfoRaw } from './DeviceInfo'; +import * as semver from 'semver'; export class RokuDeploy { @@ -435,7 +436,8 @@ export class RokuDeploy { let requestOptions = this.generateBaseRequestOptions(route, options, { mysubmit: 'Replace', - archive: readStream + archive: readStream, + ...(options.appType ? { 'app_type': options.appType } : {}) }); //attach the remotedebug flag if configured @@ -684,12 +686,50 @@ export class RokuDeploy { return this.getToFile(requestOptions, pkgFilePath); } + /** + * Set the `User-Agent` header if missing from the request params, ensuring it's included in all requests made by roku-deploy + * @param params + * @returns + */ + private setUserAgentIfMissing(params: requestType.OptionsWithUrl) { + if (!params) { + params = {} as requestType.OptionsWithUrl; + } + if (!params.headers) { + params.headers = {}; + } + if (!params.headers['User-Agent']) { + params.headers['User-Agent'] = this.getUserAgent(); + } + return params; + } + + /** + * Get the user-agent string used for HTTP requests sent by this package + * @returns + */ + private getUserAgent() { + try { + if (this._packageVersion === undefined) { + this._packageVersion = _fsExtra.readJsonSync(`${__dirname}/../package.json`).version; + } + } catch (e) { + this._packageVersion = null; + } + return `roku-deploy/${this._packageVersion ?? 'unknown'}`; + } + + private _packageVersion: string; + /** * Centralized function for handling POST http requests * @param params */ - private async doPostRequest(params: any, verify = true) { + private async doPostRequest(params: requestType.OptionsWithUrl, verify = true) { let results: { response: any; body: any } = await new Promise((resolve, reject) => { + + this.setUserAgentIfMissing(params); + request.post(params, (err, resp, body) => { if (err) { return reject(err); @@ -709,6 +749,9 @@ export class RokuDeploy { */ private async doGetRequest(params: requestType.OptionsWithUrl) { let results: { response: any; body: any } = await new Promise((resolve, reject) => { + + this.setUserAgentIfMissing(params); + request.get(params, (err, resp, body) => { if (err) { return reject(err); @@ -826,6 +869,30 @@ export class RokuDeploy { return result; } + /** + * Parse out the list of packages that are currently installed on the device by looking for the JSON in the response body + * @param body + * @returns + */ + private getPackagesFromResponseBody(body: string): RokuPackage[] { + let jsonParseRegex = /JSON\.parse\(('.+')\);/igm; + let jsonMatch: RegExpExecArray; + + while ((jsonMatch = jsonParseRegex.exec(body))) { + let [, jsonString] = jsonMatch; + let jsonObject = parseJsonc(jsonString); + if (typeof jsonObject === 'object' && !Array.isArray(jsonObject) && jsonObject !== null) { + let packages = jsonObject.packages; + + if (!Array.isArray(packages)) { + continue; + } + return packages; + } + } + return []; + } + /** * Create a zip of the project, and then publish to the target Roku device * @param options @@ -859,6 +926,53 @@ export class RokuDeploy { return this.doPostRequest(deleteOptions); } + /** + * Delete the component library with the specified filename from the device + */ + public async deleteComponentLibrary(options?: { host: string; password: string; fileName: string; username?: string }) { + options = this.getOptions(options) as any; + + let deleteOptions = this.generateBaseRequestOptions('plugin_install', options); + deleteOptions.formData = { + mysubmit: 'Delete', + 'app_type': 'dcl', + fileName: options.fileName + }; + deleteOptions.qs ??= {}; + // eslint-disable-next-line camelcase + deleteOptions.qs.dcl_enabled = '1'; + await this.doPostRequest(deleteOptions); + } + + /** + * Delete all component libraries from the device + */ + public async deleteAllComponentLibraries(options: { host: string; password: string; username?: string }) { + const packages = await this.getInstalledPackages(options); + for (const pkg of packages) { + if (pkg.appType === 'dcl') { + await this.deleteComponentLibrary({ + ...options, + fileName: pkg.archiveFileName + }); + } + } + } + + /** + * Fetch the full list of installed packages from the device. Useful for finding the file names of installed component libraries or the dev channel. + */ + private async getInstalledPackages(options: { host: string; password: string; username?: string }): Promise { + options = this.getOptions(options) as any; + let deleteOptions = this.generateBaseRequestOptions('plugin_install', options); + deleteOptions.qs ??= {}; + // eslint-disable-next-line camelcase + deleteOptions.qs.dcl_enabled = '1'; + const result = await this.doGetRequest(deleteOptions); + const packages = this.getPackagesFromResponseBody(result.body); + return packages; + } + /** * Gets a screenshot from the device. A side-loaded channel must be running or an error will be thrown. */ @@ -1082,13 +1196,18 @@ export class RokuDeploy { const url = `http://${options.host}:${options.remotePort}/query/device-info`; - let response = await this.doGetRequest({ - url: url, - timeout: options.timeout, - headers: { - 'User-Agent': 'https://github.com/RokuCommunity/roku-deploy' + let response; + try { + response = await this.doGetRequest({ + url: url, + timeout: options.timeout + }); + } catch (e) { + if ((e as any)?.results?.response?.headers?.server?.includes('Roku')) { + throw new errors.EcpNetworkAccessModeDisabledError(`Unable to access device-info because ecp-setting-mode is 'disabled'`, response); } - }); + throw e; + } try { const parsedContent = await xml2js.parseStringPromise(response.body, { explicitArray: false @@ -1112,6 +1231,30 @@ export class RokuDeploy { } } + /** + * Get the External Control Protocol (ECP) setting mode of the device. This determines whether + * the device accepts remote control commands via the ECP API. + * + * @param options - Configuration options including host, remotePort, timeout, etc. + * @returns The ECP setting mode: + * - 'enabled': fully enabled and accepting commands + * - 'disabled': ECP is disabled (device may still be reachable but ECP commands won't work) + * - 'limited': Restricted functionality, text and movement commands only + * - 'permissive': Full access for internal networks + */ + public async getEcpNetworkAccessMode(options: GetDeviceInfoOptions): Promise { + try { + const deviceInfo = await this.getDeviceInfo(options); + return deviceInfo.ecpSettingMode; + } catch (e) { + if ((e as any)?.results?.response?.headers?.server?.includes('Roku')) { + return 'disabled'; + } + throw new errors.UnknownDeviceResponseError('Could not retrieve device ECP setting'); + } + + } + /** * Normalize a deviceInfo field value. This includes things like converting boolean strings to booleans, number strings to numbers, * decoding HtmlEntities, etc. @@ -1211,7 +1354,7 @@ export class RokuDeploy { if (ext === '.jpg' || ext === '.png' || ext === '.jpeg') { compression = 'STORE'; } - zip.file(file.dest.replace(/[\\/]/g, '/'), data, { + zip.file(file.dest.replace(/[\\/]/g, '/'), data as any, { compression: compression }); }); @@ -1222,6 +1365,48 @@ export class RokuDeploy { const content = await zip.generateAsync({ type: 'nodebuffer', compressionOptions: { level: 2 } }); return this.fsExtra.outputFile(zipFilePath, content); } + + public async rebootDevice(options: RokuDeployOptions) { + options = this.getOptions(options); + + // Get device info to check software version + const deviceInfo = await this.getDeviceInfo(options as any); + const softwareVersion = deviceInfo['software-version']; + + // Check if device version is at least 15.0.4 + if (!softwareVersion || semver.lt(semver.coerce(softwareVersion), '15.0.4')) { + throw new errors.UnsupportedFirmwareVersionError(`Device software version ${softwareVersion} is below the minimum required version 15.0.4 for reboot operation`); + } + + return this.doPostRequest({ + ...this.generateBaseRequestOptions('plugin_swup', options), + formData: { + mysubmit: 'Reboot', + archive: '' + } + }); + } + + public async checkForUpdate(options: RokuDeployOptions) { + options = this.getOptions(options); + + // Get device info to check software version + const deviceInfo = await this.getDeviceInfo(options as any); + const softwareVersion = deviceInfo['software-version']; + + // Check if device version is at least 15.0.4 + if (!softwareVersion || semver.lt(semver.coerce(softwareVersion), '15.0.4')) { + throw new errors.UnsupportedFirmwareVersionError(`Device software version ${softwareVersion} is below the minimum required version 15.0.4 for check update operation`); + } + + return this.doPostRequest({ + ...this.generateBaseRequestOptions('plugin_swup', options), + formData: { + mysubmit: 'CheckUpdate', + archive: '' + } + }); + } } export interface ManifestData { @@ -1262,6 +1447,17 @@ export interface RokuMessages { successes: string[]; } +export interface RokuPackage { + appType: 'channel' | 'dcl'; + archiveFileName: string; + fileType: string; + id: number; + location: string; + md5: string; + pkgPath: string; + size: string; +} + enum RokuMessageType { success = 'success', info = 'info', @@ -1324,3 +1520,5 @@ export interface GetDeviceInfoOptions { */ enhance?: boolean; } + +export type EcpNetworkAccessMode = 'enabled' | 'disabled' | 'limited' | 'permissive'; diff --git a/src/RokuDeployOptions.ts b/src/RokuDeployOptions.ts index 8b44f99..a6fc07f 100644 --- a/src/RokuDeployOptions.ts +++ b/src/RokuDeployOptions.ts @@ -24,6 +24,12 @@ export interface RokuDeployOptions { */ rootDir?: string; + /** + * What type of app is being sideloaded? `'channel'` is for applications, and `'dcl'` is for installing component libraries on device for use in channels. + * @default 'channel' + */ + appType?: 'channel' | 'dcl'; + /** * An array of source file paths, source file globs, or {src,dest} objects indicating * where the source files are and where they should be placed