├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ ├── create-package.yml │ ├── create-vsix.yml │ ├── initialize-release.yml │ ├── make-release-artifacts.yml │ └── publish-release.yml ├── .gitignore ├── .npmignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── DeviceInfo.ts ├── Errors.ts ├── Logger.spec.ts ├── Logger.ts ├── RokuDeploy.spec.ts ├── RokuDeploy.ts ├── RokuDeployOptions.ts ├── Stopwatch.spec.ts ├── Stopwatch.ts ├── cli.ts ├── device.spec.ts ├── index.ts ├── testUtils.spec.ts ├── util.spec.ts └── util.ts ├── testSignedPackage.pkg └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | env: { 5 | node: true, 6 | mocha: true, 7 | es6: true 8 | }, 9 | parserOptions: { 10 | project: './tsconfig.json' 11 | }, 12 | plugins: [ 13 | '@typescript-eslint' 14 | ], 15 | extends: [ 16 | 'eslint:all', 17 | 'plugin:@typescript-eslint/all' 18 | ], 19 | rules: { 20 | '@typescript-eslint/array-type': 'off', 21 | '@typescript-eslint/consistent-type-assertions': 'off', 22 | '@typescript-eslint/explicit-function-return-type': 'off', 23 | '@typescript-eslint/explicit-member-accessibility': 'off', 24 | '@typescript-eslint/explicit-module-boundary-types': 'off', 25 | '@typescript-eslint/init-declarations': 'off', 26 | '@typescript-eslint/member-ordering': 'off', 27 | "@typescript-eslint/naming-convention": 'off', 28 | '@typescript-eslint/no-base-to-string': 'off', 29 | '@typescript-eslint/no-confusing-void-expression': 'off', 30 | '@typescript-eslint/no-dynamic-delete': 'off', 31 | '@typescript-eslint/no-empty-function': 'off', 32 | '@typescript-eslint/no-explicit-any': 'off', 33 | '@typescript-eslint/no-extra-parens': 'off', 34 | '@typescript-eslint/no-floating-promises': 'error', 35 | '@typescript-eslint/no-invalid-void-type': 'off', 36 | '@typescript-eslint/no-magic-numbers': 'off', 37 | '@typescript-eslint/no-parameter-properties': 'off', 38 | '@typescript-eslint/no-this-alias': 'off', 39 | '@typescript-eslint/no-type-alias': 'off', 40 | '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', 41 | '@typescript-eslint/no-unnecessary-condition': 'off', 42 | '@typescript-eslint/no-unsafe-argument': 'off', 43 | '@typescript-eslint/no-unsafe-assignment': 'off', 44 | '@typescript-eslint/no-unsafe-call': 'off', 45 | '@typescript-eslint/no-unsafe-member-access': 'off', 46 | '@typescript-eslint/no-unsafe-return': 'off', 47 | '@typescript-eslint/no-unused-vars': 'off', 48 | '@typescript-eslint/no-unused-vars-experimental': 'off', 49 | '@typescript-eslint/no-use-before-define': 'off', 50 | '@typescript-eslint/object-curly-spacing': [ 51 | 'error', 52 | 'always' 53 | ], 54 | '@typescript-eslint/prefer-readonly': 'off', 55 | '@typescript-eslint/prefer-readonly-parameter-types': 'off', 56 | '@typescript-eslint/promise-function-async': 'off', 57 | '@typescript-eslint/quotes': [ 58 | 'error', 59 | 'single', 60 | { 61 | 'allowTemplateLiterals': true 62 | } 63 | ], 64 | '@typescript-eslint/require-array-sort-compare': 'off', 65 | '@typescript-eslint/restrict-plus-operands': 'off', 66 | '@typescript-eslint/restrict-template-expressions': 'off', 67 | '@typescript-eslint/sort-type-union-intersection-members': 'off', 68 | '@typescript-eslint/space-before-function-paren': 'off', 69 | '@typescript-eslint/strict-boolean-expressions': 'off', 70 | '@typescript-eslint/typedef': 'off', 71 | '@typescript-eslint/unbound-method': 'off', 72 | '@typescript-eslint/unified-signatures': 'off', 73 | 'array-bracket-newline': 'off', 74 | 'array-element-newline': 'off', 75 | 'array-type': 'off', 76 | 'arrow-body-style': 'off', 77 | 'arrow-parens': 'off', 78 | 'callback-return': 'off', 79 | 'capitalized-comments': 'off', 80 | 'class-methods-use-this': 'off', 81 | 'complexity': 'off', 82 | 'consistent-return': 'off', 83 | 'consistent-this': 'off', 84 | 'curly': 'error', 85 | 'default-case': 'off', 86 | 'dot-location': 'off', 87 | 'func-style': 'off', 88 | 'function-call-argument-newline': 'off', 89 | 'function-paren-newline': 'off', 90 | 'guard-for-in': 'off', 91 | 'id-length': 'off', 92 | 'indent': 'off', 93 | 'init-declarations': 'off', 94 | 'line-comment-position': 'off', 95 | 'linebreak-style': 'off', 96 | 'lines-around-comment': 'off', 97 | 'lines-between-class-members': 'off', 98 | 'max-classes-per-file': 'off', 99 | 'max-depth': 'off', 100 | 'max-len': 'off', 101 | 'max-lines': 'off', 102 | 'max-lines-per-function': 'off', 103 | 'max-params': 'off', 104 | 'max-statements': 'off', 105 | 'multiline-comment-style': 'off', 106 | 'multiline-ternary': 'off', 107 | 'new-cap': 'off', 108 | 'newline-per-chained-call': 'off', 109 | 'no-await-in-loop': 'off', 110 | 'no-case-declarations': 'off', 111 | 'no-constant-condition': 'off', 112 | 'no-console': 'off', 113 | 'no-continue': 'off', 114 | 'no-else-return': 'off', 115 | 'no-empty': 'off', 116 | 'no-implicit-coercion': 'off', 117 | 'no-inline-comments': 'off', 118 | 'no-invalid-this': 'off', 119 | 'no-labels': 'off', 120 | 'no-negated-condition': 'off', 121 | 'no-param-reassign': 'off', 122 | 'no-plusplus': 'off', 123 | 'no-process-exit': 'off', 124 | 'no-prototype-builtins': 'off', 125 | 'no-shadow': 'off', 126 | 'no-sync': 'off', 127 | 'no-ternary': 'off', 128 | 'no-undefined': 'off', 129 | 'no-underscore-dangle': 'off', 130 | 'no-unneeded-ternary': 'off', 131 | 'no-useless-escape': 'off', 132 | 'no-void': 'off', 133 | 'no-warning-comments': 'off', 134 | 'object-curly-spacing': 'off', 135 | 'object-property-newline': 'off', 136 | 'object-shorthand': [ 137 | 'error', 138 | 'never' 139 | ], 140 | 'one-var': [ 141 | 'error', 142 | 'never' 143 | ], 144 | 'padded-blocks': 'off', 145 | 'prefer-const': 'off', 146 | 'prefer-destructuring': 'off', 147 | 'prefer-named-capture-group': 'off', 148 | 'prefer-template': 'off', 149 | 'quote-props': 'off', 150 | 'radix': 'off', 151 | 'require-atomic-updates': 'off', 152 | 'require-unicode-regexp': 'off', 153 | 'sort-imports': 'off', 154 | 'sort-keys': 'off', 155 | 'spaced-comment': 'off', 156 | 'vars-on-top': 'off', 157 | 'wrap-regex': 'off' 158 | }, 159 | overrides: [ 160 | //disable certain rules for tests 161 | { 162 | files: ['*.spec.ts'], 163 | rules: { 164 | '@typescript-eslint/dot-notation': 'off', 165 | '@typescript-eslint/no-invalid-this': 'off', 166 | '@typescript-eslint/no-unsafe-call': 'off', 167 | '@typescript-eslint/no-unsafe-member-access': 'off', 168 | '@typescript-eslint/no-unsafe-return': 'off', 169 | '@typescript-eslint/no-unused-expressions': 'off', 170 | '@typescript-eslint/no-unused-vars': 'off', 171 | '@typescript-eslint/no-unused-vars-experimental': 'off', 172 | '@typescript-eslint/no-unsafe-argument': 'off', 173 | 'camelcase': 'off', 174 | 'dot-notation': 'off', 175 | 'new-cap': 'off', 176 | 'no-shadow': 'off' 177 | } 178 | } 179 | ], 180 | ignorePatterns: ['types'] 181 | }; 182 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | tags: 7 | - v* 8 | pull_request: 9 | 10 | jobs: 11 | ci: 12 | runs-on: ${{ matrix.os }} 13 | env: 14 | #hardcode the coveralls token...it's not overly important to protect, and github actions won't allow forks to work with coveralls otherwise 15 | COVERALLS_REPO_TOKEN: "HtDqX6EhvX1rnSjcyezbUWczuR6FZ6eaV" 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | steps: 20 | - uses: actions/checkout@master 21 | - uses: actions/setup-node@master 22 | with: 23 | node-version: "14.18.1" 24 | architecture: 'x64' # fix for macos-latest 25 | - run: cd src && cd .. #trying to fix "ENOENT: no such file or directory, uv_cwd" error 26 | - run: npm ci 27 | - run: npm run build 28 | - run: npm run lint 29 | - run: npm run test 30 | - run: npm run publish-coverage 31 | -------------------------------------------------------------------------------- /.github/workflows/create-package.yml: -------------------------------------------------------------------------------- 1 | name: create-package 2 | on: 3 | pull_request: 4 | types: [labeled, unlabeled, synchronize] 5 | jobs: 6 | create-package: 7 | runs-on: ubuntu-latest 8 | if: contains(github.event.pull_request.labels.*.name, 'create-package') 9 | env: 10 | GH_TOKEN: ${{ github.token }} 11 | steps: 12 | - uses: actions/checkout@master 13 | - uses: actions/setup-node@master 14 | with: 15 | node-version: "14.19.0" 16 | # Get a bot token so the bot's name shows up on all our actions 17 | - name: Get Token From roku-ci-token Application 18 | uses: tibdex/github-app-token@v1 19 | id: generate-token 20 | with: 21 | app_id: ${{ secrets.BOT_APP_ID }} 22 | private_key: ${{ secrets.BOT_PRIVATE_KEY }} 23 | - run: echo "TOKEN=${{ steps.generate-token.outputs.token }}" >> $GITHUB_ENV 24 | - name: Compute variables 25 | run: | 26 | CURRENT_VERSION=$(grep -o '\"version\": *\"[^\"]*\"' package.json | awk -F'\"' '{print $4}') 27 | SANITIZED_BRANCH_NAME=$(echo "$GITHUB_HEAD_REF" | sed 's/[^0-9a-zA-Z-]/-/g') 28 | BUILD_VERSION="$CURRENT_VERSION-$SANITIZED_BRANCH_NAME.$(date +%Y%m%d%H%M%S)" 29 | NPM_PACKAGE_NAME=$(grep -o '\"name\": *\"[^\"]*\"' package.json | awk -F'\"' '{print $4}') 30 | ARTIFACT_NAME=$(echo "$NPM_PACKAGE_NAME-$BUILD_VERSION.tgz" | tr '/' '-') 31 | ARTIFACT_URL="${{ github.server_url }}/${{ github.repository }}/releases/download/v0.0.0-packages/${ARTIFACT_NAME}" 32 | 33 | echo "BUILD_VERSION=$BUILD_VERSION" >> $GITHUB_ENV 34 | echo "ARTIFACT_URL=$ARTIFACT_URL" >> $GITHUB_ENV 35 | 36 | - run: npm ci 37 | - run: npm version "$BUILD_VERSION" --no-git-tag-version 38 | - run: npm pack 39 | 40 | # create the release if not exist 41 | - run: gh release create v0.0.0-packages --latest=false --prerelease --notes "catchall release for temp packages" -R ${{ github.repository }} 42 | continue-on-error: true 43 | 44 | # upload this artifact to the "packages" github release 45 | - run: gh release upload v0.0.0-packages *.tgz -R ${{ github.repository }} 46 | 47 | - name: Fetch build artifact 48 | uses: actions/github-script@v7 49 | with: 50 | github-token: ${{ env.TOKEN }} 51 | script: | 52 | return github.rest.issues.createComment({ 53 | issue_number: context.issue.number, 54 | owner: context.repo.owner, 55 | repo: context.repo.repo, 56 | body: "Hey there! I just built a new temporary npm package based on ${{ github.event.pull_request.head.sha }}. You can download it [here](${{ env.ARTIFACT_URL }}) or install it by running the following command: \n```bash\nnpm install ${{ env.ARTIFACT_URL }}\n```" 57 | }); 58 | -------------------------------------------------------------------------------- /.github/workflows/create-vsix.yml: -------------------------------------------------------------------------------- 1 | name: create-vsix 2 | on: 3 | pull_request: 4 | types: [labeled, unlabeled, synchronize] 5 | jobs: 6 | create-vsix: 7 | runs-on: ubuntu-latest 8 | if: contains(github.event.pull_request.labels.*.name, 'create-vsix') 9 | steps: 10 | # Get a bot token so the bot's name shows up on all our actions 11 | - name: Get Token From roku-ci-token Application 12 | uses: tibdex/github-app-token@v1 13 | id: generate-token 14 | with: 15 | app_id: ${{ secrets.BOT_APP_ID }} 16 | private_key: ${{ secrets.BOT_PRIVATE_KEY }} 17 | - name: Set New GitHub Token 18 | run: echo "TOKEN=${{ steps.generate-token.outputs.token }}" >> $GITHUB_ENV 19 | #trigger the build on vscode-brightscript-language 20 | - name: Build vsix for branch "${{ github.head_ref}}" 21 | uses: aurelien-baudet/workflow-dispatch@v2.1.1 22 | id: create-vsix 23 | with: 24 | workflow: create-vsix 25 | ref: master 26 | wait-for-completion: true 27 | display-workflow-run-url: true 28 | repo: rokucommunity/vscode-brightscript-language 29 | token: ${{ env.TOKEN }} 30 | inputs: '{ "branch": "${{ github.head_ref }}"}' 31 | #add a comment on the PR 32 | - name: Add comment to PR 33 | uses: actions/github-script@v5 34 | with: 35 | github-token: ${{ env.TOKEN }} 36 | script: | 37 | github.rest.issues.createComment({ 38 | issue_number: context.issue.number, 39 | owner: context.repo.owner, 40 | repo: context.repo.repo, 41 | body: 'Hey there! I just built a new version of the vscode extension based on ${{ github.event.pull_request.head.sha }}. You can download the .vsix [here](${{steps.create-vsix.outputs.workflow-url}}) and then follow [these installation instructions](https://github.com/rokucommunity/vscode-brightscript-language#pre-release-versions).' 42 | }) 43 | -------------------------------------------------------------------------------- /.github/workflows/initialize-release.yml: -------------------------------------------------------------------------------- 1 | name: Initialize Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | branch: 7 | type: string 8 | description: "Create release from this branch:" 9 | default: "master" 10 | required: true 11 | releaseType: 12 | type: choice 13 | description: "Release type:" 14 | required: true 15 | default: "patch" 16 | options: 17 | - major 18 | - minor 19 | - patch 20 | - prerelease 21 | customVersion: 22 | type: string 23 | description: "Version override: (ignore release type)" 24 | default: "" 25 | required: false 26 | installDependencies: 27 | type: boolean 28 | description: "Install latest RokuCommunity dependencies" 29 | required: true 30 | default: true 31 | 32 | jobs: 33 | run: 34 | uses: rokucommunity/workflows/.github/workflows/initialize-release.yml@master 35 | with: 36 | branch: ${{ github.event.inputs.branch }} 37 | releaseType: ${{ github.event.inputs.releaseType }} 38 | customVersion: ${{ github.event.inputs.customVersion }} 39 | installDependencies: ${{ github.event.inputs.installDependencies }} 40 | secrets: inherit 41 | -------------------------------------------------------------------------------- /.github/workflows/make-release-artifacts.yml: -------------------------------------------------------------------------------- 1 | name: Make Release Artifacts 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - reopened 7 | - opened 8 | - synchronize 9 | 10 | jobs: 11 | run: 12 | if: startsWith( github.head_ref, 'release/') 13 | uses: rokucommunity/workflows/.github/workflows/make-release-artifacts.yml@master 14 | with: 15 | branch: ${{ github.event.pull_request.head.ref }} 16 | node-version: "16.20.2" 17 | artifact-paths: "./*.tgz" # "*.vsix" 18 | secrets: inherit 19 | 20 | success-or-skip: 21 | if: always() 22 | needs: [run] 23 | runs-on: ubuntu-latest 24 | steps: 25 | - run: if [ "${{ needs.run.result }}" = "failure" ]; then exit 1; fi 26 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | paths: 8 | - 'package.json' 9 | - 'package-lock.json' 10 | 11 | jobs: 12 | run: 13 | if: startsWith( github.head_ref, 'release/') && (github.event.pull_request.merged == true) 14 | uses: rokucommunity/workflows/.github/workflows/publish-release.yml@master 15 | with: 16 | release-type: "npm" # "vsce" 17 | ref: ${{ github.event.pull_request.merge_commit_sha }} 18 | secrets: inherit 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .DS_Store 5 | .tmp 6 | .roku-deploy-staging 7 | coverage 8 | .nyc_output 9 | *.zip -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .git 3 | .github 4 | .nyc_output 5 | coverage 6 | dist/*.spec.* 7 | src 8 | testProject 9 | .gitignore 10 | .travis.yml 11 | rokudeploy.json 12 | *.pkg 13 | tsconfig.json 14 | .eslintrc.js 15 | *.tgz 16 | *.map 17 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug RokuDeploy.spec.ts Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 9 | "args": [ 10 | "-r", 11 | "ts-node/register", 12 | "-r", 13 | "source-map-support/register", 14 | "./src/RokuDeploy.spec.ts", 15 | "./src/util.spec.ts", 16 | "--timeout", 17 | "987654" 18 | ], 19 | "env": { 20 | "TS_NODE_TRANSPILE_ONLY": "TRUE" 21 | }, 22 | "cwd": "${workspaceRoot}", 23 | "protocol": "inspector", 24 | "internalConsoleOptions": "openOnSessionStart" 25 | }, 26 | { 27 | "name": "Debug device.spec.ts Tests", 28 | "type": "node", 29 | "request": "launch", 30 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 31 | "args": [ 32 | "-r", 33 | "ts-node/register", 34 | "-r", 35 | "source-map-support/register", 36 | "./src/device.spec.ts", 37 | "--timeout", 38 | "987654" 39 | ], 40 | "cwd": "${workspaceRoot}", 41 | "protocol": "inspector", 42 | "internalConsoleOptions": "openOnSessionStart" 43 | }, 44 | { 45 | "name": "Debug All Tests", 46 | "type": "node", 47 | "request": "launch", 48 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 49 | "args": [ 50 | "-r", 51 | "ts-node/register", 52 | "-r", 53 | "source-map-support/register", 54 | "./src/**/*.spec.ts", 55 | "--timeout", 56 | "987654" 57 | ], 58 | "cwd": "${workspaceRoot}", 59 | "protocol": "inspector", 60 | "internalConsoleOptions": "openOnSessionStart" 61 | } 62 | ] 63 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/node_modules": true, 9 | "**/coverage": true, 10 | "**.nyc_output": true 11 | }, 12 | "search.exclude": { 13 | "**/node_modules": true, 14 | "**/bower_components": true, 15 | "**/*.code-search": true, 16 | "**/dist": true 17 | } 18 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "shell", 6 | "label": "test:nocover", 7 | "command": "npm", 8 | "group": { 9 | "kind": "test", 10 | "isDefault": true 11 | }, 12 | "presentation": { 13 | "echo": true, 14 | "reveal": "always", 15 | "focus": true, 16 | "panel": "shared", 17 | "showReuseMessage": true, 18 | "clear": true 19 | }, 20 | "args": [ 21 | "run", 22 | "test:nocover", 23 | "--silent" 24 | ], 25 | "problemMatcher": [] 26 | }, 27 | { 28 | "type": "npm", 29 | "label": "build", 30 | "group": { 31 | "kind": "build", 32 | "isDefault": true 33 | }, 34 | "presentation": { 35 | "echo": true, 36 | "reveal": "always", 37 | "focus": true, 38 | "panel": "shared", 39 | "showReuseMessage": true, 40 | "clear": true 41 | }, 42 | "script": "build", 43 | "problemMatcher": [ 44 | "$eslint-compact", 45 | "$tsc" 46 | ] 47 | }, { 48 | "type": "npm", 49 | "label": "watch", 50 | "presentation": { 51 | "group": "watch" 52 | }, 53 | "script": "watch", 54 | "problemMatcher": "$tsc-watch", 55 | "isBackground": true 56 | } 57 | ] 58 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | 8 | 9 | ## [3.12.6](https://github.com/rokucommunity/roku-deploy/compare/3.12.5...v3.12.6) - 2025-06-03 10 | ### Changed 11 | - chore: upgrade to the `undent` package instead of `dedent` ([#192](https://github.com/rokucommunity/roku-deploy/pull/196)) 12 | 13 | 14 | 15 | ## [3.12.5](https://github.com/rokucommunity/roku-deploy/compare/3.12.4...v3.12.5) - 2025-05-05 16 | ### Changed 17 | - (chore) Add missing template workflows for shared ci ([#189](https://github.com/rokucommunity/roku-deploy/pull/189)) 18 | - (chore) Shared CI Support Prerelease ([#185](https://github.com/rokucommunity/roku-deploy/pull/185)) 19 | 20 | 21 | 22 | ## [3.12.4](https://github.com/rokucommunity/roku-deploy/compare/v3.12.3...v3.12.4) - 2025-01-22 23 | ### Fixed 24 | - fixed an issue with `577` error codes ([#182](https://github.com/rokucommunity/roku-deploy/pull/182)) 25 | 26 | 27 | 28 | ## [3.12.3](https://github.com/rokucommunity/roku-deploy/compare/v3.12.2...v3.12.3) - 2024-12-06 29 | ### Changed 30 | - Identify when a 577 error is thrown, send a new developer friendly message ([#180](https://github.com/rokucommunity/roku-deploy/pull/180)) 31 | ### Fixed 32 | - issues with detecting "check for updates required" ([#181](https://github.com/rokucommunity/roku-deploy/pull/181)) 33 | 34 | 35 | 36 | ## [3.12.2](https://github.com/rokucommunity/roku-deploy/compare/v3.12.1...v3.12.2) - 2024-10-18 37 | ### Fixed 38 | - updated regex to find a signed package on `/plugin_package` page ([#176](https://github.com/rokucommunity/roku-deploy/pull/176)) 39 | 40 | 41 | 42 | ## [3.12.1](https://github.com/rokucommunity/roku-deploy/compare/v3.12.0...v3.12.1) - 2024-07-19 43 | ### Changed 44 | - fix-node14 CI/CD issues ([#165](https://github.com/rokucommunity/roku-deploy/pull/165)) 45 | ### Fixed 46 | - bug with absolute paths and `getDestPath` ([#171](https://github.com/rokucommunity/roku-deploy/pull/171)) 47 | 48 | 49 | 50 | ## [3.12.0](https://github.com/rokucommunity/roku-deploy/compare/v3.11.3...v3.12.0) - 2024-03-01 51 | ### Changed 52 | - Support overriding various package upload form data ([#136](https://github.com/rokucommunity/roku-deploy/pull/136)) 53 | 54 | 55 | 56 | ## [3.11.3](https://github.com/rokucommunity/roku-deploy/compare/v3.11.2...v3.11.3) - 2024-02-29 57 | ### Fixed 58 | - Retry the convertToSquahsfs request to mitigate the HPE_INVALID_CONSTANT error ([#145](https://github.com/rokucommunity/roku-deploy/pull/145)) 59 | 60 | 61 | 62 | ## [3.11.2](https://github.com/rokucommunity/roku-deploy/compare/v3.11.1...v3.11.2) - 2023-12-20 63 | ### Changed 64 | - Update wrong host password error message ([#134](https://github.com/rokucommunity/roku-deploy/pull/134)) 65 | 66 | 67 | 68 | ## [3.11.1](https://github.com/rokucommunity/roku-deploy/compare/v3.11.0...v3.11.1) - 2023-11-30 69 | ### Fixed 70 | - Wait for file stream to close before resolving promise ([#133](https://github.com/rokucommunity/roku-deploy/pull/133)) 71 | 72 | 73 | 74 | ## [3.11.0](https://github.com/rokucommunity/roku-deploy/compare/v3.10.5...v3.11.0) - 2023-11-28 75 | ### Changed 76 | - Add public function `normalizeDeviceInfoFieldValue` to normalize device-info field values ([#129](https://github.com/rokucommunity/roku-deploy/pull/129)) 77 | 78 | 79 | 80 | ## [3.10.5](https://github.com/rokucommunity/roku-deploy/compare/v3.10.4...v3.10.5) - 2023-11-14 81 | ### Changed 82 | - better device-info docs ([#128](https://github.com/rokucommunity/roku-deploy/pull/128)) 83 | - Better deploy error detection ([#127](https://github.com/rokucommunity/roku-deploy/pull/127)) 84 | 85 | 86 | 87 | ## [3.10.4](https://github.com/rokucommunity/roku-deploy/compare/v3.10.3...v3.10.4) - 2023-11-03 88 | ### Changed 89 | - Enhance getDeviceInfo() method to support camelCase and convert bool|number strings to their primitive types ([#120](https://github.com/rokucommunity/roku-deploy/pull/120)) 90 | 91 | 92 | 93 | ## [3.10.3](https://github.com/rokucommunity/roku-deploy/compare/v3.10.2...3.10.3) - 2023-07-22 94 | ### Changed 95 | - Bump word-wrap from 1.2.3 to 1.2.4 ([#117](https://github.com/rokucommunity/roku-deploy/pull/117)) 96 | 97 | 98 | 99 | ## [3.10.2](https://github.com/rokucommunity/roku-deploy/compare/v3.10.1...3.10.2) - 2023-05-10 100 | ### Changed 101 | - remove `request` in favor of `postman-request` to fix security issues 102 | - remove dev dependency `coveralls` in favor of `coveralls-next` to fix security issues 103 | ### Fixed 104 | - compatibility issues with Node.js v19 and above ([#115](https://github.com/rokucommunity/roku-deploy/pull/115)) 105 | - npm audit issues ([#116](https://github.com/rokucommunity/roku-deploy/pull/116)) 106 | 107 | 108 | 109 | ## [3.10.1](https://github.com/rokucommunity/roku-deploy/compare/v3.10.0...v3.10.1) - 2023-04-14 110 | ### Changed 111 | - Bump xml2js from 0.4.23 to 0.5.0 ([#112](https://github.com/rokucommunity/roku-deploy/pull/112)) 112 | - Fix build status badge ([ad2c9ec](https://github.com/rokucommunity/roku-deploy/commit/ad2c9ec)) 113 | 114 | 115 | 116 | ## [3.10.0](https://github.com/rokucommunity/roku-deploy/compare/v3.9.3...v3.10.0) - 2023-03-16 117 | ### Changed 118 | - Use micromatch instead of picomatch ([#109](https://github.com/rokucommunity/roku-deploy/pull/109)) 119 | 120 | 121 | 122 | ## [3.9.3](https://github.com/rokucommunity/roku-deploy/compare/v3.9.2...3.9.3) - 2023-01-12 123 | ### Changed 124 | - Bump minimatch from 3.0.4 to 3.1.2 ([#107](https://github.com/rokucommunity/roku-deploy/pull/107)) 125 | - Bump json5 from 2.2.0 to 2.2.3 ([#106](https://github.com/rokucommunity/roku-deploy/pull/106)) 126 | 127 | 128 | 129 | ## [3.9.2](https://github.com/rokucommunity/roku-deploy/compare/v3.9.1...3.9.2) - 2022-10-03 130 | ### Fixed 131 | - Replace minimatch with picomatch ([#101](https://github.com/rokucommunity/roku-deploy/pull/101)) 132 | 133 | 134 | 135 | ## [3.9.1](https://github.com/rokucommunity/roku-deploy/compare/v3.9.0...3.9.1) - 2022-09-19 136 | ### Fixed 137 | - Sync retainStagingFolder, stagingFolderPath with options, fixing a critical backwards compatibility bug ([#100](https://github.com/rokucommunity/roku-deploy/pull/100)) 138 | 139 | 140 | 141 | ## [3.9.0](https://github.com/rokucommunity/roku-deploy/compare/v3.8.1...3.9.0) - 2022-09-16 142 | ### Added 143 | - Add `stagingDir` and `retainStagingDir`. ([#99](https://github.com/rokucommunity/roku-deploy/pull/99)) 144 | ### Changed 145 | - deprecated `stagingFolderPath` and `retainStagingFolder. ([#99](https://github.com/rokucommunity/roku-deploy/pull/99)) 146 | 147 | 148 | 149 | ## [3.8.1](https://github.com/rokucommunity/roku-deploy/compare/v3.8.0...3.8.1) - 2022-09-02 150 | ### Changed 151 | - Bump moment from 2.29.2 to 2.29.4 ([#98](https://github.com/rokucommunity/roku-deploy/pull/98)) 152 | 153 | 154 | 155 | ## [3.8.0](https://github.com/rokucommunity/roku-deploy/compare/v3.7.1...3.8.0) - 2022-08-30 156 | ### Added 157 | - add support for `remotedebug_connect_early` form field ([#97](https://github.com/rokucommunity/roku-deploy/pull/97)) 158 | - Better compile error handling ([#96](https://github.com/rokucommunity/roku-deploy/pull/96)) 159 | 160 | 161 | 162 | ## [3.7.1](https://github.com/RokuCommunity/roku-deploy/compare/v3.7.0...v3.7.1) - 2022-06-08 163 | ### Fixed 164 | - make the json parser less sensitive to trailing commas ([#95](https://github.com/rokucommunity/roku-deploy/pull/95)) 165 | 166 | 167 | 168 | ## [3.7.0](https://github.com/RokuCommunity/roku-deploy/compare/v3.6.0...v3.7.0) - 2022-05-23 169 | ### Added 170 | - new `files` parameter to `zipFolder()` to allow including/excluding files when building the zip 171 | - new `rokuDeploy.takeScreenshot()` function ([#92](https://github.com/rokucommunity/roku-deploy/pull/92)) 172 | - export `rokuDeploy` const to improve the docs. Developers should switch to `import { rokuDeploy } from 'roku-deploy'` instead of `import * as rokuDeploy from 'roku-deploy'`. 173 | 174 | 175 | 176 | ## [3.6.0](https://github.com/RokuCommunity/roku-deploy/compare/v3.5.4...v3.6.0) - 2022-04-13 177 | ### Added 178 | - `deleteInstalledChannel` option to specify whether the previously installed dev channel will be deleted before installing the new one 179 | 180 | 181 | 182 | ## [3.5.4](https://github.com/RokuCommunity/roku-deploy/compare/v3.5.3...v3.5.4) - 2022-03-17 183 | ### Changed 184 | - use `fast-glob` instead of `glob` for globbing. ([#86](https://github.com/rokucommunity/roku-deploy/pull/86)) 185 | ### Fixed 186 | - significant performance issues during globbing. ([#86](https://github.com/rokucommunity/roku-deploy/pull/86)) 187 | 188 | 189 | 190 | ## [3.5.3](https://github.com/RokuCommunity/roku-deploy/compare/v3.5.2...v3.5.3) - 2022-02-16 191 | ### Fixed 192 | - removed `request` property from `RokuDeploy` class that was only there for unit testing, and was causing typescript issues in downstream dependencies. ([#84](https://github.com/rokucommunity/roku-deploy/pull/84)) 193 | 194 | 195 | 196 | ## [3.5.2](https://github.com/RokuCommunity/roku-deploy/compare/v3.5.1...v3.5.2) - 2021-11-02 197 | ### Fixed 198 | - bug introduced in v3.5.0 with `retrieveSignedPackage` that would produce an empty package. ([#82](https://github.com/rokucommunity/roku-deploy/pull/82)) 199 | 200 | 201 | 202 | ## [3.5.1](https://github.com/RokuCommunity/roku-deploy/compare/v3.5.0...v3.5.1) - 2021-11-02 203 | ### Fixed 204 | - bug introduced in v3.5.0 with `rekeyDevice` that would crash because the read stream was closed before the request got sent. ([#81](https://github.com/rokucommunity/roku-deploy/pull/81)) 205 | 206 | 207 | 208 | ## [3.5.0](https://github.com/RokuCommunity/roku-deploy/compare/v3.4.2...v3.5.0) - 2021-10-27 209 | ### Added 210 | - ability to use negated non-rootDir top-level patterns in the `files` array ([#78](https://github.com/rokucommunity/roku-deploy/pull/78)) 211 | 212 | 213 | 214 | ## [3.4.2](https://github.com/RokuCommunity/roku-deploy/compare/v3.4.1...v3.4.2) - 2021-09-17 215 | ### Fixed 216 | - Prevent deploy crashes when target Roku doesn't have an installed channel ([#65](https://github.com/rokucommunity/roku-deploy/pull/65)) 217 | - reduce npm package size by ignoring .tgz files during publishing (#d6d7c57)(https://github.com/rokucommunity/roku-deploy/commit/d6d7c5743383363d7e8db13c60b03d1df5d5563b) 218 | 219 | 220 | 221 | ## [3.4.1](https://github.com/RokuCommunity/roku-deploy/compare/v3.4.0...v3.4.1) - 2021-06-01 222 | ### Fixed 223 | - incorrect path separator issue on windows. 224 | - missing `chalk` prod dependency causing import issues 225 | 226 | 227 | 228 | ## [3.4.0](https://github.com/RokuCommunity/roku-deploy/compare/v3.3.0...v3.4.0) - 2021-05-28 229 | ### Added 230 | - `preFileZipCallback` parameter to `RokuDeploy.zipFolder` to allow per-file modifications before adding the file to the zip 231 | ### Changed 232 | - switch internal zip library to [jszip](https://www.npmjs.com/package/jszip) which seems to yield 75% faster zip times. 233 | 234 | 235 | 236 | ## [3.3.0](https://github.com/RokuCommunity/roku-deploy/compare/v3.2.4...v3.3.0) - 2021-02-05 237 | ### Added 238 | - support for `timeout` option to fail deploys after a certain amount of time 239 | 240 | 241 | 242 | ## [3.2.4](https://github.com/RokuCommunity/roku-deploy/compare/v3.2.3...v3.2.4) - 2021-01-08 243 | ### Fixed 244 | - don't fail deployment when home press command returns 202 http status code 245 | 246 | 247 | 248 | ## [3.2.3](https://github.com/RokuCommunity/roku-deploy/compare/v3.2.2...v3.2.3) - 2020-08-14 249 | ### Changed 250 | - throw exception during `copyToStaging` when rootDir does not exist 251 | - throw exception during `zipPackage` when `${stagingFolder}/manifest` does not exist 252 | 253 | 254 | ## [3.2.2](https://github.com/RokuCommunity/roku-deploy/compare/v3.2.1...v3.2.2) - 2020-07-14 255 | ### Fixed 256 | - bug when loading `stagingFolderPath` from `rokudeploy.json` or `bsconfig.json` that would cause an exception. 257 | 258 | 259 | 260 | ## [3.2.1](https://github.com/RokuCommunity/roku-deploy/compare/v3.2.0...v3.2.1) - 2020-07-07 261 | ### Changed 262 | - `rokudeploy.json` now supports jsonc (json with comments) 263 | ### Fixed 264 | - loading `bsconfig.json` file with comments would fail the entire roku-deploy process. 265 | 266 | 267 | 268 | ## [3.2.0](https://github.com/RokuCommunity/roku-deploy/compare/v3.1.1...v3.2.0) - 2020-07-06 269 | ### Added 270 | - support for loading `bsconfig.json` files. 271 | 272 | 273 | 274 | ## [3.1.1](https://github.com/RokuCommunity/roku-deploy/compare/v3.1.0...v3.1.1) - 2020-05-08 275 | ### Added 276 | - export `DefaultFilesArray` so other tools can use that as their defaults as well. 277 | 278 | 279 | 280 | ## [3.1.0](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.2...v3.1.0) - 2020-05-08 281 | ### Added 282 | - config setting `retainDeploymentArchive` which specifies if the zip should be deleted after a publish. 283 | 284 | 285 | 286 | ## [3.0.2](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.1...v3.0.2) - 2020-04-10 287 | ### Fixed 288 | - issue where `prepublishToStaging` wasn't recognizing nested files inside a symlinked folder. 289 | 290 | 291 | 292 | ## [3.0.1](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.0...v3.0.1) - 2020-04-03 293 | ### Changed 294 | - coerce `rootDir` to an absolute path in `rokuDeploy.getDestPath` and `rokuDeploy.getFilePaths`. 295 | 296 | 297 | 298 | ## [3.0.0](https://github.com/RokuCommunity/roku-deploy/compare/v2.7.0...v3.0.0) - 2020-03-23 299 | ### Added 300 | - all changes from v3.0.0-beta1-v3.0.0-beta.8 301 | 302 | 303 | 304 | ## [3.0.0-beta.8](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.0-beta.7...v3.0.0-beta.8) - 2020-03-06 305 | ### Added 306 | - all changes from 2.7.0 307 | 308 | 309 | 310 | ## [2.7.0](https://github.com/RokuCommunity/roku-deploy/compare/v2.6.1...v2.7.0) - 2020-03-06 311 | ### Added 312 | - support for `remoteDebug` property which enables the experimental remote debug protocol on newer versions of Roku hardware. See [this](https://developer.roku.com/en-ca/docs/developer-program/debugging/socket-based-debugger.md) for more information. 313 | 314 | 315 | ## [3.0.0-beta.7](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.0-beta.6...v3.0.0-beta.7) - 2020-01-10 316 | ### Fixed 317 | - bug during file copy that was not prepending `stagingFolderPath` to certain file operations. 318 | 319 | 320 | 321 | ## [3.0.0-beta.6](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.0-beta.5...v3.0.0-beta.6) - 2020-01-06 322 | ### Fixed 323 | - bug that was not discarding duplicate file entries targeting the same `dest` path. 324 | 325 | 326 | 327 | ## [3.0.0-beta.5](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.0-beta.4...v3.0.0-beta.5) - 2019-12-20 328 | ### Added 329 | - all changes from 2.6.1 330 | 331 | 332 | 333 | ## [3.0.0-beta.4](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.0-beta.3...v3.0.0-beta.4) - 2019-11-12 334 | ### Added 335 | - all changes from 2.6.0 336 | 337 | 338 | 339 | ## [3.0.0-beta.3](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.0-beta.2...v3.0.0-beta.3) - 2019-11-12 340 | ### Added 341 | - `RokuDeploy.getDestPath` function which returns the dest path for a full file path. Useful for figuring out where a file will be placed in the pkg. 342 | ### Changed 343 | - made `RokuDeploy.normalizeFilesArray` public 344 | - disallow using explicit folder paths in files array. You must use globs for folders. 345 | 346 | 347 | 348 | ## [3.0.0-beta.2](https://github.com/RokuCommunity/roku-deploy/compare/v3.0.0-beta.1...v3.0.0-beta.2) - 2019-10-23 349 | ### Changed 350 | - signature of `getFilePaths()` to no longer accept `stagingFolderPath` 351 | - `getFilePaths()` now returns `dest` file paths relative to pkg instead of absolute file paths. These paths do _not_ include a leading slash 352 | 353 | 354 | 355 | ## [3.0.0-beta.1](https://github.com/RokuCommunity/roku-deploy/compare/v2.5.0...v3.0.0-beta.1) - 2019-10-16 356 | ### Added 357 | - information in the readme about the `files` array 358 | - support for file overrides in the `files` array. This supports including the same file from A and B, and letting the final file override previous files. 359 | ### Changed 360 | - the files array is now a bit more strict, and has a more consistent approach. 361 | ## [2.6.1] - 2019-12-20 362 | ### Fixed 363 | - Throw better error message during publish when missing the zip file. 364 | 365 | 366 | 367 | ## [2.6.0](https://github.com/RokuCommunity/roku-deploy/compare/2.6.0-beta.0...v2.6.0) - 2019-12-04 368 | ### Added 369 | - `remotePort` and `packagePort` for customizing the ports used for network-related roku requests. Mainly useful for emulators or communicating with Rokus behind port-forwards. 370 | 371 | 372 | 373 | ## [2.6.0-beta.0](https://github.com/RokuCommunity/roku-deploy/compare/v2.5.0...v2.6.0-beta.0) - 2019-11-18 374 | ### Added 375 | - `remotePort` and `packagePort` for customizing the ports used for network-related roku requests. Mainly useful for emulators or communicating with Rokus behind port-forwards. 376 | 377 | 378 | 379 | ## [2.5.0](https://github.com/RokuCommunity/roku-deploy/compare/v2.4.1...v2.5.0) - 2019-10-05 380 | ### Added 381 | - `stagingFolderPath` option to allow overriding the location of the staging folder 382 | 383 | 384 | 385 | ## [2.4.1](https://github.com/RokuCommunity/roku-deploy/compare/v2.4.0...v2.4.1) - 2019-08-27 386 | ### Changed 387 | - updated new repository location (https://github.com/RokuCommunity/roku-deploy) 388 | 389 | 390 | 391 | ## [2.4.0](https://github.com/RokuCommunity/roku-deploy/compare/v2.3.0...v2.4.0) - 2019-08-26 392 | ### Added 393 | - `deleteInstalledChannel` method that will delete the installed channel on the remote Roku 394 | ### Changed 395 | - `deploy` now deletes any installed channel before publishing the new channel 396 | 397 | 398 | 399 | ## [2.3.0](https://github.com/RokuCommunity/roku-deploy/compare/v2.2.1...v2.3.0) - 2019-08-20 400 | ### Added 401 | - support for returning a promise in the `createPackage` `beforeZipCallback` parameter. 402 | 403 | 404 | 405 | ## [2.2.1](https://github.com/RokuCommunity/roku-deploy/compare/v2.2.0...v2.2.1) - 2019-08-07 406 | ### Fixed 407 | - colors starting with # symbol in manifest file that were being treated as comments. This removes the dependency on `ini` in favor of a local function. 408 | 409 | 410 | 411 | ## [2.2.0](https://github.com/RokuCommunity/roku-deploy/compare/v2.1.0...v2.2.0) - 2019-07-05 412 | ### Added 413 | - support for converting to squashfs 414 | ### Fixed 415 | - issue where manifest files with `bs_const` weren't being handled correctly 416 | 417 | 418 | 419 | ## [2.1.0](https://github.com/RokuCommunity/roku-deploy/compare/v2.1.0-beta1...v2.1.0) - 2019-05-14 420 | ### Added 421 | - rekeying capability 422 | 423 | 424 | 425 | ## [2.1.0-beta1](https://github.com/RokuCommunity/roku-deploy/compare/v2.0.0...v2.1.0-beta1) - 2019-02-15 426 | ### Added 427 | - Support for signed package creation 428 | - ability to register a callback function before the package is zipped. 429 | - `incrementBuildNumber` option 430 | ### Changed 431 | - Stop calling home button on deploy 432 | - `outFile` to be `baseName` so it can be used for both zip and pkg file names 433 | 434 | 435 | 436 | ## [2.0.0](https://github.com/RokuCommunity/roku-deploy/compare/v2.0.0-beta5...v2.0.0) - 2019-01-07 437 | ### Added 438 | - support for absolute file paths in the `files` property 439 | - dereference symlinks on file copy 440 | 441 | 442 | 443 | ## [2.0.0-beta5](https://github.com/RokuCommunity/roku-deploy/compare/v2.0.0-beta4...v2.0.0-beta5) - 2019-01-18 444 | ### Changed 445 | - Changed `normalizeFilesOption` to be sync instead of async, since it didn't need to be async. 446 | 447 | 448 | 449 | ## [2.0.0-beta4](https://github.com/RokuCommunity/roku-deploy/compare/v2.0.0-beta3...v2.0.0-beta4) - 2019-01-17 450 | ### Fixed 451 | - bug that wasn't using rootDir for glob matching 452 | 453 | 454 | 455 | ## [2.0.0-beta3](https://github.com/RokuCommunity/roku-deploy/compare/v2.0.0-beta2...v2.0.0-beta3) - 2019-01-17 456 | ### Changed 457 | - export the `getFilepaths` for use in external libraries 458 | 459 | 460 | 461 | ## [2.0.0-beta2](https://github.com/RokuCommunity/roku-deploy/compare/v2.0.0-beta1...v2.0.0-beta2) - 2019-01-15 462 | ### Changed 463 | - prevent empty directories from being created 464 | ### Fixed 465 | - bug in `src`/`dest` globs. 466 | - bug that wasn't copying folders properly 467 | 468 | 469 | 470 | ## [2.0.0-beta1](https://github.com/RokuCommunity/roku-deploy/compare/v1.0.0...v2.0.0-beta1) - 2019-01-07 471 | ### Changed 472 | - removed the requirement for manifest to be located at the top of `rootDir`. Instead, it is simply assumed to exist. 473 | ### Fixed 474 | - regression issue that prevented folder names from being used without globs 475 | 476 | 477 | 478 | ## [1.0.0](https://github.com/RokuCommunity/roku-deploy/compare/v0.2.1...v1.0.0) - 2018-12-18 479 | ### Added 480 | - support for negated globs 481 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Bronley Plumb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # roku-deploy 2 | 3 | Publish Roku projects to a Roku device by using Node.js. 4 | 5 | [![build status](https://img.shields.io/github/actions/workflow/status/rokucommunity/roku-deploy/build.yml?branch=master)](https://github.com/rokucommunity/roku-deploy/actions?query=branch%3Amaster+workflow%3Abuild) 6 | [![coverage status](https://img.shields.io/coveralls/github/rokucommunity/roku-deploy?logo=coveralls)](https://coveralls.io/github/rokucommunity/roku-deploy?branch=master) 7 | [![monthly downloads](https://img.shields.io/npm/dm/roku-deploy.svg?sanitize=true&logo=npm&logoColor=)](https://npmcharts.com/compare/roku-deploy?minimal=true) 8 | [![npm version](https://img.shields.io/npm/v/roku-deploy.svg?logo=npm)](https://www.npmjs.com/package/roku-deploy) 9 | [![license](https://img.shields.io/github/license/rokucommunity/roku-deploy.svg)](LICENSE) 10 | [![Slack](https://img.shields.io/badge/Slack-RokuCommunity-4A154B?logo=slack)](https://join.slack.com/t/rokudevelopers/shared_invite/zt-4vw7rg6v-NH46oY7hTktpRIBM_zGvwA) 11 | ## Installation 12 | 13 | npm install roku-deploy 14 | 15 | ## Requirements 16 | 17 | 1. Your project must be structured the way that Roku expects. The source files can be in a subdirectory (using the `rootDir` config option), but whever your roku files exist, they must align with the following folder structure: 18 | 19 | components/ 20 | images/ 21 | source/ 22 | manifest 23 | 24 | 2. You should create a rokudeploy.json file at the root of your project that contains all of the overrides to the default options. roku-deploy will auto-detect this file and use it when possible. (**note**: `rokudeploy.json` is jsonc, which means it supports comments). 25 | 26 | sample rokudeploy.json 27 | 28 | ```jsonc 29 | { 30 | "host": "192.168.1.101", 31 | "password": "securePassword" 32 | } 33 | ``` 34 | ## Usage 35 | 36 | From a node script 37 | ```javascript 38 | var rokuDeploy = require('roku-deploy'); 39 | 40 | //deploy a .zip package of your project to a roku device 41 | rokuDeploy.deploy({ 42 | host: 'ip-of-roku', 43 | password: 'password for roku dev admin portal' 44 | //other options if necessary 45 | }).then(function(){ 46 | //it worked 47 | }, function(error) { 48 | //it failed 49 | console.error(error); 50 | }); 51 | ``` 52 | Or 53 | ```javascript 54 | //create a signed package of your project 55 | rokuDeploy.deployAndSignPackage({ 56 | host: 'ip-of-roku', 57 | password: 'password for roku dev admin portal', 58 | signingPassword: 'signing password' 59 | //other options if necessary 60 | }).then(function(pathToSignedPackage){ 61 | console.log('Signed package created at ', pathToSignedPackage); 62 | }, function(error) { 63 | //it failed 64 | console.error(error); 65 | }); 66 | ``` 67 | 68 | 69 | ### Copying the files to staging 70 | If you'd like to use roku-deploy to copy files to a staging folder, you can do the following: 71 | ```typescript 72 | rokuDeploy.prepublishToStaging({ 73 | rootDir: "folder/with/your/source/code", 74 | stagingDir: 'path/to/staging/folder', 75 | files: [ 76 | "source/**/*", 77 | "components/**/*", 78 | "images/**/*", 79 | "manifest" 80 | ], 81 | //...other options if necessary 82 | }).then(function(){ 83 | //the files have been copied to staging 84 | }, function(error) { 85 | //it failed 86 | console.error(error); 87 | }); 88 | ``` 89 | 90 | ### Creating a zip from an already-populated staging folder 91 | Use this logic if you'd like to create a zip from your application folder. 92 | ```typescript 93 | /create a signed package of your project 94 | rokuDeploy.zipPackage({ 95 | outDir: 'folder/to/put/zip', 96 | stagingDir: 'path/to/files/to/zip', 97 | outFile: 'filename-of-your-app.zip' 98 | //...other options if necessary 99 | }).then(function(){ 100 | //the zip has been created 101 | }, function(error) { 102 | //it failed 103 | console.error(error); 104 | }); 105 | ``` 106 | 107 | 108 | ### Deploying an existing zip 109 | If you've already created a zip using some other tool, you can use roku-deploy to sideload the zip. 110 | ```typescript 111 | /create a signed package of your project 112 | rokuDeploy.publish({ 113 | host: 'ip-of-roku', 114 | password: 'password for roku dev admin portal', 115 | outDir: 'folder/where/your/zip/resides/', 116 | outFile: 'filename-of-your-app.zip' 117 | //...other options if necessary 118 | }).then(function(){ 119 | //the app has been sideloaded 120 | }, function(error) { 121 | //it failed 122 | console.error(error); 123 | }); 124 | ``` 125 | 126 | ### running roku-deploy as an npm script 127 | From an npm script in `package.json`. (Requires `rokudeploy.json` to exist at the root level where this is being run) 128 | 129 | { 130 | "scripts": { 131 | "deploy": "roku-deploy" 132 | } 133 | } 134 | 135 | You can provide a callback in any of the higher level methods, which allows you to modify the copied contents before the package is zipped. An info object is passed in with the following attributes 136 | - **manifestData:** [key: string]: string 137 | Contains all the parsed values from the manifest file 138 | - **stagingDir:** string 139 | Path to staging folder to make it so you only need to know the relative path to what you're trying to modify 140 | 141 | ```javascript 142 | let options = { 143 | host: 'ip-of-roku', 144 | password: 'password for roku dev admin portal' 145 | //other options if necessary 146 | }; 147 | 148 | rokuDeploy.deploy(options, (info) => { 149 | //modify staging dir before it's zipped. 150 | //At this point, all files have been copied to the staging directory. 151 | manipulateFilesInStagingFolder(info.stagingDir) 152 | //this function can also return a promise, 153 | //which will be awaited before roku-deploy starts deploying. 154 | }).then(function(){ 155 | //it worked 156 | }, function(){ 157 | //it failed 158 | }); 159 | ``` 160 | 161 | ## bsconfig.json 162 | Another common config file is [bsconfig.json](https://github.com/rokucommunity/brighterscript#bsconfigjson-options), used by the [BrighterScript](https://github.com/rokucommunity/brighterscript) project and the [BrightScript extension for VSCode](https://github.com/rokucommunity/vscode-brightscript-language). Since many of the config settings are shared between `roku-deploy.json` and `bsconfig.json`, `roku-deploy` supports reading from that file as well. Here is the loading order: 163 | - if `roku-deploy.json` is found, those settings are used. 164 | - if `roku-deploy.json` is not found, look for `bsconfig.json` and use those settings. 165 | 166 | Note that When roku-deploy is called from within a NodeJS script, the options passed into the roku-deploy methods will override any options found in `roku-deploy.json` and `bsconfig.json`. 167 | 168 | 169 | ## Files Array 170 | 171 | The files array is how you specify what files are included in your project. Any strings found in the files array must be relative to `rootDir`, and are used as include _filters_, meaning that if a file matches the pattern, it is included. 172 | 173 | For most standard projects, the default files array should work just fine: 174 | 175 | ```jsonc 176 | { 177 | "files": [ 178 | "source/**/*", 179 | "components/**/*", 180 | "images/**/*", 181 | "manifest" 182 | ] 183 | } 184 | ``` 185 | 186 | This will copy all files from the standard roku folders directly into the package while maintaining each file's relative file path within `rootDir`. 187 | 188 | If you want to include additonal files, you will need to provide the entire array. For example, if you have a folder with other assets, you could do the following: 189 | 190 | ```jsonc 191 | { 192 | "files": [ 193 | "source/**/*", 194 | "components/**/*", 195 | "images/**/*", 196 | "manifest" 197 | //your folder with other assets 198 | "assets/**/*", 199 | ] 200 | } 201 | ``` 202 | 203 | ### Excluding Files 204 | You can also prefix your file patterns with "`!`" which will _exclude_ files from the output. This is useful in cases where you want everything in a folder EXCEPT certain files. The files array is processed top to bottom. Here's an example: 205 | 206 | ```jsonc 207 | { 208 | "files": [ 209 | "source/**/*", 210 | "!source/some/unwanted/file.brs" 211 | ] 212 | } 213 | ``` 214 | 215 | #### Top-level String Rules 216 | - All patterns will be resolved relative to `rootDir`, with their relative positions within `rootDir` maintained. 217 | 218 | - No pattern may reference a file outside of `rootDir`. (You can use `{src;dest}` objects to accomplish) For example: 219 | ```jsonc 220 | { 221 | "rootDir": "C:/projects/CatVideoPlayer", 222 | "files": [ 223 | "source/main.brs", 224 | 225 | //NOT allowed because it navigates outside the rootDir 226 | "../common/promise.brs" 227 | ] 228 | } 229 | ``` 230 | 231 | - Any valid glob pattern is supported. See [glob on npm](https://www.npmjs.com/package/glob) for more information. 232 | 233 | - Empty folders are not copied 234 | 235 | - Paths to folders will be ignored. If you want to copy a folder and its contents, use the glob syntax (i.e. `some_folder/**/*`) 236 | 237 | ### Advanced Usage 238 | For more advanced use cases, you may provide an object which contains the source pattern and output path. This allows you to get very specific about what files to copy, and where they are placed in the output folder. This option also supports copying files from outside the project. 239 | 240 | The object structure is as follows: 241 | 242 | ```typescript 243 | { 244 | /** 245 | * a glob pattern string or file path, or an array of glob pattern strings and/or file paths. 246 | * These can be relative paths or absolute paths. 247 | * All non-absolute paths are resolved relative to the rootDir 248 | */ 249 | src: Array; 250 | /** 251 | * The relative path to the location in the output folder where the files should be placed, relative to the root of the output folder 252 | */ 253 | dest: string|undefined 254 | } 255 | ``` 256 | #### { src; dest } Object Rules 257 | - if `src` is a non-glob path to a single file, then `dest` should include the filename and extension. For example: 258 | `{ src: "lib/Promise/promise.brs", dest: "source/promise.brs"}` 259 | 260 | - if `src` is a glob pattern, then `dest` should be a path to the folder in the output directory. For example: 261 | `{ src: "lib/*.brs", dest: "source/lib"}` 262 | 263 | - if `src` is a glob pattern that includes `**`, then all files found in `src` after the `**` will retain their relative paths in `src` when copied to `dest`. For example: 264 | `{ src: "lib/**.brs", dest: "source/lib"}` 265 | 266 | - if `src` is a path to a folder, it will be ignored. If you want to copy a folder and its contents, use the glob syntax. The following example will copy all files from the `lib/vendor` folder recursively: 267 | `{ src: "lib/vendor/**/*", dest: "vendor" }` 268 | 269 | - if `dest` is not specified, the root of the output folder is assumed 270 | 271 | ### Collision Handling 272 | `roku-deploy` processes file entries in order, so if you want to override a file, just make sure the one you want to keep is later in the files array 273 | 274 | For example, if you have a base project, and then a child project that wants to override specific files, you could do the following: 275 | ```jsonc 276 | { 277 | "files": [ 278 | { 279 | //copy all files from the base project 280 | "src": "../BaseProject/**/*" 281 | }, 282 | //override "../BaseProject/themes/theme.brs" with "${rootDir}/themes/theme.brs" 283 | "themes/theme.brs" 284 | ] 285 | } 286 | ``` 287 | 288 | 289 | 290 | ## roku-deploy Options 291 | Here are the available options. The defaults are shown to the right of the option name, but all can be overridden: 292 | 293 | - **host:** string (*required*) 294 | The IP address or hostname of the target Roku device. Example: `"192.168.1.21"` 295 | 296 | - **password:** string (*required*) 297 | The password for logging in to the developer portal on the target Roku device 298 | 299 | - **signingPassword:** string (*required for signing*) 300 | The password used for creating signed packages 301 | 302 | - **rekeySignedPackage:** string (*required for rekeying*) 303 | Path to a copy of the signed package you want to use for rekeying 304 | 305 | - **devId:** string 306 | Dev ID we are expecting the device to have. If supplied we check that the dev ID returned after keying matches what we expected 307 | 308 | 309 | - **outDir?:** string = `"./out"` 310 | A full path to the folder where the zip/pkg package should be placed 311 | 312 | - **outFile?:** string = `"roku-deploy"` 313 | The base filename the zip/pkg file should be given (excluding the extension) 314 | 315 | - **rootDir?:** string = `'./'` 316 | The root path to the folder holding your project. The manifest file should be directly underneath this folder. Use this option when your roku project is in a subdirectory of where roku-deploy is installed. 317 | 318 | - **files?:** ( string | { src: string; dest: string; } ) [] = 319 | ``` 320 | [ 321 | "source/**/*.*", 322 | "components/**/*.*", 323 | "images/**/*.*", 324 | "manifest" 325 | ] 326 | ``` 327 | An array of file paths, globs, or {src:string;dest:string} objects that will be copied into the deployment package. 328 | 329 | Using the {src;dest} objects will allow you to move files into different destination paths in the 330 | deployment package. This would be useful for copying environment-specific configs into a common config location 331 | (i.e. copy from `"ProjectRoot\configs\dev.config.json"` to `"roku-deploy.zip\config.json"`). Here's a sample: 332 | ```jsonc 333 | //deploy configs/dev.config.json as config.json 334 | { 335 | "src": "configs/dev.config.json", 336 | "dest": "config.json" 337 | } 338 | ``` 339 | 340 | ```jsonc 341 | //you can omit the filename in dest if you want the file to keep its name. Just end dest with a trailing slash. 342 | { 343 | "src": "languages/english/language.xml", 344 | "dest": "languages/" 345 | } 346 | 347 | ``` 348 | This will result in the `[sourceFolder]/configs/dev.config.json` file being copied to the zip file and named `"config.json"`. 349 | 350 | 351 | You can also provide negated globs (thanks to [glob-all](https://www.npmjs.com/package/glob-all)). So something like this would include all component files EXCEPT for specs. 352 | ``` 353 | files: [ 354 | 'components/**/*.*', 355 | '!components/**/*.spec.*' 356 | ] 357 | ``` 358 | 359 | *NOTE:* If you override this "files" property, you need to provide **all** config values, as your array will completely overwrite the default. 360 | 361 | - **retainStagingFolder?:** boolean = `false` 362 | Set this to true to prevent the staging folder from being deleted after creating the package. This is helpful for troubleshooting why your package isn't being created the way you expected. 363 | 364 | - **stagingDir?:** string = `` `${options.outDir}/.roku-deploy-staging` `` 365 | The path to the staging folder (where roku-deploy places all of the files right before zipping them up). 366 | 367 | - **convertToSquashfs?:** boolean = `false` 368 | If true we convert to squashfs before creating the pkg file 369 | 370 | - **incrementBuildNumber?:** boolean = `false` 371 | If true we increment the build number to be a timestamp in the format yymmddHHMM 372 | 373 | - **username?:** string = `"rokudev"` 374 | The username for the roku box. This will always be 'rokudev', but allow to be passed in 375 | just in case roku adds support for custom usernames in the future 376 | 377 | - **packagePort?:** string = 80 378 | The port used for package-related requests. This is mainly used for things like emulators, or when your roku is behind a firewall with a port-forward. 379 | 380 | - **remotePort?:** string = 8060 381 | The port used for sending remote control commands (like home press or back press). This is mainly used for things like emulators, or when your roku is behind a firewall with a port-forward. 382 | 383 | - **remoteDebug?:** boolean = false 384 | When publishing a side loaded channel this flag can be used to enable the socket based BrightScript debug protocol. This should always be `false` unless you're creating a plugin for an editor such as VSCode, Atom, Sublime, etc. 385 | More information on the BrightScript debug protocol can be found here: https://developer.roku.com/en-ca/docs/developer-program/debugging/socket-based-debugger.md 386 | 387 | - **deleteInstalledChannel?:** boolean = true 388 | If true the previously installed dev channel will be deleted before installing the new one 389 | 390 | 391 | Click [here](https://github.com/rokucommunity/roku-deploy/blob/8e1cbdfcccb38dad4a1361277bdaf5484f1c2bcd/src/RokuDeploy.ts#L897) to see the typescript interface for these options 392 | 393 | 394 | ## Troubleshooting 395 | - 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. 396 | 397 | ## Changelog 398 | Click [here](CHANGELOG.md) to view the changelog 399 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roku-deploy", 3 | "version": "3.12.6", 4 | "description": "Package and publish a Roku application using Node.js", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "rimraf dist && tsc", 8 | "preversion": "npm run build && npm run lint && npm run test", 9 | "prepublishOnly": "npm run build", 10 | "watch": "rimraf dist && tsc --watch", 11 | "lint": "eslint \"./src/**/*.ts\"", 12 | "test": "nyc mocha \"src/**/*.spec.ts\" --exclude \"src/device.spec.ts\"", 13 | "test:nocover": "mocha \"src/**/*.spec.ts\" --exclude \"src/device.spec.ts\"", 14 | "test:device": "nyc mocha src/device.spec.ts", 15 | "test:all": "nyc mocha \"src/**/*.spec.ts\"", 16 | "test-without-sourcemaps": "npm run build && nyc mocha dist/**/*.spec.js", 17 | "publish-coverage": "nyc report --reporter=text-lcov | coveralls", 18 | "package": "npm run build && npm pack" 19 | }, 20 | "dependencies": { 21 | "@types/request": "^2.47.0", 22 | "chalk": "^2.4.2", 23 | "dateformat": "^3.0.3", 24 | "dayjs": "^1.11.0", 25 | "fast-glob": "^3.2.12", 26 | "fs-extra": "^7.0.1", 27 | "is-glob": "^4.0.3", 28 | "jsonc-parser": "^2.3.0", 29 | "jszip": "^3.6.0", 30 | "lodash": "^4.17.21", 31 | "micromatch": "^4.0.4", 32 | "moment": "^2.29.1", 33 | "parse-ms": "^2.1.0", 34 | "postman-request": "^2.88.1-postman.40", 35 | "temp-dir": "^2.0.0", 36 | "xml2js": "^0.5.0" 37 | }, 38 | "devDependencies": { 39 | "@types/chai": "^4.2.22", 40 | "@types/fs-extra": "^5.0.1", 41 | "@types/is-glob": "^4.0.2", 42 | "@types/lodash": "^4.14.200", 43 | "@types/micromatch": "^4.0.2", 44 | "@types/mocha": "^9.0.0", 45 | "@types/node": "^16.11.3", 46 | "@types/q": "^1.5.8", 47 | "@types/sinon": "^10.0.4", 48 | "@types/xml2js": "^0.4.5", 49 | "@typescript-eslint/eslint-plugin": "5.1.0", 50 | "@typescript-eslint/parser": "5.1.0", 51 | "chai": "^4.3.4", 52 | "coveralls-next": "^4.2.0", 53 | "eslint": "8.0.1", 54 | "mocha": "^9.1.3", 55 | "nyc": "^15.1.0", 56 | "q": "^1.5.1", 57 | "rimraf": "^6.0.1", 58 | "sinon": "^11.1.2", 59 | "source-map-support": "^0.5.13", 60 | "ts-node": "^10.3.1", 61 | "typescript": "^4.4.4", 62 | "undent": "^1.0.0" 63 | }, 64 | "mocha": { 65 | "require": [ 66 | "source-map-support/register", 67 | "ts-node/register" 68 | ], 69 | "fullTrace": true, 70 | "watchExtensions": [ 71 | "ts" 72 | ] 73 | }, 74 | "typings": "dist/index.d.ts", 75 | "bin": { 76 | "roku-deploy": "dist/cli.js" 77 | }, 78 | "repository": { 79 | "type": "git", 80 | "url": "https://github.com/RokuCommunity/roku-deploy.git" 81 | }, 82 | "author": "RokuCommunity", 83 | "license": "MIT", 84 | "nyc": { 85 | "include": [ 86 | "src/**/*.ts", 87 | "!src/Errors.ts", 88 | "!src/**/*.spec.ts" 89 | ], 90 | "extension": [ 91 | ".ts" 92 | ], 93 | "require": [ 94 | "ts-node/register", 95 | "source-map-support/register" 96 | ], 97 | "reporter": [ 98 | "text-summary", 99 | "html" 100 | ], 101 | "sourceMap": true, 102 | "instrument": true, 103 | "check-coverage": true, 104 | "lines": 100, 105 | "statements": 100, 106 | "functions": 100, 107 | "branches": 100 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/DeviceInfo.ts: -------------------------------------------------------------------------------- 1 | //there are 2 copies of this interface in here. If you add a new field, be sure to add it to both 2 | 3 | export interface DeviceInfo { 4 | udn?: string; 5 | serialNumber?: string; 6 | deviceId?: string; 7 | advertisingId?: string; 8 | vendorName?: string; 9 | modelName?: string; 10 | modelNumber?: string; 11 | modelRegion?: string; 12 | isTv?: boolean; 13 | isStick?: boolean; 14 | mobileHasLiveTv?: boolean; 15 | uiResolution?: string; 16 | supportsEthernet?: boolean; 17 | wifiMac?: string; 18 | wifiDriver?: string; 19 | hasWifiExtender?: boolean; 20 | hasWifi5GSupport?: boolean; 21 | canUseWifiExtender?: boolean; 22 | ethernetMac?: string; 23 | networkType?: string; 24 | networkName?: string; 25 | friendlyDeviceName?: string; 26 | friendlyModelName?: string; 27 | defaultDeviceName?: string; 28 | userDeviceName?: string; 29 | userDeviceLocation?: string; 30 | buildNumber?: string; 31 | softwareVersion?: string; 32 | softwareBuild?: number; 33 | secureDevice?: boolean; 34 | language?: string; 35 | country?: string; 36 | locale?: string; 37 | timeZoneAuto?: boolean; 38 | timeZone?: string; 39 | timeZoneName?: string; 40 | timeZoneTz?: string; 41 | timeZoneOffset?: number; 42 | clockFormat?: string; 43 | uptime?: number; 44 | powerMode?: string; 45 | supportsSuspend?: boolean; 46 | supportsFindRemote?: boolean; 47 | findRemoteIsPossible?: boolean; 48 | supportsAudioGuide?: boolean; 49 | supportsRva?: boolean; 50 | hasHandsFreeVoiceRemote?: boolean; 51 | developerEnabled?: boolean; 52 | keyedDeveloperId?: string; 53 | searchEnabled?: boolean; 54 | searchChannelsEnabled?: boolean; 55 | voiceSearchEnabled?: boolean; 56 | notificationsEnabled?: boolean; 57 | notificationsFirstUse?: boolean; 58 | supportsPrivateListening?: boolean; 59 | headphonesConnected?: boolean; 60 | supportsAudioSettings?: boolean; 61 | supportsEcsTextedit?: boolean; 62 | supportsEcsMicrophone?: boolean; 63 | supportsWakeOnWlan?: boolean; 64 | supportsAirplay?: boolean; 65 | hasPlayOnRoku?: boolean; 66 | hasMobileScreensaver?: boolean; 67 | supportUrl?: string; 68 | grandcentralVersion?: string; 69 | trcVersion?: number; 70 | trcChannelVersion?: string; 71 | davinciVersion?: string; 72 | avSyncCalibrationEnabled?: number; 73 | brightscriptDebuggerVersion?: string; 74 | } 75 | 76 | export interface DeviceInfoRaw { 77 | 'udn'?: string; 78 | 'serialNumber'?: string; 79 | 'deviceId'?: string; 80 | 'advertising-id'?: string; 81 | 'vendor-name'?: string; 82 | 'model-name'?: string; 83 | 'model-number'?: string; 84 | 'model-region'?: string; 85 | 'is-tv'?: string; 86 | 'is-stick'?: string; 87 | 'mobile-has-live-tv'?: string; 88 | 'ui-resolution'?: string; 89 | 'supports-ethernet'?: string; 90 | 'wifi-mac'?: string; 91 | 'wifi-driver'?: string; 92 | 'has-wifi-extender'?: string; 93 | 'has-wifi-5G-support'?: string; 94 | 'can-use-wifi-extender'?: string; 95 | 'ethernet-mac'?: string; 96 | 'network-type'?: string; 97 | 'network-name'?: string; 98 | 'friendly-device-name'?: string; 99 | 'friendly-model-name'?: string; 100 | 'default-device-name'?: string; 101 | 'user-device-name'?: string; 102 | 'user-device-location'?: string; 103 | 'build-number'?: string; 104 | 'software-version'?: string; 105 | 'software-build'?: string; 106 | 'secure-device'?: string; 107 | 'language'?: string; 108 | 'country'?: string; 109 | 'locale'?: string; 110 | 'time-zone-auto'?: string; 111 | 'time-zone'?: string; 112 | 'time-zone-name'?: string; 113 | 'time-zone-tz'?: string; 114 | 'time-zone-offset'?: string; 115 | 'clock-format'?: string; 116 | 'uptime'?: string; 117 | 'power-mode'?: string; 118 | 'supports-suspend'?: string; 119 | 'supports-find-remote'?: string; 120 | 'find-remote-is-possible'?: string; 121 | 'supports-audio-guide'?: string; 122 | 'supports-rva'?: string; 123 | 'has-hands-free-voice-remote'?: string; 124 | 'developer-enabled'?: string; 125 | 'keyed-developer-id'?: string; 126 | 'search-enabled'?: string; 127 | 'search-channels-enabled'?: string; 128 | 'voice-search-enabled'?: string; 129 | 'notifications-enabled'?: string; 130 | 'notifications-first-use'?: string; 131 | 'supports-private-listening'?: string; 132 | 'headphones-connected'?: string; 133 | 'supports-audio-settings'?: string; 134 | 'supports-ecs-textedit'?: string; 135 | 'supports-ecs-microphone'?: string; 136 | 'supports-wake-on-wlan'?: string; 137 | 'supports-airplay'?: string; 138 | 'has-play-on-roku'?: string; 139 | 'has-mobile-screensaver'?: string; 140 | 'support-url'?: string; 141 | 'grandcentral-version'?: string; 142 | 'trc-version'?: string; 143 | 'trc-channel-version'?: string; 144 | 'davinci-version'?: string; 145 | 'av-sync-calibration-enabled'?: string; 146 | 'brightscript-debugger-version'?: string; 147 | // catchall index lookup for keys we weren't aware of 148 | [key: string]: any; 149 | } 150 | -------------------------------------------------------------------------------- /src/Errors.ts: -------------------------------------------------------------------------------- 1 | import type { HttpResponse, RokuMessages } from './RokuDeploy'; 2 | import type * as requestType from 'request'; 3 | 4 | export class InvalidDeviceResponseCodeError extends Error { 5 | constructor(message: string, public results?: any) { 6 | super(message); 7 | Object.setPrototypeOf(this, InvalidDeviceResponseCodeError.prototype); 8 | } 9 | } 10 | 11 | export class UnauthorizedDeviceResponseError extends Error { 12 | constructor(message: string, public results?: any) { 13 | super(message); 14 | Object.setPrototypeOf(this, UnauthorizedDeviceResponseError.prototype); 15 | } 16 | } 17 | 18 | export class UnparsableDeviceResponseError extends Error { 19 | constructor(message: string, public results?: any) { 20 | super(message); 21 | Object.setPrototypeOf(this, UnparsableDeviceResponseError.prototype); 22 | } 23 | } 24 | 25 | export class FailedDeviceResponseError extends Error { 26 | constructor(message: string, public results?: any) { 27 | super(message); 28 | Object.setPrototypeOf(this, FailedDeviceResponseError.prototype); 29 | } 30 | } 31 | 32 | export class UnknownDeviceResponseError extends Error { 33 | constructor(message: string, public results?: any) { 34 | super(message); 35 | Object.setPrototypeOf(this, UnknownDeviceResponseError.prototype); 36 | } 37 | } 38 | 39 | export class CompileError extends Error { 40 | constructor(message: string, public results: any, public rokuMessages: RokuMessages) { 41 | super(message); 42 | Object.setPrototypeOf(this, CompileError.prototype); 43 | } 44 | } 45 | 46 | export class ConvertError extends Error { 47 | constructor(message: string) { 48 | super(message); 49 | Object.setPrototypeOf(this, ConvertError.prototype); 50 | } 51 | } 52 | 53 | export class MissingRequiredOptionError extends Error { 54 | constructor(message: string) { 55 | super(message); 56 | Object.setPrototypeOf(this, MissingRequiredOptionError.prototype); 57 | } 58 | } 59 | 60 | /** 61 | * 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). 62 | */ 63 | export class UpdateCheckRequiredError extends Error { 64 | 65 | static MESSAGE = `Your device needs to check for updates before accepting connections. Please navigate to System Settings and check for updates and then try again.\n\nhttps://support.roku.com/article/208755668.`; 66 | 67 | constructor( 68 | public response: HttpResponse, 69 | public requestOptions: requestType.OptionsWithUrl, 70 | public cause?: Error 71 | ) { 72 | super(); 73 | this.message = UpdateCheckRequiredError.MESSAGE; 74 | Object.setPrototypeOf(this, UpdateCheckRequiredError.prototype); 75 | } 76 | } 77 | 78 | export function isUpdateCheckRequiredError(e: any): e is UpdateCheckRequiredError { 79 | return e?.constructor?.name === 'UpdateCheckRequiredError'; 80 | } 81 | 82 | /** 83 | * This error is thrown when a Roku device ends the connection unexpectedly, causing an 'ECONNRESET' error. Typically this happens when the device needs to check for updates (even if no updates are available), but it can also happen for other reasons. 84 | */ 85 | export class ConnectionResetError extends Error { 86 | 87 | static MESSAGE = `The Roku device ended the connection unexpectedly and may need to check for updates before accepting connections. Please navigate to System Settings and check for updates and then try again.\n\nhttps://support.roku.com/article/208755668.`; 88 | 89 | constructor(error: Error, requestOptions: requestType.OptionsWithUrl) { 90 | super(); 91 | this.message = ConnectionResetError.MESSAGE; 92 | this.cause = error; 93 | Object.setPrototypeOf(this, ConnectionResetError.prototype); 94 | } 95 | 96 | public cause?: Error; 97 | } 98 | 99 | export function isConnectionResetError(e: any): e is ConnectionResetError { 100 | return e?.constructor?.name === 'ConnectionResetError'; 101 | } 102 | -------------------------------------------------------------------------------- /src/Logger.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Logger, LogLevel, noop } from './Logger'; 3 | import chalk from 'chalk'; 4 | import { createSandbox } from 'sinon'; 5 | const sinon = createSandbox(); 6 | 7 | describe('Logger', () => { 8 | let logger: Logger; 9 | 10 | beforeEach(() => { 11 | logger = new Logger(LogLevel.trace); 12 | sinon.restore(); 13 | //disable chalk colors for testing 14 | sinon.stub(chalk, 'grey').callsFake((arg) => arg as any); 15 | }); 16 | 17 | it('noop does nothing', () => { 18 | noop(); 19 | }); 20 | 21 | it('loglevel setter converts string to enum', () => { 22 | (logger as any).logLevel = 'error'; 23 | expect(logger.logLevel).to.eql(LogLevel.error); 24 | (logger as any).logLevel = 'info'; 25 | expect(logger.logLevel).to.eql(LogLevel.info); 26 | }); 27 | 28 | it('uses LogLevel.log by default', () => { 29 | logger = new Logger(); 30 | expect(logger.logLevel).to.eql(LogLevel.log); 31 | }); 32 | 33 | describe('log methods call correct error type', () => { 34 | it('error', () => { 35 | const stub = sinon.stub(logger as any, 'writeToLog').callsFake(() => { }); 36 | logger.error(); 37 | expect(stub.getCalls()[0].args[0]).to.eql(console.error); 38 | }); 39 | 40 | it('warn', () => { 41 | const stub = sinon.stub(logger as any, 'writeToLog').callsFake(() => { }); 42 | logger.warn(); 43 | expect(stub.getCalls()[0].args[0]).to.eql(console.warn); 44 | }); 45 | 46 | it('log', () => { 47 | const stub = sinon.stub(logger as any, 'writeToLog').callsFake(() => { }); 48 | logger.log(); 49 | expect(stub.getCalls()[0].args[0]).to.eql(console.log); 50 | }); 51 | 52 | it('info', () => { 53 | const stub = sinon.stub(logger as any, 'writeToLog').callsFake(() => { }); 54 | logger.info(); 55 | expect(stub.getCalls()[0].args[0]).to.eql(console.info); 56 | }); 57 | 58 | it('debug', () => { 59 | const stub = sinon.stub(logger as any, 'writeToLog').callsFake(() => { }); 60 | logger.debug(); 61 | expect(stub.getCalls()[0].args[0]).to.eql(console.debug); 62 | }); 63 | 64 | it('trace', () => { 65 | const stub = sinon.stub(logger as any, 'writeToLog').callsFake(() => { }); 66 | logger.trace(); 67 | expect(stub.getCalls()[0].args[0]).to.eql(console.trace); 68 | }); 69 | }); 70 | 71 | it('skips all errors on error level', () => { 72 | logger.logLevel = LogLevel.off; 73 | const stub = sinon.stub(logger as any, 'writeToLog').callsFake(() => { }); 74 | logger.trace(); 75 | logger.debug(); 76 | logger.info(); 77 | logger.log(); 78 | logger.warn(); 79 | logger.error(); 80 | 81 | expect( 82 | stub.getCalls().map(x => x.args[0]) 83 | ).to.eql([]); 84 | }); 85 | 86 | it('does not skip when log level is high enough', () => { 87 | logger.logLevel = LogLevel.trace; 88 | const stub = sinon.stub(logger as any, 'writeToLog').callsFake(() => { }); 89 | logger.trace(); 90 | logger.debug(); 91 | logger.info(); 92 | logger.log(); 93 | logger.warn(); 94 | logger.error(); 95 | 96 | expect( 97 | stub.getCalls().map(x => x.args[0]) 98 | ).to.eql([ 99 | console.trace, 100 | console.debug, 101 | console.info, 102 | console.log, 103 | console.warn, 104 | console.error 105 | ]); 106 | }); 107 | 108 | describe('time', () => { 109 | it('calls action even if logLevel is wrong', () => { 110 | logger.logLevel = LogLevel.error; 111 | const spy = sinon.spy(); 112 | logger.time(LogLevel.info, null, spy); 113 | expect(spy.called).to.be.true; 114 | }); 115 | 116 | it('runs timer when loglevel is right', () => { 117 | logger.logLevel = LogLevel.log; 118 | const spy = sinon.spy(); 119 | logger.time(LogLevel.log, null, spy); 120 | expect(spy.called).to.be.true; 121 | }); 122 | 123 | it('returns value', () => { 124 | logger.logLevel = LogLevel.log; 125 | const spy = sinon.spy(() => { 126 | return true; 127 | }); 128 | expect( 129 | logger.time(LogLevel.log, null, spy) 130 | ).to.be.true; 131 | expect(spy.called).to.be.true; 132 | }); 133 | 134 | it('gives callable pause and resume functions even when not running timer', () => { 135 | logger.time(LogLevel.info, null, (pause, resume) => { 136 | pause(); 137 | resume(); 138 | }); 139 | }); 140 | 141 | it('waits for and returns a promise when a promise is returned from the action', () => { 142 | expect(logger.time(LogLevel.info, ['message'], () => { 143 | return Promise.resolve(); 144 | })).to.be.instanceof(Promise); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /src/Logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import * as moment from 'moment'; 3 | import { Stopwatch } from './Stopwatch'; 4 | 5 | export class Logger { 6 | /** 7 | * A string with whitespace used for indenting all messages 8 | */ 9 | private indent = ''; 10 | 11 | constructor(logLevel?: LogLevel) { 12 | this.logLevel = logLevel; 13 | } 14 | 15 | public get logLevel() { 16 | return this._logLevel; 17 | } 18 | 19 | public set logLevel(value: LogLevel) { 20 | //cast the string version to the numberic version 21 | if (typeof (value) === 'string') { 22 | value = LogLevel[value] as any; 23 | } 24 | this._logLevel = value ?? LogLevel.log; 25 | } 26 | 27 | private _logLevel = LogLevel.log; 28 | 29 | private getTimestamp() { 30 | return '[' + chalk.grey(moment().format(`hh:mm:ss:SSSS A`)) + ']'; 31 | } 32 | 33 | private writeToLog(method: (...consoleArgs: any[]) => void, ...args: any[]) { 34 | if (this._logLevel === LogLevel.trace) { 35 | method = console.trace; 36 | } 37 | let finalArgs = []; 38 | for (let arg of args) { 39 | finalArgs.push(arg); 40 | } 41 | method.call(console, this.getTimestamp(), this.indent, ...finalArgs); 42 | } 43 | 44 | /** 45 | * Log an error message to the console 46 | */ 47 | error(...messages) { 48 | if (this._logLevel >= LogLevel.error) { 49 | this.writeToLog(console.error, ...messages); 50 | } 51 | } 52 | 53 | /** 54 | * Log a warning message to the console 55 | */ 56 | warn(...messages) { 57 | if (this._logLevel >= LogLevel.warn) { 58 | this.writeToLog(console.warn, ...messages); 59 | } 60 | } 61 | 62 | /** 63 | * Log a standard log message to the console 64 | */ 65 | log(...messages) { 66 | if (this._logLevel >= LogLevel.log) { 67 | this.writeToLog(console.log, ...messages); 68 | } 69 | } 70 | 71 | /** 72 | * Log an info message to the console 73 | */ 74 | info(...messages) { 75 | if (this._logLevel >= LogLevel.info) { 76 | this.writeToLog(console.info, ...messages); 77 | } 78 | } 79 | 80 | /** 81 | * Log a debug message to the console 82 | */ 83 | debug(...messages) { 84 | if (this._logLevel >= LogLevel.debug) { 85 | this.writeToLog(console.debug, ...messages); 86 | } 87 | } 88 | 89 | /** 90 | * Log a debug message to the console 91 | */ 92 | trace(...messages) { 93 | if (this._logLevel >= LogLevel.trace) { 94 | this.writeToLog(console.trace, ...messages); 95 | } 96 | } 97 | 98 | /** 99 | * Writes to the log (if logLevel matches), and also times how long the action took to occur. 100 | * `action` is called regardless of logLevel, so this function can be used to nicely wrap 101 | * pieces of functionality. 102 | * The action function also includes two parameters, `pause` and `resume`, which can be used to improve timings by focusing only on 103 | * the actual logic of that action. 104 | */ 105 | time(logLevel: LogLevel, messages: any[], action: (pause: () => void, resume: () => void) => T): T { 106 | //call the log if loglevel is in range 107 | if (this._logLevel >= logLevel) { 108 | let stopwatch = new Stopwatch(); 109 | let logLevelString = LogLevel[logLevel]; 110 | 111 | //write the initial log 112 | this[logLevelString](...messages ?? []); 113 | this.indent += ' '; 114 | 115 | stopwatch.start(); 116 | //execute the action 117 | let result = action(stopwatch.stop.bind(stopwatch), stopwatch.start.bind(stopwatch)) as any; 118 | stopwatch.stop(); 119 | 120 | //return a function to call when the timer is complete 121 | let done = () => { 122 | this.indent = this.indent.substring(2); 123 | this[logLevelString](...messages ?? [], `finished. (${chalk.blue(stopwatch.getDurationText())})`); 124 | }; 125 | 126 | //if this is a promise, wait for it to resolve and then return the original result 127 | if (typeof result?.then === 'function') { 128 | return Promise.resolve(result).then(done).then(() => { 129 | return result; 130 | }) as any; 131 | } else { 132 | //this was not a promise. finish the timer now 133 | done(); 134 | return result; 135 | } 136 | } else { 137 | return action(noop, noop); 138 | } 139 | } 140 | } 141 | 142 | export function noop() { 143 | 144 | } 145 | 146 | export enum LogLevel { 147 | off = 0, 148 | error = 1, 149 | warn = 2, 150 | log = 3, 151 | info = 4, 152 | debug = 5, 153 | trace = 6 154 | } 155 | -------------------------------------------------------------------------------- /src/RokuDeploy.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as _fsExtra from 'fs-extra'; 3 | import * as r from 'postman-request'; 4 | import type * as requestType from 'request'; 5 | const request = r as typeof requestType; 6 | import * as JSZip from 'jszip'; 7 | import * as dateformat from 'dateformat'; 8 | import * as errors from './Errors'; 9 | import * as isGlob from 'is-glob'; 10 | import * as picomatch from 'picomatch'; 11 | import * as xml2js from 'xml2js'; 12 | import type { ParseError } from 'jsonc-parser'; 13 | import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser'; 14 | import { util } from './util'; 15 | import type { RokuDeployOptions, FileEntry } from './RokuDeployOptions'; 16 | import { Logger, LogLevel } from './Logger'; 17 | import * as tempDir from 'temp-dir'; 18 | import * as dayjs from 'dayjs'; 19 | import * as lodash from 'lodash'; 20 | import type { DeviceInfo, DeviceInfoRaw } from './DeviceInfo'; 21 | 22 | export class RokuDeploy { 23 | 24 | constructor() { 25 | this.logger = new Logger(); 26 | } 27 | 28 | private logger: Logger; 29 | //store the import on the class to make testing easier 30 | 31 | public fsExtra = _fsExtra; 32 | 33 | public screenshotDir = path.join(tempDir, '/roku-deploy/screenshots/'); 34 | 35 | /** 36 | * Copies all of the referenced files to the staging folder 37 | * @param options 38 | */ 39 | public async prepublishToStaging(options: RokuDeployOptions) { 40 | options = this.getOptions(options); 41 | 42 | //clean the staging directory 43 | await this.fsExtra.remove(options.stagingDir); 44 | 45 | //make sure the staging folder exists 46 | await this.fsExtra.ensureDir(options.stagingDir); 47 | await this.copyToStaging(options.files, options.stagingDir, options.rootDir); 48 | return options.stagingDir; 49 | } 50 | 51 | /** 52 | * Given an array of `FilesType`, normalize them each into a `StandardizedFileEntry`. 53 | * Each entry in the array or inner `src` array will be extracted out into its own object. 54 | * This makes it easier to reason about later on in the process. 55 | * @param files 56 | */ 57 | public normalizeFilesArray(files: FileEntry[]) { 58 | const result: Array = []; 59 | 60 | for (let i = 0; i < files.length; i++) { 61 | let entry = files[i]; 62 | //skip falsey and blank entries 63 | if (!entry) { 64 | continue; 65 | 66 | //string entries 67 | } else if (typeof entry === 'string') { 68 | result.push(entry); 69 | 70 | //objects with src: (string | string[]) 71 | } else if ('src' in entry) { 72 | //validate dest 73 | if (entry.dest !== undefined && entry.dest !== null && typeof entry.dest !== 'string') { 74 | throw new Error(`Invalid type for "dest" at index ${i} of files array`); 75 | } 76 | 77 | //objects with src: string 78 | if (typeof entry.src === 'string') { 79 | result.push({ 80 | src: util.standardizePath(entry.src), 81 | dest: util.standardizePath(entry.dest) 82 | }); 83 | 84 | //objects with src:string[] 85 | } else if ('src' in entry && Array.isArray(entry.src)) { 86 | //create a distinct entry for each item in the src array 87 | for (let srcEntry of entry.src) { 88 | result.push({ 89 | src: util.standardizePath(srcEntry), 90 | dest: util.standardizePath(entry.dest) 91 | }); 92 | } 93 | } else { 94 | throw new Error(`Invalid type for "src" at index ${i} of files array`); 95 | } 96 | } else { 97 | throw new Error(`Invalid entry at index ${i} in files array`); 98 | } 99 | } 100 | 101 | return result; 102 | } 103 | 104 | /** 105 | * Given an already-populated staging folder, create a zip archive of it and copy it to the output folder 106 | * @param options 107 | */ 108 | public async zipPackage(options: RokuDeployOptions) { 109 | options = this.getOptions(options); 110 | 111 | //make sure the output folder exists 112 | await this.fsExtra.ensureDir(options.outDir); 113 | 114 | let zipFilePath = this.getOutputZipFilePath(options); 115 | 116 | //ensure the manifest file exists in the staging folder 117 | if (!await util.fileExistsCaseInsensitive(`${options.stagingDir}/manifest`)) { 118 | throw new Error(`Cannot zip package: missing manifest file in "${options.stagingDir}"`); 119 | } 120 | 121 | //create a zip of the staging folder 122 | await this.zipFolder(options.stagingDir, zipFilePath); 123 | 124 | //delete the staging folder unless told to retain it. 125 | if (options.retainStagingDir !== true) { 126 | await this.fsExtra.remove(options.stagingDir); 127 | } 128 | } 129 | 130 | /** 131 | * Create a zip folder containing all of the specified roku project files. 132 | * @param options 133 | */ 134 | public async createPackage(options: RokuDeployOptions, beforeZipCallback?: (info: BeforeZipCallbackInfo) => Promise | void) { 135 | options = this.getOptions(options); 136 | 137 | await this.prepublishToStaging(options); 138 | 139 | let manifestPath = util.standardizePath(`${options.stagingDir}/manifest`); 140 | let parsedManifest = await this.parseManifest(manifestPath); 141 | 142 | if (options.incrementBuildNumber) { 143 | let timestamp = dateformat(new Date(), 'yymmddHHMM'); 144 | parsedManifest.build_version = timestamp; //eslint-disable-line camelcase 145 | await this.fsExtra.outputFile(manifestPath, this.stringifyManifest(parsedManifest)); 146 | } 147 | 148 | if (beforeZipCallback) { 149 | let info: BeforeZipCallbackInfo = { 150 | manifestData: parsedManifest, 151 | stagingFolderPath: options.stagingDir, 152 | stagingDir: options.stagingDir 153 | }; 154 | 155 | await Promise.resolve(beforeZipCallback(info)); 156 | } 157 | await this.zipPackage(options); 158 | } 159 | 160 | /** 161 | * Given a root directory, normalize it to a full path. 162 | * Fall back to cwd if not specified 163 | * @param rootDir 164 | */ 165 | public normalizeRootDir(rootDir: string) { 166 | if (!rootDir || (typeof rootDir === 'string' && rootDir.trim().length === 0)) { 167 | return process.cwd(); 168 | } else { 169 | return path.resolve(rootDir); 170 | } 171 | } 172 | 173 | /** 174 | * Get all file paths for the specified options 175 | * @param files 176 | * @param rootFolderPath - the absolute path to the root dir where relative files entries are relative to 177 | */ 178 | public async getFilePaths(files: FileEntry[], rootDir: string): Promise { 179 | //if the rootDir isn't absolute, convert it to absolute using the standard options flow 180 | if (path.isAbsolute(rootDir) === false) { 181 | rootDir = this.getOptions({ rootDir: rootDir }).rootDir; 182 | } 183 | const entries = this.normalizeFilesArray(files); 184 | const srcPathsByIndex = await util.globAllByIndex( 185 | entries.map(x => { 186 | return typeof x === 'string' ? x : x.src; 187 | }), 188 | rootDir 189 | ); 190 | 191 | /** 192 | * Result indexed by the dest path 193 | */ 194 | let result = new Map(); 195 | 196 | //compute `dest` path for every file 197 | for (let i = 0; i < srcPathsByIndex.length; i++) { 198 | const srcPaths = srcPathsByIndex[i]; 199 | const entry = entries[i]; 200 | if (srcPaths) { 201 | for (let srcPath of srcPaths) { 202 | srcPath = util.standardizePath(srcPath); 203 | 204 | const dest = this.computeFileDestPath(srcPath, entry, rootDir); 205 | //the last file with this `dest` will win, so just replace any existing entry with this one. 206 | result.set(dest, { 207 | src: srcPath, 208 | dest: dest 209 | }); 210 | } 211 | } 212 | } 213 | return [...result.values()]; 214 | } 215 | 216 | /** 217 | * Given a full path to a file, determine its dest path 218 | * @param srcPath the absolute path to the file. This MUST be a file path, and it is not verified to exist on the filesystem 219 | * @param files the files array 220 | * @param rootDir the absolute path to the root dir 221 | * @param skipMatch - skip running the minimatch process (i.e. assume the file is a match 222 | * @returns the RELATIVE path to the dest location for the file. 223 | */ 224 | public getDestPath(srcPathAbsolute: string, files: FileEntry[], rootDir: string, skipMatch = false) { 225 | srcPathAbsolute = util.standardizePath(srcPathAbsolute); 226 | rootDir = rootDir.replace(/\\+/g, '/'); 227 | const entries = this.normalizeFilesArray(files); 228 | 229 | function makeGlobAbsolute(pattern: string) { 230 | return path.resolve( 231 | rootDir, 232 | //remove leading exclamation point if pattern is negated 233 | pattern 234 | //coerce all slashes to forward 235 | ).replace(/\\+/g, '/'); 236 | } 237 | 238 | let result: string; 239 | 240 | //add the file into every matching cache bucket 241 | for (let entry of entries) { 242 | const pattern = (typeof entry === 'string' ? entry : entry.src); 243 | //filter previous paths 244 | if (pattern.startsWith('!')) { 245 | const keepFile = picomatch('!' + makeGlobAbsolute(pattern.replace(/^!/, ''))); 246 | if (!keepFile(srcPathAbsolute)) { 247 | result = undefined; 248 | } 249 | } else { 250 | const keepFile = picomatch(makeGlobAbsolute(pattern)); 251 | if (keepFile(srcPathAbsolute)) { 252 | try { 253 | result = this.computeFileDestPath( 254 | srcPathAbsolute, 255 | entry, 256 | util.standardizePath(rootDir) 257 | ); 258 | } catch { 259 | //ignore errors...the file just has no dest path 260 | } 261 | } 262 | } 263 | } 264 | return result; 265 | } 266 | 267 | /** 268 | * Compute the `dest` path. This accounts for magic globstars in the pattern, 269 | * as well as relative paths based on the dest. This is only used internally. 270 | * @param src an absolute, normalized path for a file 271 | * @param dest the `dest` entry for this file. If omitted, files will derive their paths relative to rootDir. 272 | * @param pattern the glob pattern originally used to find this file 273 | * @param rootDir absolute normalized path to the rootDir 274 | */ 275 | private computeFileDestPath(srcPath: string, entry: string | StandardizedFileEntry, rootDir: string) { 276 | let result: string; 277 | let globstarIdx: number; 278 | //files under rootDir with no specified dest 279 | if (typeof entry === 'string') { 280 | if (util.isParentOfPath(rootDir, srcPath, false)) { 281 | //files that are actually relative to rootDir 282 | result = util.stringReplaceInsensitive(srcPath, rootDir, ''); 283 | } else { 284 | // result = util.stringReplaceInsensitive(srcPath, rootDir, ''); 285 | throw new Error('Cannot reference a file outside of rootDir when using a top-level string. Please use a src;des; object instead'); 286 | } 287 | 288 | //non-glob-pattern explicit file reference 289 | } else if (!isGlob(entry.src.replace(/\\/g, '/'), { strict: false })) { 290 | let isEntrySrcAbsolute = path.isAbsolute(entry.src); 291 | let entrySrcPathAbsolute = isEntrySrcAbsolute ? entry.src : util.standardizePath(`${rootDir}/${entry.src}`); 292 | 293 | let isSrcChildOfRootDir = util.isParentOfPath(rootDir, entrySrcPathAbsolute, false); 294 | 295 | let fileNameAndExtension = path.basename(entrySrcPathAbsolute); 296 | 297 | //no dest 298 | if (entry.dest === null || entry.dest === undefined) { 299 | //no dest, absolute path or file outside of rootDir 300 | if (isEntrySrcAbsolute || isSrcChildOfRootDir === false) { 301 | //copy file to root of staging folder 302 | result = fileNameAndExtension; 303 | 304 | //no dest, relative path, lives INSIDE rootDir 305 | } else { 306 | //copy relative file structure to root of staging folder 307 | let srcPathRelative = util.stringReplaceInsensitive(entrySrcPathAbsolute, rootDir, ''); 308 | result = srcPathRelative; 309 | } 310 | 311 | //assume entry.dest is the relative path to the folder AND file if applicable 312 | } else if (entry.dest === '') { 313 | result = fileNameAndExtension; 314 | } else { 315 | result = entry.dest; 316 | } 317 | //has a globstar 318 | } else if ((globstarIdx = entry.src.indexOf('**')) > -1) { 319 | const rootGlobstarPath = path.resolve(rootDir, entry.src.substring(0, globstarIdx)) + path.sep; 320 | const srcPathRelative = util.stringReplaceInsensitive(srcPath, rootGlobstarPath, ''); 321 | if (entry.dest) { 322 | result = `${entry.dest}/${srcPathRelative}`; 323 | } else { 324 | result = srcPathRelative; 325 | } 326 | 327 | //`pattern` is some other glob magic 328 | } else { 329 | const fileNameAndExtension = path.basename(srcPath); 330 | result = util.standardizePath(`${entry.dest ?? ''}/${fileNameAndExtension}`); 331 | } 332 | 333 | result = util.standardizePath( 334 | //remove leading slashes 335 | result.replace(/^[\/\\]+/, '') 336 | ); 337 | return result; 338 | } 339 | 340 | /** 341 | * Copy all of the files to the staging directory 342 | * @param fileGlobs 343 | * @param stagingPath 344 | */ 345 | private async copyToStaging(files: FileEntry[], stagingPath: string, rootDir: string) { 346 | if (!stagingPath) { 347 | throw new Error('stagingPath is required'); 348 | } 349 | if (!rootDir) { 350 | throw new Error('rootDir is required'); 351 | } 352 | if (!await this.fsExtra.pathExists(rootDir)) { 353 | throw new Error(`rootDir does not exist at "${rootDir}"`); 354 | } 355 | 356 | let fileObjects = await this.getFilePaths(files, rootDir); 357 | //copy all of the files 358 | await Promise.all(fileObjects.map(async (fileObject) => { 359 | let destFilePath = util.standardizePath(`${stagingPath}/${fileObject.dest}`); 360 | 361 | //make sure the containing folder exists 362 | await this.fsExtra.ensureDir(path.dirname(destFilePath)); 363 | 364 | //sometimes the copyfile action fails due to race conditions (normally to poorly constructed src;dest; objects with duplicate files in them 365 | await util.tryRepeatAsync(async () => { 366 | //copy the src item using the filesystem 367 | await this.fsExtra.copy(fileObject.src, destFilePath, { 368 | //copy the actual files that symlinks point to, not the symlinks themselves 369 | dereference: true 370 | }); 371 | }, 10); 372 | })); 373 | } 374 | 375 | private generateBaseRequestOptions(requestPath: string, options: RokuDeployOptions, formData = {} as T): requestType.OptionsWithUrl { 376 | options = this.getOptions(options); 377 | let url = `http://${options.host}:${options.packagePort}/${requestPath}`; 378 | let baseRequestOptions = { 379 | url: url, 380 | timeout: options.timeout, 381 | auth: { 382 | user: options.username, 383 | pass: options.password, 384 | sendImmediately: false 385 | }, 386 | formData: formData, 387 | agentOptions: { 'keepAlive': false } 388 | }; 389 | return baseRequestOptions; 390 | } 391 | 392 | /** 393 | * Simulate pressing the home button on the remote for this roku. 394 | * This makes the roku return to the home screen 395 | * @param host - the host 396 | * @param port - the port that should be used for the request. defaults to 8060 397 | * @param timeout - request timeout duration in milliseconds. defaults to 150000 398 | */ 399 | public async pressHomeButton(host, port?: number, timeout?: number) { 400 | let options = this.getOptions(); 401 | port = port ? port : options.remotePort; 402 | timeout = timeout ? timeout : options.timeout; 403 | // press the home button to return to the main screen 404 | return this.doPostRequest({ 405 | url: `http://${host}:${port}/keypress/Home`, 406 | timeout: timeout 407 | }, false); 408 | } 409 | 410 | /** 411 | * Publish a pre-existing packaged zip file to a remote Roku. 412 | * @param options 413 | */ 414 | public async publish(options: RokuDeployOptions): Promise<{ message: string; results: any }> { 415 | options = this.getOptions(options); 416 | if (!options.host) { 417 | throw new errors.MissingRequiredOptionError('must specify the host for the Roku device'); 418 | } 419 | //make sure the outDir exists 420 | await this.fsExtra.ensureDir(options.outDir); 421 | 422 | let zipFilePath = this.getOutputZipFilePath(options); 423 | let readStream: _fsExtra.ReadStream; 424 | try { 425 | if ((await this.fsExtra.pathExists(zipFilePath)) === false) { 426 | throw new Error(`Cannot publish because file does not exist at '${zipFilePath}'`); 427 | } 428 | readStream = this.fsExtra.createReadStream(zipFilePath); 429 | //wait for the stream to open (no harm in doing this, and it helps solve an issue in the tests) 430 | await new Promise((resolve) => { 431 | readStream.on('open', resolve); 432 | }); 433 | 434 | const route = options.packageUploadOverrides?.route ?? 'plugin_install'; 435 | 436 | let requestOptions = this.generateBaseRequestOptions(route, options, { 437 | mysubmit: 'Replace', 438 | archive: readStream 439 | }); 440 | 441 | //attach the remotedebug flag if configured 442 | if (options.remoteDebug) { 443 | requestOptions.formData.remotedebug = '1'; 444 | } 445 | 446 | //attach the remotedebug_connect_early if present 447 | if (options.remoteDebugConnectEarly) { 448 | // eslint-disable-next-line camelcase 449 | requestOptions.formData.remotedebug_connect_early = '1'; 450 | } 451 | 452 | //apply any supplied formData overrides 453 | for (const key in options.packageUploadOverrides?.formData ?? {}) { 454 | const value = options.packageUploadOverrides.formData[key]; 455 | if (value === undefined || value === null) { 456 | delete requestOptions.formData[key]; 457 | } else { 458 | requestOptions.formData[key] = value; 459 | } 460 | } 461 | 462 | //try to "replace" the channel first since that usually works. 463 | let response: HttpResponse; 464 | try { 465 | try { 466 | response = await this.doPostRequest(requestOptions); 467 | } catch (replaceError: any) { 468 | //fail if this is a compile error 469 | if (this.isCompileError(replaceError.message) && options.failOnCompileError) { 470 | throw new errors.CompileError('Compile error', replaceError, replaceError.results); 471 | } else if (this.isUpdateRequiredError(replaceError)) { 472 | throw replaceError; 473 | } else { 474 | requestOptions.formData.mysubmit = 'Install'; 475 | response = await this.doPostRequest(requestOptions); 476 | } 477 | } 478 | } catch (e: any) { 479 | //if this is a 577 error, we have high confidence that the device needs to do an update check 480 | if (this.isUpdateRequiredError(e)) { 481 | throw new errors.UpdateCheckRequiredError(response, requestOptions, e); 482 | 483 | //a reset connection could be cause by several things, but most likely it's due to the device needing to check for updates 484 | } else if (e.code === 'ECONNRESET') { 485 | throw new errors.ConnectionResetError(e, requestOptions); 486 | } else { 487 | throw e; 488 | } 489 | } 490 | 491 | //if we got a non-error status code, but the body includes a message about needing to update, throw a special error 492 | if (this.isUpdateCheckRequiredResponse(response.body)) { 493 | throw new errors.UpdateCheckRequiredError(response, requestOptions); 494 | } 495 | 496 | if (options.failOnCompileError) { 497 | if (this.isCompileError(response.body)) { 498 | throw new errors.CompileError('Compile error', response, this.getRokuMessagesFromResponseBody(response.body)); 499 | } 500 | } 501 | 502 | if (response.body.indexOf('Identical to previous version -- not replacing.') > -1) { 503 | return { message: 'Identical to previous version -- not replacing', results: response }; 504 | } 505 | return { message: 'Successful deploy', results: response }; 506 | } finally { 507 | //delete the zip file only if configured to do so 508 | if (options.retainDeploymentArchive === false) { 509 | await this.fsExtra.remove(zipFilePath); 510 | } 511 | //try to close the read stream to prevent files becoming locked 512 | try { 513 | readStream?.close(); 514 | } catch (e) { 515 | this.logger.info('Error closing read stream', e); 516 | } 517 | } 518 | } 519 | 520 | /** 521 | * Does the response look like a compile error 522 | */ 523 | private isCompileError(responseHtml: string) { 524 | return !!/install\sfailure:\scompilation\sfailed/i.exec(responseHtml); 525 | } 526 | 527 | /** 528 | * Does the response look like a compile error 529 | */ 530 | private isUpdateCheckRequiredResponse(responseHtml: string) { 531 | return !!/["']\s*Failed\s*to\s*check\s*for\s*software\s*update\s*["']/i.exec(responseHtml); 532 | } 533 | 534 | /** 535 | * Checks to see if the exception is due to the device needing to check for updates 536 | */ 537 | private isUpdateRequiredError(e: any): boolean { 538 | return e.results?.response?.statusCode === 577 || (typeof e.results?.body === 'string' && this.isUpdateCheckRequiredResponse(e.results.body)); 539 | } 540 | 541 | /** 542 | * Converts existing loaded package to squashfs for faster loading packages 543 | * @param options 544 | */ 545 | public async convertToSquashfs(options: RokuDeployOptions) { 546 | options = this.getOptions(options); 547 | if (!options.host) { 548 | throw new errors.MissingRequiredOptionError('must specify the host for the Roku device'); 549 | } 550 | let requestOptions = this.generateBaseRequestOptions('plugin_install', options, { 551 | archive: '', 552 | mysubmit: 'Convert to squashfs' 553 | }); 554 | let results; 555 | try { 556 | results = await this.doPostRequest(requestOptions); 557 | } catch (error) { 558 | //Occasionally this error is seen if the zip size and file name length at the 559 | //wrong combination. The device fails to respond to our request with a valid response. 560 | //The device successfully converted the zip, so ping the device and and check the response 561 | //for "fileType": "squashfs" then return a happy response, otherwise throw the original error 562 | if ((error as any).code === 'HPE_INVALID_CONSTANT') { 563 | try { 564 | results = await this.doPostRequest(requestOptions, false); 565 | if (/"fileType"\s*:\s*"squashfs"/.test(results.body)) { 566 | return results; 567 | } 568 | } catch (e) { 569 | throw error; 570 | } 571 | } else { 572 | throw error; 573 | } 574 | } 575 | if (results.body.indexOf('Conversion succeeded') === -1) { 576 | throw new errors.ConvertError('Squashfs conversion failed'); 577 | } 578 | } 579 | 580 | /** 581 | * resign Roku Device with supplied pkg and 582 | * @param options 583 | */ 584 | public async rekeyDevice(options: RokuDeployOptions) { 585 | options = this.getOptions(options); 586 | if (!options.rekeySignedPackage) { 587 | throw new errors.MissingRequiredOptionError('Must supply rekeySignedPackage'); 588 | } 589 | 590 | if (!options.signingPassword) { 591 | throw new errors.MissingRequiredOptionError('Must supply signingPassword'); 592 | } 593 | 594 | let rekeySignedPackagePath = options.rekeySignedPackage; 595 | if (!path.isAbsolute(options.rekeySignedPackage)) { 596 | rekeySignedPackagePath = path.join(options.rootDir, options.rekeySignedPackage); 597 | } 598 | let requestOptions = this.generateBaseRequestOptions('plugin_inspect', options, { 599 | mysubmit: 'Rekey', 600 | passwd: options.signingPassword, 601 | archive: null as _fsExtra.ReadStream 602 | }); 603 | 604 | let results: HttpResponse; 605 | try { 606 | requestOptions.formData.archive = this.fsExtra.createReadStream(rekeySignedPackagePath); 607 | results = await this.doPostRequest(requestOptions); 608 | } finally { 609 | //ensure the stream is closed 610 | try { 611 | requestOptions.formData.archive?.close(); 612 | } catch { } 613 | } 614 | 615 | let resultTextSearch = /([^<]+)<\/font>/.exec(results.body); 616 | if (!resultTextSearch) { 617 | throw new errors.UnparsableDeviceResponseError('Unknown Rekey Failure'); 618 | } 619 | 620 | if (resultTextSearch[1] !== 'Success.') { 621 | throw new errors.FailedDeviceResponseError('Rekey Failure: ' + resultTextSearch[1]); 622 | } 623 | 624 | if (options.devId) { 625 | let devId = await this.getDevId(options); 626 | 627 | if (devId !== options.devId) { 628 | throw new errors.UnknownDeviceResponseError('Rekey was successful but resulting Dev ID "' + devId + '" did not match expected value of "' + options.devId + '"'); 629 | } 630 | } 631 | } 632 | 633 | /** 634 | * Sign a pre-existing package using Roku and return path to retrieve it 635 | * @param options 636 | */ 637 | public async signExistingPackage(options: RokuDeployOptions): Promise { 638 | options = this.getOptions(options); 639 | if (!options.signingPassword) { 640 | throw new errors.MissingRequiredOptionError('Must supply signingPassword'); 641 | } 642 | let manifestPath = path.join(options.stagingDir, 'manifest'); 643 | let parsedManifest = await this.parseManifest(manifestPath); 644 | let appName = parsedManifest.title + '/' + parsedManifest.major_version + '.' + parsedManifest.minor_version; 645 | 646 | let requestOptions = this.generateBaseRequestOptions('plugin_package', options, { 647 | mysubmit: 'Package', 648 | pkg_time: (new Date()).getTime(), //eslint-disable-line camelcase 649 | passwd: options.signingPassword, 650 | app_name: appName //eslint-disable-line camelcase 651 | }); 652 | 653 | let results = await this.doPostRequest(requestOptions); 654 | 655 | let failedSearchMatches = /Failed: (.*)/.exec(results.body); 656 | if (failedSearchMatches) { 657 | throw new errors.FailedDeviceResponseError(failedSearchMatches[1], results); 658 | } 659 | 660 | //grab the package url from the JSON on the page if it exists (https://regex101.com/r/1HUXgk/1) 661 | let pkgSearchMatches = /"pkgPath"\s*:\s*"(.*?)"/.exec(results.body); 662 | if (pkgSearchMatches) { 663 | return pkgSearchMatches[1]; 664 | } 665 | //for some reason we couldn't find the pkgPath from json, look in the tag 666 | pkgSearchMatches = //.exec(results.body); 667 | if (pkgSearchMatches) { 668 | return pkgSearchMatches[1]; 669 | } 670 | 671 | throw new errors.UnknownDeviceResponseError('Unknown error signing package', results); 672 | } 673 | 674 | /** 675 | * Sign a pre-existing package using Roku and return path to retrieve it 676 | * @param pkgPath 677 | * @param options 678 | */ 679 | public async retrieveSignedPackage(pkgPath: string, options: RokuDeployOptions): Promise { 680 | options = this.getOptions(options); 681 | let requestOptions = this.generateBaseRequestOptions(pkgPath, options); 682 | 683 | let pkgFilePath = this.getOutputPkgFilePath(options); 684 | return this.getToFile(requestOptions, pkgFilePath); 685 | } 686 | 687 | /** 688 | * Centralized function for handling POST http requests 689 | * @param params 690 | */ 691 | private async doPostRequest(params: any, verify = true) { 692 | let results: { response: any; body: any } = await new Promise((resolve, reject) => { 693 | request.post(params, (err, resp, body) => { 694 | if (err) { 695 | return reject(err); 696 | } 697 | return resolve({ response: resp, body: body }); 698 | }); 699 | }); 700 | if (verify) { 701 | this.checkRequest(results); 702 | } 703 | return results as HttpResponse; 704 | } 705 | 706 | /** 707 | * Centralized function for handling GET http requests 708 | * @param params 709 | */ 710 | private async doGetRequest(params: requestType.OptionsWithUrl) { 711 | let results: { response: any; body: any } = await new Promise((resolve, reject) => { 712 | request.get(params, (err, resp, body) => { 713 | if (err) { 714 | return reject(err); 715 | } 716 | return resolve({ response: resp, body: body }); 717 | }); 718 | }); 719 | this.checkRequest(results); 720 | return results as HttpResponse; 721 | } 722 | 723 | private checkRequest(results) { 724 | if (!results || !results.response || typeof results.body !== 'string') { 725 | throw new errors.UnparsableDeviceResponseError('Invalid response', results); 726 | } 727 | 728 | if (results.response.statusCode === 401) { 729 | const host = results.response.request?.host?.toString?.(); 730 | throw new errors.UnauthorizedDeviceResponseError(`Unauthorized. Please verify credentials for host '${host}'`, results); 731 | } 732 | 733 | let rokuMessages = this.getRokuMessagesFromResponseBody(results.body); 734 | 735 | if (rokuMessages.errors.length > 0) { 736 | throw new errors.FailedDeviceResponseError(rokuMessages.errors[0], rokuMessages); 737 | } 738 | 739 | if (results.response.statusCode !== 200) { 740 | throw new errors.InvalidDeviceResponseCodeError('Invalid response code: ' + results.response.statusCode, results); 741 | } 742 | } 743 | 744 | private getRokuMessagesFromResponseBody(body: string): RokuMessages { 745 | const result = { 746 | errors: [] as string[], 747 | infos: [] as string[], 748 | successes: [] as string[] 749 | }; 750 | let errorRegex = /Shell\.create\('Roku\.Message'\)\.trigger\('[\w\s]+',\s+'(\w+)'\)\.trigger\('[\w\s]+',\s+'(.*?)'\)/igm; 751 | let match: RegExpExecArray; 752 | 753 | while ((match = errorRegex.exec(body))) { 754 | let [, messageType, message] = match; 755 | switch (messageType.toLowerCase()) { 756 | case RokuMessageType.error: 757 | if (!result.errors.includes(message)) { 758 | result.errors.push(message); 759 | } 760 | break; 761 | 762 | case RokuMessageType.info: 763 | if (!result.infos.includes(message)) { 764 | result.infos.push(message); 765 | } 766 | break; 767 | 768 | case RokuMessageType.success: 769 | if (!result.successes.includes(message)) { 770 | result.successes.push(message); 771 | } 772 | break; 773 | 774 | default: 775 | break; 776 | } 777 | } 778 | 779 | let jsonParseRegex = /JSON\.parse\(('.+')\);/igm; 780 | let jsonMatch: RegExpExecArray; 781 | 782 | while ((jsonMatch = jsonParseRegex.exec(body))) { 783 | let [, jsonString] = jsonMatch; 784 | let jsonObject = parseJsonc(jsonString); 785 | if (typeof jsonObject === 'object' && !Array.isArray(jsonObject) && jsonObject !== null) { 786 | let messages = jsonObject.messages; 787 | 788 | if (!Array.isArray(messages)) { 789 | continue; 790 | } 791 | 792 | for (let messageObject of messages) { 793 | // Try to duck type the object to make sure it is some form of message to be displayed 794 | if (typeof messageObject.type === 'string' && messageObject.text_type === 'text' && typeof messageObject.text === 'string') { 795 | const messageType: string = messageObject.type; 796 | const text: string = messageObject.text; 797 | switch (messageType.toLowerCase()) { 798 | case RokuMessageType.error: 799 | if (!result.errors.includes(text)) { 800 | result.errors.push(text); 801 | } 802 | break; 803 | 804 | case RokuMessageType.info: 805 | if (!result.infos.includes(text)) { 806 | result.infos.push(text); 807 | } 808 | break; 809 | 810 | case RokuMessageType.success: 811 | if (!result.successes.includes(text)) { 812 | result.successes.push(text); 813 | } 814 | 815 | break; 816 | 817 | default: 818 | break; 819 | } 820 | } 821 | } 822 | } 823 | 824 | } 825 | 826 | return result; 827 | } 828 | 829 | /** 830 | * Create a zip of the project, and then publish to the target Roku device 831 | * @param options 832 | */ 833 | public async deploy(options?: RokuDeployOptions, beforeZipCallback?: (info: BeforeZipCallbackInfo) => void) { 834 | options = this.getOptions(options); 835 | await this.createPackage(options, beforeZipCallback); 836 | if (options.deleteInstalledChannel) { 837 | try { 838 | await this.deleteInstalledChannel(options); 839 | } catch (e) { 840 | // note we don't report the error; as we don't actually care that we could not deploy - it's just useless noise to log it. 841 | } 842 | } 843 | let result = await this.publish(options); 844 | return result; 845 | } 846 | 847 | /** 848 | * Deletes any installed dev channel on the target Roku device 849 | * @param options 850 | */ 851 | public async deleteInstalledChannel(options?: RokuDeployOptions) { 852 | options = this.getOptions(options); 853 | 854 | let deleteOptions = this.generateBaseRequestOptions('plugin_install', options); 855 | deleteOptions.formData = { 856 | mysubmit: 'Delete', 857 | archive: '' 858 | }; 859 | return this.doPostRequest(deleteOptions); 860 | } 861 | 862 | /** 863 | * Gets a screenshot from the device. A side-loaded channel must be running or an error will be thrown. 864 | */ 865 | public async takeScreenshot(options: TakeScreenshotOptions) { 866 | options.outDir = options.outDir ?? this.screenshotDir; 867 | options.outFile = options.outFile ?? `screenshot-${dayjs().format('YYYY-MM-DD-HH.mm.ss.SSS')}`; 868 | let saveFilePath: string; 869 | 870 | // Ask for the device to make an image 871 | let createScreenshotResult = await this.doPostRequest({ 872 | ...this.generateBaseRequestOptions('plugin_inspect', options), 873 | formData: { 874 | mysubmit: 'Screenshot', 875 | archive: '' 876 | } 877 | }); 878 | 879 | // Pull the image url out of the response body 880 | const [_, imageUrlOnDevice, imageExt] = /["'](pkgs\/dev(\.jpg|\.png)\?.+?)['"]/gi.exec(createScreenshotResult.body) ?? []; 881 | 882 | if (imageUrlOnDevice) { 883 | saveFilePath = util.standardizePath(path.join(options.outDir, options.outFile + imageExt)); 884 | await this.getToFile( 885 | this.generateBaseRequestOptions(imageUrlOnDevice, options), 886 | saveFilePath 887 | ); 888 | } else { 889 | throw new Error('No screen shot url returned from device'); 890 | } 891 | return saveFilePath; 892 | } 893 | 894 | private async getToFile(requestParams: any, filePath: string) { 895 | let writeStream: _fsExtra.WriteStream; 896 | await this.fsExtra.ensureFile(filePath); 897 | return new Promise((resolve, reject) => { 898 | writeStream = this.fsExtra.createWriteStream(filePath, { 899 | flags: 'w' 900 | }); 901 | if (!writeStream) { 902 | reject(new Error(`Unable to create write stream for "${filePath}"`)); 903 | return; 904 | } 905 | //when the file has finished writing to disk, we can finally resolve and say we're done 906 | writeStream.on('finish', () => { 907 | resolve(filePath); 908 | }); 909 | //if anything does wrong with the write stream, reject the promise 910 | writeStream.on('error', (error) => { 911 | reject(error); 912 | }); 913 | 914 | request.get(requestParams).on('error', (err) => { 915 | reject(err); 916 | }).on('response', (response) => { 917 | if (response.statusCode !== 200) { 918 | return reject(new Error('Invalid response code: ' + response.statusCode)); 919 | } 920 | }).pipe(writeStream); 921 | 922 | }).finally(() => { 923 | try { 924 | writeStream.close(); 925 | } catch { } 926 | }); 927 | } 928 | 929 | /** 930 | * executes sames steps as deploy and signs the package and stores it in the out folder 931 | * @param options 932 | */ 933 | public async deployAndSignPackage(options?: RokuDeployOptions, beforeZipCallback?: (info: BeforeZipCallbackInfo) => void): Promise { 934 | options = this.getOptions(options); 935 | let retainStagingDirInitialValue = options.retainStagingDir; 936 | options.retainStagingDir = true; 937 | await this.deploy(options, beforeZipCallback); 938 | 939 | if (options.convertToSquashfs) { 940 | await this.convertToSquashfs(options); 941 | } 942 | 943 | let remotePkgPath = await this.signExistingPackage(options); 944 | let localPkgFilePath = await this.retrieveSignedPackage(remotePkgPath, options); 945 | if (retainStagingDirInitialValue !== true) { 946 | await this.fsExtra.remove(options.stagingDir); 947 | } 948 | return localPkgFilePath; 949 | } 950 | 951 | /** 952 | * Get an options with all overridden vaues, and then defaults for missing values 953 | * @param options 954 | */ 955 | public getOptions(options: RokuDeployOptions = {}) { 956 | let fileOptions: RokuDeployOptions = {}; 957 | const fileNames = ['rokudeploy.json', 'bsconfig.json']; 958 | if (options.project) { 959 | fileNames.unshift(options.project); 960 | } 961 | 962 | for (const fileName of fileNames) { 963 | if (this.fsExtra.existsSync(fileName)) { 964 | let configFileText = this.fsExtra.readFileSync(fileName).toString(); 965 | let parseErrors = [] as ParseError[]; 966 | fileOptions = parseJsonc(configFileText, parseErrors, { 967 | allowEmptyContent: true, 968 | allowTrailingComma: true, 969 | disallowComments: false 970 | }); 971 | if (parseErrors.length > 0) { 972 | throw new Error(`Error parsing "${path.resolve(fileName)}": ` + JSON.stringify( 973 | parseErrors.map(x => { 974 | return { 975 | message: printParseErrorCode(x.error), 976 | offset: x.offset, 977 | length: x.length 978 | }; 979 | }) 980 | )); 981 | } 982 | break; 983 | } 984 | } 985 | 986 | let defaultOptions = { 987 | outDir: './out', 988 | outFile: 'roku-deploy', 989 | retainDeploymentArchive: true, 990 | incrementBuildNumber: false, 991 | failOnCompileError: true, 992 | deleteInstalledChannel: true, 993 | packagePort: 80, 994 | remotePort: 8060, 995 | timeout: 150000, 996 | rootDir: './', 997 | files: [...DefaultFiles], 998 | username: 'rokudev', 999 | logLevel: LogLevel.log 1000 | }; 1001 | 1002 | //override the defaults with any found or provided options 1003 | let finalOptions = { ...defaultOptions, ...fileOptions, ...options }; 1004 | this.logger.logLevel = finalOptions.logLevel; 1005 | 1006 | //fully resolve the folder paths 1007 | finalOptions.rootDir = path.resolve(process.cwd(), finalOptions.rootDir); 1008 | finalOptions.outDir = path.resolve(process.cwd(), finalOptions.outDir); 1009 | finalOptions.retainStagingDir = (finalOptions.retainStagingDir !== undefined) ? finalOptions.retainStagingDir : finalOptions.retainStagingFolder; 1010 | //sync the new option with the old one (for back-compat) 1011 | finalOptions.retainStagingFolder = finalOptions.retainStagingDir; 1012 | 1013 | let stagingDir = finalOptions.stagingDir || finalOptions.stagingFolderPath; 1014 | 1015 | //stagingDir 1016 | if (stagingDir) { 1017 | finalOptions.stagingDir = path.resolve(process.cwd(), stagingDir); 1018 | } else { 1019 | finalOptions.stagingDir = path.resolve( 1020 | process.cwd(), 1021 | util.standardizePath(`${finalOptions.outDir}/.roku-deploy-staging`) 1022 | ); 1023 | } 1024 | //sync the new option with the old one (for back-compat) 1025 | finalOptions.stagingFolderPath = finalOptions.stagingDir; 1026 | 1027 | return finalOptions; 1028 | } 1029 | 1030 | /** 1031 | * Centralizes getting output zip file path based on passed in options 1032 | * @param options 1033 | */ 1034 | public getOutputZipFilePath(options: RokuDeployOptions) { 1035 | options = this.getOptions(options); 1036 | 1037 | let zipFileName = options.outFile; 1038 | if (!zipFileName.toLowerCase().endsWith('.zip') && !zipFileName.toLowerCase().endsWith('.squashfs')) { 1039 | zipFileName += '.zip'; 1040 | } 1041 | let outFolderPath = path.resolve(options.outDir); 1042 | 1043 | let outZipFilePath = path.join(outFolderPath, zipFileName); 1044 | return outZipFilePath; 1045 | } 1046 | 1047 | /** 1048 | * Centralizes getting output pkg file path based on passed in options 1049 | * @param options 1050 | */ 1051 | public getOutputPkgFilePath(options?: RokuDeployOptions) { 1052 | options = this.getOptions(options); 1053 | 1054 | let pkgFileName = options.outFile; 1055 | if (pkgFileName.toLowerCase().endsWith('.zip')) { 1056 | pkgFileName = pkgFileName.replace('.zip', '.pkg'); 1057 | } else { 1058 | pkgFileName += '.pkg'; 1059 | } 1060 | let outFolderPath = path.resolve(options.outDir); 1061 | 1062 | let outPkgFilePath = path.join(outFolderPath, pkgFileName); 1063 | return outPkgFilePath; 1064 | } 1065 | 1066 | /** 1067 | * Get the `device-info` response from a Roku device 1068 | * @param host the host or IP address of the Roku 1069 | * @param port the port to use for the ECP request (defaults to 8060) 1070 | */ 1071 | public async getDeviceInfo(options?: { enhance: true } & GetDeviceInfoOptions): Promise; 1072 | public async getDeviceInfo(options?: GetDeviceInfoOptions): Promise 1073 | public async getDeviceInfo(options: GetDeviceInfoOptions) { 1074 | options = this.getOptions(options) as any; 1075 | 1076 | //if the host is a DNS name, look up the IP address 1077 | try { 1078 | options.host = await util.dnsLookup(options.host); 1079 | } catch (e) { 1080 | //try using the host as-is (it'll probably fail...) 1081 | } 1082 | 1083 | const url = `http://${options.host}:${options.remotePort}/query/device-info`; 1084 | 1085 | let response = await this.doGetRequest({ 1086 | url: url, 1087 | timeout: options.timeout, 1088 | headers: { 1089 | 'User-Agent': 'https://github.com/RokuCommunity/roku-deploy' 1090 | } 1091 | }); 1092 | try { 1093 | const parsedContent = await xml2js.parseStringPromise(response.body, { 1094 | explicitArray: false 1095 | }); 1096 | // clone the data onto an object because xml2js somehow makes this object not an object??? 1097 | let deviceInfo = { 1098 | ...parsedContent['device-info'] 1099 | } as Record; 1100 | 1101 | if (options.enhance) { 1102 | const result = {}; 1103 | // sanitize/normalize values to their native formats, and also convert property names to camelCase 1104 | for (let key in deviceInfo) { 1105 | result[lodash.camelCase(key)] = this.normalizeDeviceInfoFieldValue(deviceInfo[key]); 1106 | } 1107 | deviceInfo = result; 1108 | } 1109 | return deviceInfo; 1110 | } catch (e) { 1111 | throw new errors.UnparsableDeviceResponseError('Could not retrieve device info', response); 1112 | } 1113 | } 1114 | 1115 | /** 1116 | * Normalize a deviceInfo field value. This includes things like converting boolean strings to booleans, number strings to numbers, 1117 | * decoding HtmlEntities, etc. 1118 | * @param deviceInfo 1119 | */ 1120 | public normalizeDeviceInfoFieldValue(value: any) { 1121 | let num: number; 1122 | // convert 'true' and 'false' string values to boolean 1123 | if (value === 'true') { 1124 | return true; 1125 | } else if (value === 'false') { 1126 | return false; 1127 | } else if (value.trim() !== '' && !isNaN(num = Number(value))) { 1128 | return num; 1129 | } else { 1130 | return util.decodeHtmlEntities(value); 1131 | } 1132 | } 1133 | 1134 | public async getDevId(options?: RokuDeployOptions) { 1135 | const deviceInfo = await this.getDeviceInfo(options as any); 1136 | return deviceInfo['keyed-developer-id']; 1137 | } 1138 | 1139 | public async parseManifest(manifestPath: string): Promise { 1140 | if (!await this.fsExtra.pathExists(manifestPath)) { 1141 | throw new Error(manifestPath + ' does not exist'); 1142 | } 1143 | 1144 | let manifestContents = await this.fsExtra.readFile(manifestPath, 'utf-8'); 1145 | return this.parseManifestFromString(manifestContents); 1146 | } 1147 | 1148 | public parseManifestFromString(manifestContents: string): ManifestData { 1149 | let manifestLines = manifestContents.split('\n'); 1150 | let manifestData: ManifestData = {}; 1151 | manifestData.keyIndexes = {}; 1152 | manifestData.lineCount = manifestLines.length; 1153 | manifestLines.forEach((line, index) => { 1154 | let match = /(\w+)=(.+)/.exec(line); 1155 | if (match) { 1156 | let key = match[1]; 1157 | manifestData[key] = match[2]; 1158 | manifestData.keyIndexes[key] = index; 1159 | } 1160 | }); 1161 | 1162 | return manifestData; 1163 | } 1164 | 1165 | public stringifyManifest(manifestData: ManifestData): string { 1166 | let output = []; 1167 | 1168 | if (manifestData.keyIndexes && manifestData.lineCount) { 1169 | output.fill('', 0, manifestData.lineCount); 1170 | 1171 | let key; 1172 | for (key in manifestData) { 1173 | if (key === 'lineCount' || key === 'keyIndexes') { 1174 | continue; 1175 | } 1176 | 1177 | let index = manifestData.keyIndexes[key]; 1178 | output[index] = `${key}=${manifestData[key]}`; 1179 | } 1180 | } else { 1181 | output = Object.keys(manifestData).map((key) => { 1182 | return `${key}=${manifestData[key]}`; 1183 | }); 1184 | } 1185 | 1186 | return output.join('\n'); 1187 | } 1188 | 1189 | /** 1190 | * Given a path to a folder, zip up that folder and all of its contents 1191 | * @param srcFolder the folder that should be zipped 1192 | * @param zipFilePath the path to the zip that will be created 1193 | * @param preZipCallback a function to call right before every file gets added to the zip 1194 | * @param files a files array used to filter the files from `srcFolder` 1195 | */ 1196 | public async zipFolder(srcFolder: string, zipFilePath: string, preFileZipCallback?: (file: StandardizedFileEntry, data: Buffer) => Buffer, files: FileEntry[] = ['**/*']) { 1197 | const filePaths = await this.getFilePaths(files, srcFolder); 1198 | 1199 | const zip = new JSZip(); 1200 | // Allows us to wait until all are done before we build the zip 1201 | const promises = []; 1202 | for (const file of filePaths) { 1203 | const promise = this.fsExtra.readFile(file.src).then((data) => { 1204 | if (preFileZipCallback) { 1205 | data = preFileZipCallback(file, data); 1206 | } 1207 | 1208 | const ext = path.extname(file.dest).toLowerCase(); 1209 | let compression = 'DEFLATE'; 1210 | 1211 | if (ext === '.jpg' || ext === '.png' || ext === '.jpeg') { 1212 | compression = 'STORE'; 1213 | } 1214 | zip.file(file.dest.replace(/[\\/]/g, '/'), data, { 1215 | compression: compression 1216 | }); 1217 | }); 1218 | promises.push(promise); 1219 | } 1220 | await Promise.all(promises); 1221 | // level 2 compression seems to be the best balance between speed and file size. Speed matters more since most will be calling squashfs afterwards. 1222 | const content = await zip.generateAsync({ type: 'nodebuffer', compressionOptions: { level: 2 } }); 1223 | return this.fsExtra.outputFile(zipFilePath, content); 1224 | } 1225 | } 1226 | 1227 | export interface ManifestData { 1228 | [key: string]: any; 1229 | keyIndexes?: Record; 1230 | lineCount?: number; 1231 | } 1232 | 1233 | export interface BeforeZipCallbackInfo { 1234 | /** 1235 | * Contains an associative array of the parsed values in the manifest 1236 | */ 1237 | manifestData: ManifestData; 1238 | /** 1239 | * @deprecated since 3.9.0. use `stagingDir` instead 1240 | */ 1241 | stagingFolderPath: string; 1242 | /** 1243 | * The directory where the files were staged 1244 | */ 1245 | stagingDir: string; 1246 | } 1247 | 1248 | export interface StandardizedFileEntry { 1249 | /** 1250 | * The full path to the source file 1251 | */ 1252 | src: string; 1253 | /** 1254 | * The path relative to the root of the pkg to where the file should be placed 1255 | */ 1256 | dest: string; 1257 | } 1258 | 1259 | export interface RokuMessages { 1260 | errors: string[]; 1261 | infos: string[]; 1262 | successes: string[]; 1263 | } 1264 | 1265 | enum RokuMessageType { 1266 | success = 'success', 1267 | info = 'info', 1268 | error = 'error' 1269 | } 1270 | 1271 | export const DefaultFiles = [ 1272 | 'source/**/*.*', 1273 | 'components/**/*.*', 1274 | 'images/**/*.*', 1275 | 'manifest' 1276 | ]; 1277 | 1278 | export interface HttpResponse { 1279 | response: any; 1280 | body: any; 1281 | } 1282 | 1283 | export interface TakeScreenshotOptions { 1284 | /** 1285 | * The IP address or hostname of the target Roku device. 1286 | * @example '192.168.1.21' 1287 | */ 1288 | host: string; 1289 | 1290 | /** 1291 | * The password for logging in to the developer portal on the target Roku device 1292 | */ 1293 | password: string; 1294 | 1295 | /** 1296 | * A full path to the folder where the screenshots should be saved. 1297 | * Will use the OS temp directory by default 1298 | */ 1299 | outDir?: string; 1300 | 1301 | /** 1302 | * The base filename the image file should be given (excluding the extension) 1303 | * The default format looks something like this: screenshot-YYYY-MM-DD-HH.mm.ss.SSS. 1304 | */ 1305 | outFile?: string; 1306 | } 1307 | 1308 | export interface GetDeviceInfoOptions { 1309 | /** 1310 | * The hostname or IP address to use for the device-info URL 1311 | */ 1312 | host: string; 1313 | /** 1314 | * The port to use to send the device-info request (defaults to the standard 8060 ECP port) 1315 | */ 1316 | remotePort?: number; 1317 | /** 1318 | * The number of milliseconds at which point this request should timeout and return a rejected promise 1319 | */ 1320 | timeout?: number; 1321 | /** 1322 | * Should the device-info be enhanced by camel-casing the property names and converting boolean strings to booleans and number strings to numbers? 1323 | * @default false 1324 | */ 1325 | enhance?: boolean; 1326 | } 1327 | -------------------------------------------------------------------------------- /src/RokuDeployOptions.ts: -------------------------------------------------------------------------------- 1 | import type { LogLevel } from './Logger'; 2 | 3 | export interface RokuDeployOptions { 4 | /** 5 | * Path to a bsconfig.json project file 6 | */ 7 | project?: string; 8 | 9 | /** 10 | * A full path to the folder where the zip/pkg package should be placed 11 | * @default './out' 12 | */ 13 | outDir?: string; 14 | 15 | /** 16 | * The base filename the zip/pkg file should be given (excluding the extension) 17 | * @default 'roku-deploy' 18 | */ 19 | outFile?: string; 20 | 21 | /** 22 | * The root path to the folder holding your Roku project's source files (manifest, components/, source/ should be directly under this folder) 23 | * @default './' 24 | */ 25 | rootDir?: string; 26 | 27 | /** 28 | * An array of source file paths, source file globs, or {src,dest} objects indicating 29 | * where the source files are and where they should be placed 30 | * in the output directory 31 | * @default [ 32 | 'source/**\/*.*', 33 | 'components/**\/*.*', 34 | 'images/**\/*.*', 35 | 'manifest' 36 | ], 37 | */ 38 | files?: FileEntry[]; 39 | 40 | /** 41 | * Set this to true to prevent the staging folder from being deleted after creating the package 42 | * @default false 43 | * @deprecated use `retainStagingDir` instead 44 | */ 45 | retainStagingFolder?: boolean; 46 | 47 | /** 48 | * Set this to true to prevent the staging folder from being deleted after creating the package 49 | * @default false 50 | */ 51 | retainStagingDir?: boolean; 52 | 53 | /** 54 | * Should the zipped package be retained after deploying to a roku. If false, this will delete the zip after a deployment. 55 | * @default true 56 | */ 57 | retainDeploymentArchive?: boolean; 58 | 59 | /** 60 | * The path where roku-deploy should stage all of the files right before being zipped. defaults to ${outDir}/.roku-deploy-staging 61 | * @deprecated since 3.9.0. use `stagingDir` instead 62 | */ 63 | stagingFolderPath?: string; 64 | 65 | /** 66 | * The path where roku-deploy should stage all of the files right before being zipped. defaults to ${outDir}/.roku-deploy-staging 67 | */ 68 | stagingDir?: string; 69 | 70 | /** 71 | * The IP address or hostname of the target Roku device. 72 | * @example '192.168.1.21' 73 | */ 74 | host?: string; 75 | 76 | /** 77 | * The port that should be used when installing the package. Defaults to 80. 78 | * This is mainly useful for things like emulators that use alternate ports, 79 | * or when publishing through some type of port forwarding configuration. 80 | */ 81 | packagePort?: number; 82 | 83 | /** 84 | * When publishing a side loaded channel this flag can be used to enable the socket based BrightScript debug protocol. Defaults to false. 85 | * More information on the BrightScript debug protocol can be found here: https://developer.roku.com/en-ca/docs/developer-program/debugging/socket-based-debugger.md 86 | */ 87 | remoteDebug?: boolean; 88 | 89 | /** 90 | * When publishing a sideloaded channel, this flag can be used to tell the Roku device that, should any compile errors occur, a client device (such as vscode) 91 | * will be trying to attach to the debug protocol control port to consume those compile errors. This must be used in conjuction with the `remoteDebug` option 92 | */ 93 | remoteDebugConnectEarly?: boolean; 94 | 95 | /** 96 | * The port used to send remote control commands (like home press, back, etc.). Defaults to 8060. 97 | * This is mainly useful for things like emulators that use alternate ports, 98 | * or when sending commands through some type of port forwarding. 99 | */ 100 | remotePort?: number; 101 | 102 | /** 103 | * The request timeout duration in milliseconds. Defaults to 150000ms (2 minutes 30 seconds). 104 | * This is mainly useful for preventing hang ups if the Roku loses power or restarts due to a firmware bug. 105 | * This is applied per network request to the device and does not apply to the total time it takes to completely execute a call to roku-deploy. 106 | */ 107 | timeout?: number; 108 | 109 | /** 110 | * The username for the roku box. This will always be 'rokudev', but allows to be overridden 111 | * just in case roku adds support for custom usernames in the future 112 | * @default 'rokudev' 113 | */ 114 | username?: string; 115 | 116 | /** 117 | * The password for logging in to the developer portal on the target Roku device 118 | */ 119 | password?: string; 120 | 121 | /** 122 | * The password used for creating signed packages 123 | */ 124 | signingPassword?: string; 125 | 126 | /** 127 | * Path to a copy of the signed package you want to use for rekeying 128 | */ 129 | rekeySignedPackage?: string; 130 | 131 | /** 132 | * Dev ID we are expecting the device to have. If supplied we check that the dev ID returned after keying matches what we expected 133 | */ 134 | devId?: string; 135 | 136 | /** 137 | * If true we increment the build number to be a timestamp in the format yymmddHHMM 138 | */ 139 | incrementBuildNumber?: boolean; 140 | 141 | /** 142 | * If true we convert to squashfs before creating the pkg file 143 | */ 144 | convertToSquashfs?: boolean; 145 | 146 | /** 147 | * If true, the publish will fail on compile error 148 | */ 149 | failOnCompileError?: boolean; 150 | 151 | /** 152 | * The log level. 153 | * @default LogLevel.log 154 | */ 155 | logLevel?: LogLevel; 156 | 157 | /** 158 | * If true, the previously installed dev channel will be deleted before installing the new one 159 | */ 160 | deleteInstalledChannel?: boolean; 161 | 162 | /** 163 | * Overrides for values used during the zip upload process. You probably don't need to change these... 164 | */ 165 | packageUploadOverrides?: { 166 | /** 167 | * The route to use for uploading to the Roku device. Defaults to 'plugin_install' 168 | * @default 'plugin_install' 169 | */ 170 | route?: string; 171 | 172 | /** 173 | * A dictionary of form fields to be included in the package upload request. Set a value to null to delete from the form 174 | */ 175 | formData?: Record; 176 | }; 177 | } 178 | 179 | export type FileEntry = (string | { src: string | string[]; dest?: string }); 180 | -------------------------------------------------------------------------------- /src/Stopwatch.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Stopwatch } from './Stopwatch'; 3 | import { util } from './util'; 4 | 5 | describe('Stopwatch', () => { 6 | let stopwatch: Stopwatch; 7 | beforeEach(() => { 8 | stopwatch = new Stopwatch(); 9 | }); 10 | 11 | it('constructs', () => { 12 | stopwatch = new Stopwatch(); 13 | }); 14 | 15 | it('starts', () => { 16 | expect(stopwatch['startTime']).to.not.exist; 17 | stopwatch.start(); 18 | expect(stopwatch['startTime']).to.exist; 19 | }); 20 | 21 | it('resets', () => { 22 | stopwatch.start(); 23 | expect(stopwatch['startTime']).to.exist; 24 | stopwatch.reset(); 25 | expect(stopwatch['startTime']).to.not.exist; 26 | }); 27 | 28 | it('stops', async () => { 29 | stopwatch.start(); 30 | expect(stopwatch['startTime']).to.exist; 31 | await util.sleep(3); 32 | stopwatch.stop(); 33 | expect(stopwatch['startTime']).to.not.exist; 34 | expect(stopwatch['totalMilliseconds']).to.be.gte(2); 35 | }); 36 | 37 | it('stop multiple times has no effect', () => { 38 | stopwatch.start(); 39 | stopwatch.stop(); 40 | stopwatch.stop(); 41 | }); 42 | 43 | it('breaks out hours, minutes, and seconds', () => { 44 | stopwatch['totalMilliseconds'] = (17 * 60 * 1000) + (43 * 1000) + 30; 45 | expect(stopwatch.getDurationText()).to.eql('17m43s30.0ms'); 46 | }); 47 | 48 | it('returns only seconds and milliseconds', () => { 49 | stopwatch['totalMilliseconds'] = (43 * 1000) + 30; 50 | expect(stopwatch.getDurationText()).to.eql('43s30.0ms'); 51 | }); 52 | 53 | it('returns only milliseconds', () => { 54 | stopwatch['totalMilliseconds'] = 30; 55 | expect(stopwatch.getDurationText()).to.eql('30.0ms'); 56 | }); 57 | 58 | it('works for single run', async () => { 59 | stopwatch = new Stopwatch(); 60 | stopwatch.start(); 61 | await new Promise((resolve) => { 62 | setTimeout(resolve, 2); 63 | }); 64 | stopwatch.stop(); 65 | expect(stopwatch.totalMilliseconds).to.be.greaterThan(1); 66 | }); 67 | 68 | it('works for multiple start/stop', async () => { 69 | stopwatch = new Stopwatch(); 70 | stopwatch.start(); 71 | stopwatch.stop(); 72 | stopwatch.totalMilliseconds = 3; 73 | stopwatch.start(); 74 | await new Promise((resolve) => { 75 | setTimeout(resolve, 4); 76 | }); 77 | stopwatch.stop(); 78 | expect(stopwatch.totalMilliseconds).to.be.at.least(6); 79 | }); 80 | 81 | it('pretty prints', () => { 82 | stopwatch = new Stopwatch(); 83 | stopwatch.totalMilliseconds = 45; 84 | expect(stopwatch.getDurationText()).to.equal('45.0ms'); 85 | stopwatch.totalMilliseconds = 2000 + 45; 86 | expect(stopwatch.getDurationText()).to.equal('2s45.0ms'); 87 | stopwatch.totalMilliseconds = 180000 + 2000 + 45; 88 | expect(stopwatch.getDurationText()).to.equal('3m2s45.0ms'); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/Stopwatch.ts: -------------------------------------------------------------------------------- 1 | import * as parseMilliseconds from 'parse-ms'; 2 | import { performance } from 'perf_hooks'; 3 | 4 | export class Stopwatch { 5 | public totalMilliseconds = 0; 6 | 7 | /** 8 | * The number of milliseconds when the stopwatch was started. 9 | */ 10 | private startTime: number; 11 | 12 | start() { 13 | this.startTime = performance.now(); 14 | } 15 | 16 | stop() { 17 | if (this.startTime) { 18 | this.totalMilliseconds += performance.now() - this.startTime; 19 | } 20 | this.startTime = undefined; 21 | } 22 | 23 | reset() { 24 | this.totalMilliseconds = 0; 25 | this.startTime = undefined; 26 | } 27 | 28 | getDurationText() { 29 | let parts = parseMilliseconds(this.totalMilliseconds); 30 | let fractionalMilliseconds = parseInt(this.totalMilliseconds.toFixed(3).toString().split('.')[1]); 31 | if (parts.minutes > 0) { 32 | return `${parts.minutes}m${parts.seconds}s${parts.milliseconds}.${fractionalMilliseconds}ms`; 33 | } else if (parts.seconds > 0) { 34 | return `${parts.seconds}s${parts.milliseconds}.${fractionalMilliseconds}ms`; 35 | } else { 36 | return `${parts.milliseconds}.${fractionalMilliseconds}ms`; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { deploy } from './index'; 3 | deploy().then((...args) => { 4 | console.log(...args); 5 | }, (...args) => { 6 | console.error(...args); 7 | }); 8 | -------------------------------------------------------------------------------- /src/device.spec.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as fsExtra from 'fs-extra'; 3 | import * as rokuDeploy from './index'; 4 | import { cwd, expectPathExists, expectThrowsAsync, outDir, rootDir, tempDir, writeFiles } from './testUtils.spec'; 5 | import undent from 'undent'; 6 | 7 | //these tests are run against an actual roku device. These cannot be enabled when run on the CI server 8 | describe('device', function device() { 9 | let options: rokuDeploy.RokuDeployOptions; 10 | 11 | beforeEach(() => { 12 | fsExtra.emptyDirSync(tempDir); 13 | fsExtra.ensureDirSync(rootDir); 14 | process.chdir(rootDir); 15 | options = rokuDeploy.getOptions({ 16 | outDir: outDir, 17 | host: '192.168.1.32', 18 | retainDeploymentArchive: true, 19 | password: 'aaaa', 20 | devId: 'c6fdc2019903ac3332f624b0b2c2fe2c733c3e74', 21 | rekeySignedPackage: `${cwd}/testSignedPackage.pkg`, 22 | signingPassword: 'drRCEVWP/++K5TYnTtuAfQ==' 23 | }); 24 | 25 | writeFiles(rootDir, [ 26 | ['manifest', undent` 27 | title=RokuDeployTestChannel 28 | major_version=1 29 | minor_version=0 30 | build_version=0 31 | splash_screen_hd=pkg:/images/splash_hd.jpg 32 | ui_resolutions=hd 33 | bs_const=IS_DEV_BUILD=false 34 | splash_color=#000000 35 | `], 36 | ['source/main.brs', undent` 37 | Sub RunUserInterface() 38 | screen = CreateObject("roSGScreen") 39 | m.scene = screen.CreateScene("HomeScene") 40 | port = CreateObject("roMessagePort") 41 | screen.SetMessagePort(port) 42 | screen.Show() 43 | 44 | while(true) 45 | msg = wait(0, port) 46 | end while 47 | 48 | if screen <> invalid then 49 | screen.Close() 50 | screen = invalid 51 | end if 52 | End Sub 53 | `] 54 | ]); 55 | }); 56 | 57 | afterEach(() => { 58 | //restore the original working directory 59 | process.chdir(cwd); 60 | fsExtra.emptyDirSync(tempDir); 61 | }); 62 | 63 | this.timeout(20000); 64 | 65 | describe('deploy', () => { 66 | it('works', async () => { 67 | options.retainDeploymentArchive = true; 68 | let response = await rokuDeploy.deploy(options); 69 | assert.equal(response.message, 'Successful deploy'); 70 | }); 71 | 72 | it('Presents nice message for 401 unauthorized status code', async () => { 73 | this.timeout(20000); 74 | options.password = 'NOT_THE_PASSWORD'; 75 | await expectThrowsAsync( 76 | rokuDeploy.deploy(options), 77 | 'Unauthorized. Please verify username and password for target Roku.' 78 | ); 79 | }); 80 | }); 81 | 82 | describe('deployAndSignPackage', () => { 83 | it('works', async () => { 84 | await rokuDeploy.deleteInstalledChannel(options); 85 | await rokuDeploy.rekeyDevice(options); 86 | expectPathExists( 87 | await rokuDeploy.deployAndSignPackage(options) 88 | ); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { RokuDeploy } from './RokuDeploy'; 2 | 3 | //export everything from the RokuDeploy file 4 | export * from './RokuDeploy'; 5 | export * from './util'; 6 | export * from './RokuDeployOptions'; 7 | export * from './Errors'; 8 | export * from './DeviceInfo'; 9 | 10 | //create a new static instance of RokuDeploy, and export those functions for backwards compatibility 11 | export const rokuDeploy = new RokuDeploy(); 12 | 13 | let createPackage = RokuDeploy.prototype.createPackage.bind(rokuDeploy); 14 | let deleteInstalledChannel = RokuDeploy.prototype.deleteInstalledChannel.bind(rokuDeploy); 15 | let deploy = RokuDeploy.prototype.deploy.bind(rokuDeploy); 16 | let deployAndSignPackage = RokuDeploy.prototype.deployAndSignPackage.bind(rokuDeploy); 17 | let getDestPath = RokuDeploy.prototype.getDestPath.bind(rokuDeploy); 18 | let getDeviceInfo = RokuDeploy.prototype.getDeviceInfo.bind(rokuDeploy); 19 | let getFilePaths = RokuDeploy.prototype.getFilePaths.bind(rokuDeploy); 20 | let getOptions = RokuDeploy.prototype.getOptions.bind(rokuDeploy); 21 | let getOutputPkgFilePath = RokuDeploy.prototype.getOutputPkgFilePath.bind(rokuDeploy); 22 | let getOutputZipFilePath = RokuDeploy.prototype.getOutputZipFilePath.bind(rokuDeploy); 23 | let normalizeFilesArray = RokuDeploy.prototype.normalizeFilesArray.bind(rokuDeploy); 24 | let normalizeRootDir = RokuDeploy.prototype.normalizeRootDir.bind(rokuDeploy); 25 | let parseManifest = RokuDeploy.prototype.parseManifest.bind(rokuDeploy); 26 | let prepublishToStaging = RokuDeploy.prototype.prepublishToStaging.bind(rokuDeploy); 27 | let pressHomeButton = RokuDeploy.prototype.pressHomeButton.bind(rokuDeploy); 28 | let publish = RokuDeploy.prototype.publish.bind(rokuDeploy); 29 | let rekeyDevice = RokuDeploy.prototype.rekeyDevice.bind(rokuDeploy); 30 | let retrieveSignedPackage = RokuDeploy.prototype.retrieveSignedPackage.bind(rokuDeploy); 31 | let signExistingPackage = RokuDeploy.prototype.signExistingPackage.bind(rokuDeploy); 32 | let stringifyManifest = RokuDeploy.prototype.stringifyManifest.bind(rokuDeploy); 33 | let takeScreenshot = RokuDeploy.prototype.takeScreenshot.bind(rokuDeploy); 34 | let zipFolder = RokuDeploy.prototype.zipFolder.bind(rokuDeploy); 35 | let zipPackage = RokuDeploy.prototype.zipPackage.bind(rokuDeploy); 36 | 37 | export { 38 | createPackage, 39 | deleteInstalledChannel, 40 | deploy, 41 | deployAndSignPackage, 42 | getDestPath, 43 | getDeviceInfo, 44 | getFilePaths, 45 | getOptions, 46 | getOutputPkgFilePath, 47 | getOutputZipFilePath, 48 | normalizeFilesArray, 49 | normalizeRootDir, 50 | parseManifest, 51 | prepublishToStaging, 52 | pressHomeButton, 53 | publish, 54 | rekeyDevice, 55 | retrieveSignedPackage, 56 | signExistingPackage, 57 | stringifyManifest, 58 | takeScreenshot, 59 | zipFolder, 60 | zipPackage 61 | }; 62 | -------------------------------------------------------------------------------- /src/testUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as fsExtra from 'fs-extra'; 3 | import * as path from 'path'; 4 | import { standardizePath as s } from './util'; 5 | 6 | export const cwd = s`${__dirname}/..`; 7 | export const tempDir = s`${cwd}/.tmp`; 8 | export const rootDir = s`${tempDir}/rootDir`; 9 | export const outDir = s`${tempDir}/outDir`; 10 | export const stagingDir = s`${outDir}/.roku-deploy-staging`; 11 | 12 | export function expectPathExists(thePath: string) { 13 | if (!fsExtra.pathExistsSync(thePath)) { 14 | throw new Error(`Expected "${thePath}" to exist`); 15 | } 16 | } 17 | 18 | export function expectPathNotExists(thePath: string) { 19 | expect( 20 | fsExtra.pathExistsSync(thePath), 21 | `Expected "${thePath}" not to exist` 22 | ).to.be.false; 23 | } 24 | 25 | export function writeFiles(baseDir: string, files: Array) { 26 | const filePaths = []; 27 | for (let entry of files) { 28 | if (typeof entry === 'string') { 29 | entry = [entry] as any; 30 | } 31 | let [filePath, contents] = entry as any; 32 | filePaths.push(filePath); 33 | filePath = path.resolve(baseDir, filePath); 34 | fsExtra.outputFileSync(filePath, contents ?? ''); 35 | } 36 | return filePaths; 37 | } 38 | 39 | export async function expectThrowsAsync(callback: Promise | (() => Promise), expectedMessage = undefined, failedTestMessage = 'Expected to throw but did not') { 40 | let wasExceptionThrown = false; 41 | let promise: Promise; 42 | if (typeof callback === 'function') { 43 | promise = callback(); 44 | } else { 45 | promise = callback; 46 | } 47 | try { 48 | await promise; 49 | } catch (e) { 50 | wasExceptionThrown = true; 51 | if (expectedMessage) { 52 | expect((e as any).message).to.eql(expectedMessage); 53 | } 54 | } 55 | if (wasExceptionThrown === false) { 56 | throw new Error(failedTestMessage); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/util.spec.ts: -------------------------------------------------------------------------------- 1 | import { util, standardizePath as s } from './util'; 2 | import { expect } from 'chai'; 3 | import * as fsExtra from 'fs-extra'; 4 | import { tempDir } from './testUtils.spec'; 5 | import * as path from 'path'; 6 | import * as dns from 'dns'; 7 | import { createSandbox } from 'sinon'; 8 | const sinon = createSandbox(); 9 | 10 | describe('util', () => { 11 | beforeEach(() => { 12 | fsExtra.emptyDirSync(tempDir); 13 | sinon.restore(); 14 | }); 15 | 16 | afterEach(() => { 17 | sinon.restore(); 18 | }); 19 | 20 | describe('isFile', () => { 21 | it('recognizes valid files', async () => { 22 | expect(await util.isFile(util.standardizePath(`${process.cwd()}/README.md`))).to.be.true; 23 | }); 24 | it('recognizes non-existant files', async () => { 25 | expect(await util.isFile(util.standardizePath(`${process.cwd()}/FILE_THAT_DOES_NOT_EXIST.md`))).to.be.false; 26 | }); 27 | }); 28 | 29 | describe('toForwardSlashes', () => { 30 | it('returns original value for non-strings', () => { 31 | expect(util.toForwardSlashes(undefined)).to.be.undefined; 32 | expect(util.toForwardSlashes(false)).to.be.false; 33 | }); 34 | 35 | it('converts mixed slashes to forward', () => { 36 | expect(util.toForwardSlashes('a\\b/c\\d/e')).to.eql('a/b/c/d/e'); 37 | }); 38 | }); 39 | 40 | describe('isChildOfPath', () => { 41 | it('works for child path', () => { 42 | let parentPath = `${process.cwd()}\\testProject`; 43 | let childPath = `${process.cwd()}\\testProject\\manifest`; 44 | expect(util.isParentOfPath(parentPath, childPath), `expected '${childPath}' to be child path of '${parentPath}'`).to.be.true; 45 | //inverse is not true 46 | expect(util.isParentOfPath(childPath, parentPath), `expected '${parentPath}' NOT to be child path of '${childPath}'`).to.be.false; 47 | }); 48 | 49 | it('handles mixed path separators', () => { 50 | let parentPath = `${process.cwd()}\\testProject`; 51 | let childPath = `${process.cwd()}\\testProject/manifest`; 52 | expect(util.isParentOfPath(parentPath, childPath), `expected '${childPath}' to be child path of '${parentPath}'`).to.be.true; 53 | }); 54 | 55 | it('handles relative path traversals', () => { 56 | let parentPath = `${process.cwd()}\\testProject`; 57 | let childPath = `${process.cwd()}/testProject/../testProject/manifest`; 58 | expect(util.isParentOfPath(parentPath, childPath), `expected '${childPath}' to be child path of '${parentPath}'`).to.be.true; 59 | }); 60 | 61 | it('works with trailing slashes', () => { 62 | let parentPath = `${process.cwd()}/testProject/`; 63 | let childPath = `${process.cwd()}/testProject/../testProject/manifest`; 64 | expect(util.isParentOfPath(parentPath, childPath), `expected '${childPath}' to be child path of '${parentPath}'`).to.be.true; 65 | }); 66 | 67 | it('works with duplicate slashes', () => { 68 | let parentPath = `${process.cwd()}///testProject/`; 69 | let childPath = `${process.cwd()}/testProject///testProject//manifest`; 70 | expect(util.isParentOfPath(parentPath, childPath), `expected '${childPath}' to be child path of '${parentPath}'`).to.be.true; 71 | }); 72 | }); 73 | 74 | describe('stringReplaceInsensitive', () => { 75 | it('works for varying case', () => { 76 | expect(util.stringReplaceInsensitive('aBcD', 'bCd', 'bcd')).to.equal('abcd'); 77 | }); 78 | 79 | it('returns the original string if the needle was not found in the haystack', () => { 80 | expect(util.stringReplaceInsensitive('abcd', 'efgh', 'EFGH')).to.equal('abcd'); 81 | }); 82 | }); 83 | 84 | describe('tryRepeatAsync', () => { 85 | it('calls callback', async () => { 86 | let count = 0; 87 | await util.tryRepeatAsync(() => { 88 | count++; 89 | if (count < 3) { 90 | throw new Error('test tryRepeatAsync'); 91 | } 92 | }, 10, 0); 93 | expect(count).to.equal(3); 94 | }); 95 | 96 | it('raises exception after max tries has been reached', async () => { 97 | let error; 98 | try { 99 | await util.tryRepeatAsync(() => { 100 | throw new Error('test tryRepeatAsync'); 101 | }, 3, 1); 102 | } catch (e) { 103 | error = e; 104 | } 105 | expect(error).to.exist; 106 | }); 107 | }); 108 | 109 | describe('globAllByIndex', () => { 110 | function writeFiles(filePaths: string[], cwd = tempDir) { 111 | for (const filePath of filePaths) { 112 | fsExtra.outputFileSync( 113 | path.resolve(cwd, filePath), 114 | '' 115 | ); 116 | } 117 | } 118 | 119 | async function doTest(patterns: string[], expectedPaths: string[][]) { 120 | const results = await util.globAllByIndex(patterns, tempDir); 121 | for (let i = 0; i < results.length; i++) { 122 | results[i] = results[i]?.map(x => s(x))?.sort(); 123 | } 124 | for (let i = 0; i < expectedPaths.length; i++) { 125 | expectedPaths[i] = expectedPaths[i]?.map(x => { 126 | return s`${path.resolve(tempDir, x)}`; 127 | })?.sort(); 128 | } 129 | expect(results).to.eql(expectedPaths); 130 | } 131 | 132 | it('finds direct file paths', async () => { 133 | writeFiles([ 134 | 'manifest', 135 | 'source/main.brs', 136 | 'components/Component1/lib.brs' 137 | ]); 138 | await doTest([ 139 | 'manifest', 140 | 'source/main.brs', 141 | 'components/Component1/lib.brs' 142 | ], [ 143 | [ 144 | 'manifest' 145 | ], [ 146 | 'source/main.brs' 147 | ], [ 148 | 'components/Component1/lib.brs' 149 | ] 150 | ]); 151 | }); 152 | 153 | it('matches the wildcard glob', async () => { 154 | writeFiles([ 155 | 'manifest', 156 | 'source/main.brs', 157 | 'components/Component1/lib.brs' 158 | ]); 159 | await doTest([ 160 | '**/*' 161 | ], [ 162 | [ 163 | 'manifest', 164 | 'source/main.brs', 165 | 'components/Component1/lib.brs' 166 | ] 167 | ]); 168 | }); 169 | 170 | it('returns the same file path in multiple matches', async () => { 171 | writeFiles([ 172 | 'manifest', 173 | 'source/main.brs', 174 | 'components/Component1/lib.brs' 175 | ]); 176 | await doTest([ 177 | 'manifest', 178 | 'source/main.brs', 179 | 'manifest', 180 | 'source/main.brs' 181 | ], [ 182 | [ 183 | 'manifest' 184 | ], [ 185 | 'source/main.brs' 186 | ], [ 187 | 'manifest' 188 | ], [ 189 | 'source/main.brs' 190 | ] 191 | ]); 192 | }); 193 | 194 | it('filters files', async () => { 195 | writeFiles([ 196 | 'manifest', 197 | 'source/main.brs', 198 | 'components/Component1/lib.brs' 199 | ]); 200 | await doTest([ 201 | '**/*', 202 | //filter out brs files 203 | '!**/*.brs' 204 | ], [ 205 | [ 206 | 'manifest' 207 | ], 208 | null 209 | ]); 210 | }); 211 | 212 | it('filters files and adds them back in later', async () => { 213 | writeFiles([ 214 | 'manifest', 215 | 'source/main.brs', 216 | 'components/Component1/lib.brs' 217 | ]); 218 | await doTest([ 219 | '**/*', 220 | //filter out brs files 221 | '!**/*.brs', 222 | //re-add the main file 223 | '**/main.brs' 224 | ], [ 225 | [ 226 | 'manifest' 227 | ], 228 | undefined, 229 | [ 230 | 'source/main.brs' 231 | ] 232 | ]); 233 | }); 234 | }); 235 | 236 | describe('filterPaths', () => { 237 | it('does not crash with bad params', () => { 238 | //shouldn't crash 239 | util['filterPaths']('*', [], '', 2); 240 | }); 241 | }); 242 | 243 | describe('dnsLookup', () => { 244 | it('returns ip address for hostname', async () => { 245 | sinon.stub(dns.promises, 'lookup').returns(Promise.resolve({ 246 | address: '1.2.3.4', 247 | family: undefined 248 | })); 249 | 250 | expect( 251 | await util.dnsLookup('some-host', true) 252 | ).to.eql('1.2.3.4'); 253 | }); 254 | 255 | it('returns ip address for ip address', async () => { 256 | sinon.stub(dns.promises, 'lookup').returns(Promise.resolve({ 257 | address: '1.2.3.4', 258 | family: undefined 259 | })); 260 | 261 | expect( 262 | await util.dnsLookup('some-host', true) 263 | ).to.eql('1.2.3.4'); 264 | }); 265 | 266 | it('returns given value if the lookup failed', async () => { 267 | sinon.stub(dns.promises, 'lookup').returns(Promise.resolve({ 268 | address: undefined, 269 | family: undefined 270 | })); 271 | 272 | expect( 273 | await util.dnsLookup('some-host', true) 274 | ).to.eql('some-host'); 275 | }); 276 | }); 277 | 278 | describe('decodeHtmlEntities', () => { 279 | it('decodes values properly', () => { 280 | expect(util.decodeHtmlEntities(' ')).to.eql(' '); 281 | expect(util.decodeHtmlEntities('&')).to.eql('&'); 282 | expect(util.decodeHtmlEntities('"')).to.eql('"'); 283 | expect(util.decodeHtmlEntities('<')).to.eql('<'); 284 | expect(util.decodeHtmlEntities('>')).to.eql('>'); 285 | expect(util.decodeHtmlEntities(''')).to.eql(`'`); 286 | }); 287 | }); 288 | }); 289 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as fsExtra from 'fs-extra'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | import * as dns from 'dns'; 5 | import * as micromatch from 'micromatch'; 6 | // eslint-disable-next-line @typescript-eslint/no-require-imports 7 | import fastGlob = require('fast-glob'); 8 | 9 | export class Util { 10 | /** 11 | * Determine if `childPath` is contained within the `parentPath` 12 | * @param parentPath 13 | * @param childPath 14 | * @param standardizePaths if false, the paths are assumed to already be in the same format and are not re-standardized 15 | */ 16 | public isParentOfPath(parentPath: string, childPath: string, standardizePaths = true) { 17 | if (standardizePaths) { 18 | parentPath = util.standardizePath(parentPath); 19 | childPath = util.standardizePath(childPath); 20 | } 21 | const relative = path.relative(parentPath, childPath); 22 | return relative && !relative.startsWith('..') && !path.isAbsolute(relative); 23 | } 24 | 25 | /** 26 | * Determines if the given path is a file 27 | * @param filePathAbsolute 28 | */ 29 | public async isFile(filePathAbsolute: string) { 30 | try { 31 | //get the full path to the file. This should be the same path for files, and the actual path for any symlinks 32 | let realPathAbsolute = fs.realpathSync(filePathAbsolute); 33 | let stat = await fsExtra.lstat(realPathAbsolute); 34 | return stat.isFile(); 35 | } catch (e) { 36 | // lstatSync throws an error if path doesn't exist 37 | return false; 38 | } 39 | } 40 | 41 | /** 42 | * Normalize path and replace all directory separators with current OS separators 43 | * @param thePath 44 | */ 45 | public standardizePath(thePath: string) { 46 | if (!thePath) { 47 | return thePath; 48 | } 49 | return path.normalize( 50 | thePath.replace(/[\/\\]+/g, path.sep) 51 | ); 52 | } 53 | 54 | /** 55 | * Convert all slashes to forward slashes 56 | */ 57 | public toForwardSlashes(thePath: string) { 58 | if (typeof thePath === 'string') { 59 | return thePath.replace(/[\/\\]+/g, '/'); 60 | } else { 61 | return thePath; 62 | } 63 | } 64 | 65 | /** 66 | * Do a case-insensitive string replacement 67 | * @param subject the string that will have its contents replaced 68 | * @param search the search text to find in `subject` 69 | * @param replace the text to replace `search` with in `subject` 70 | */ 71 | public stringReplaceInsensitive(subject: string, search: string, replace: string) { 72 | let idx = subject.toLowerCase().indexOf(search.toLowerCase()); 73 | if (idx > -1) { 74 | return subject.substring(0, idx) + replace + subject.substr(idx + search.length); 75 | } else { 76 | return subject; 77 | } 78 | } 79 | 80 | /** 81 | * Keep calling the callback until it does NOT throw an exception, or until the max number of tries has been reached. 82 | * @param callback 83 | * @param maxTries 84 | * @param sleepMilliseconds 85 | */ 86 | /* istanbul ignore next */ //typescript generates some weird while statement that can't get fully covered for some reason 87 | public async tryRepeatAsync(callback, maxTries = 10, sleepMilliseconds = 50): Promise { 88 | let tryCount = 0; 89 | while (true) { 90 | try { 91 | return await Promise.resolve(callback()); 92 | } catch (e) { 93 | tryCount++; 94 | if (tryCount > maxTries) { 95 | throw e; 96 | } else { 97 | await this.sleep(sleepMilliseconds); 98 | } 99 | } 100 | } 101 | } 102 | 103 | public async sleep(milliseconds: number) { 104 | await new Promise((resolve) => { 105 | setTimeout(resolve, milliseconds); 106 | }); 107 | } 108 | 109 | /** 110 | * Determine if a file exists (case insensitive) 111 | */ 112 | public async fileExistsCaseInsensitive(filePath: string) { 113 | filePath = this.standardizePath(filePath); 114 | const lowerFilePath = filePath.toLowerCase(); 115 | 116 | const parentDirPath = path.dirname(filePath); 117 | 118 | //file can't exist if its parent dir doesn't exist 119 | if (await fsExtra.pathExists(parentDirPath) === false) { 120 | return false; 121 | } 122 | 123 | //get a list of every file in the parent directory for this file 124 | const filesInDir = await fsExtra.readdir(parentDirPath); 125 | //look at each file path until we find the one we're searching for 126 | for (let dirFile of filesInDir) { 127 | const dirFilePath = this.standardizePath(`${parentDirPath}/${dirFile}`); 128 | if (dirFilePath.toLowerCase() === lowerFilePath) { 129 | return true; 130 | } 131 | } 132 | return false; 133 | } 134 | 135 | /** 136 | * Run a series of glob patterns, returning the matches in buckets corresponding to their pattern index. 137 | */ 138 | public async globAllByIndex(patterns: string[], cwd: string) { 139 | //force all path separators to unix style 140 | cwd = cwd.replace(/\\/g, '/'); 141 | 142 | const globResults = patterns.map(async (pattern) => { 143 | //force all windows-style slashes to unix style 144 | pattern = pattern.replace(/\\/g, '/'); 145 | //skip negated patterns (we will use them to filter later on) 146 | if (pattern.startsWith('!')) { 147 | return pattern; 148 | } else { 149 | //run glob matcher 150 | 151 | return fastGlob([pattern], { 152 | cwd: cwd, 153 | absolute: true, 154 | followSymbolicLinks: true, 155 | onlyFiles: true 156 | }); 157 | } 158 | }); 159 | 160 | const matchesByIndex: Array> = []; 161 | 162 | for (let i = 0; i < globResults.length; i++) { 163 | const globResult = await globResults[i]; 164 | //if the matches collection is missing, this is a filter 165 | if (typeof globResult === 'string') { 166 | this.filterPaths(globResult, matchesByIndex, cwd, i - 1); 167 | matchesByIndex.push(undefined); 168 | } else { 169 | matchesByIndex.push(globResult); 170 | } 171 | } 172 | return matchesByIndex; 173 | } 174 | 175 | /** 176 | * Filter all of the matches based on a minimatch pattern 177 | * @param stopIndex the max index of `matchesByIndex` to filter until 178 | * @param pattern - the pattern used to filter out entries from `matchesByIndex`. Usually preceeded by a `!` 179 | */ 180 | private filterPaths(pattern: string, filesByIndex: string[][], cwd: string, stopIndex: number) { 181 | //move the ! to the start of the string to negate the absolute path, replace windows slashes with unix ones 182 | let negatedPatternAbsolute = '!' + path.posix.join(cwd, pattern.replace(/^!/, '')); 183 | let filter = micromatch.matcher(negatedPatternAbsolute); 184 | for (let i = 0; i <= stopIndex; i++) { 185 | if (filesByIndex[i]) { 186 | //filter all matches by the specified pattern 187 | filesByIndex[i] = filesByIndex[i].filter(x => { 188 | return filter(x); 189 | }); 190 | } 191 | } 192 | } 193 | 194 | /* 195 | * Look up the ip address for a hostname. This is cached for the lifetime of the app, or bypassed with the `skipCache` parameter 196 | * @param host 197 | * @param skipCache 198 | * @returns 199 | */ 200 | public async dnsLookup(host: string, skipCache = false) { 201 | if (!this.dnsCache.has(host) || skipCache) { 202 | const result = await dns.promises.lookup(host); 203 | this.dnsCache.set(host, result.address ?? host); 204 | } 205 | return this.dnsCache.get(host); 206 | } 207 | 208 | private dnsCache = new Map(); 209 | 210 | /** 211 | * Decode HTML entities like   ' to its original character 212 | */ 213 | public decodeHtmlEntities(encodedString: string) { 214 | let translateRegex = /&(nbsp|amp|quot|lt|gt);/g; 215 | let translate = { 216 | 'nbsp': ' ', 217 | 'amp': '&', 218 | 'quot': '"', 219 | 'lt': '<', 220 | 'gt': '>' 221 | }; 222 | 223 | return encodedString.replace(translateRegex, (match, entity) => { 224 | return translate[entity]; 225 | }).replace(/&#(\d+);/gi, (match, numStr) => { 226 | let num = parseInt(numStr, 10); 227 | return String.fromCharCode(num); 228 | }); 229 | } 230 | 231 | } 232 | 233 | export let util = new Util(); 234 | 235 | 236 | /** 237 | * A tagged template literal function for standardizing the path. 238 | */ 239 | export function standardizePath(stringParts, ...expressions: any[]) { 240 | let result = []; 241 | for (let i = 0; i < stringParts.length; i++) { 242 | result.push(stringParts[i], expressions[i]); 243 | } 244 | return util.standardizePath( 245 | result.join('') 246 | ); 247 | } 248 | -------------------------------------------------------------------------------- /testSignedPackage.pkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rokucommunity/roku-deploy/26e555d76baa757dfa1ce61beb86dbf73862cb1e/testSignedPackage.pkg -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "target": "ES2017", 5 | "module": "commonjs", 6 | "sourceMap": true, 7 | "rootDir": "src", 8 | "outDir": "dist", 9 | "declaration": true, 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "strictNullChecks": false, 13 | "lib": [ 14 | "es2015" 15 | ], 16 | "typeRoots": [ 17 | "./node_modules/@types", 18 | "./types" 19 | ] 20 | }, 21 | "include": [ 22 | "src/**/*.ts", 23 | "device.spec.ts" 24 | ], 25 | "ts-node": { 26 | "transpileOnly": true 27 | } 28 | } --------------------------------------------------------------------------------