diff --git a/.circleci/config.yml b/.circleci/config.yml index aca42f44a..970165384 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,7 +18,7 @@ executors: orbs: node: circleci/node@5.0.2 - win: circleci/windows@4.1.1 + win: circleci/windows@5.0.0 commands: configure-npm-token: @@ -75,7 +75,7 @@ jobs: - run: name: Install Node command: | - nvm install --lts << parameters.node-version >> + nvm install << parameters.node-version >> nvm use << parameters.node-version >> - configure-npm-token - run: diff --git a/assets/knownApps/p2/tinker-5.0.0-p2.bin b/assets/knownApps/p2/tinker-5.0.0-p2.bin new file mode 100644 index 000000000..73109273b Binary files /dev/null and b/assets/knownApps/p2/tinker-5.0.0-p2.bin differ diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index cb8378a2b..5f4b7fb7f 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -9,9 +9,10 @@ "version": "3.11.3", "license": "Apache-2.0", "dependencies": { - "@particle/device-constants": "^3.1.9", + "@particle/device-constants": "^3.1.11", "binary-version-reader": "^2.2.0", "chalk": "^2.4.2", + "cli-progress": "^3.12.0", "cli-spinner": "^0.2.10", "cli-table": "^0.3.1", "core-js": "^3.4.7", @@ -65,7 +66,7 @@ "npm": ">=6" }, "optionalDependencies": { - "particle-usb": "^2.2.2", + "particle-usb": "^2.3.1", "serialport": "^9.2.8" } }, @@ -720,9 +721,9 @@ "optional": true }, "node_modules/@types/node": { - "version": "18.15.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", + "version": "20.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.1.tgz", + "integrity": "sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==", "optional": true }, "node_modules/@types/w3c-web-usb": { @@ -1711,6 +1712,62 @@ "node": ">=0.10.0" } }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-progress/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cli-progress/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cli-spinner": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/cli-spinner/-/cli-spinner-0.2.10.tgz", @@ -6362,9 +6419,9 @@ } }, "node_modules/node-addon-api": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.0.0.tgz", - "integrity": "sha512-GyHvgPvUXBvAkXa0YvYnhilSB1A+FRYMpIVggKzPZqdaZfevZOuzfWzyvgzOwRLHBeo/MMswmJFsrNF4Nw1pmA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "optional": true }, "node_modules/node-environment-flags": { @@ -7489,9 +7546,9 @@ } }, "node_modules/particle-usb": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/particle-usb/-/particle-usb-2.2.2.tgz", - "integrity": "sha512-omsw5sY18qLcSUIoyUjNDIYV9uaTe5+rWQxZraf3mlZizkz6PvBofSzvyOKnZ4WAjLWELVsz7DrK/LNUCdCdgw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/particle-usb/-/particle-usb-2.3.1.tgz", + "integrity": "sha512-UfA/wSNeYCBB1Pb5J4OlL9/IMc/3YIOeb7JHhNmqdlfeV7tutL80qkiKB5F3Gw+awdnEw1UJxHx3SGQsHp5EQw==", "optional": true, "dependencies": { "@particle/device-os-protobuf": "^1.2.1", @@ -7880,9 +7937,9 @@ } }, "node_modules/protobufjs": { - "version": "6.11.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz", - "integrity": "sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg==", + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", "hasInstallScript": true, "optional": true, "dependencies": { @@ -11591,9 +11648,9 @@ "optional": true }, "@types/node": { - "version": "18.15.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", + "version": "20.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.1.tgz", + "integrity": "sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==", "optional": true }, "@types/w3c-web-usb": { @@ -12379,6 +12436,49 @@ "restore-cursor": "^1.0.1" } }, + "cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "requires": { + "string-width": "^4.2.3" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, "cli-spinner": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/cli-spinner/-/cli-spinner-0.2.10.tgz", @@ -16131,9 +16231,9 @@ } }, "node-addon-api": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.0.0.tgz", - "integrity": "sha512-GyHvgPvUXBvAkXa0YvYnhilSB1A+FRYMpIVggKzPZqdaZfevZOuzfWzyvgzOwRLHBeo/MMswmJFsrNF4Nw1pmA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "optional": true }, "node-environment-flags": { @@ -17066,9 +17166,9 @@ } }, "particle-usb": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/particle-usb/-/particle-usb-2.2.2.tgz", - "integrity": "sha512-omsw5sY18qLcSUIoyUjNDIYV9uaTe5+rWQxZraf3mlZizkz6PvBofSzvyOKnZ4WAjLWELVsz7DrK/LNUCdCdgw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/particle-usb/-/particle-usb-2.3.1.tgz", + "integrity": "sha512-UfA/wSNeYCBB1Pb5J4OlL9/IMc/3YIOeb7JHhNmqdlfeV7tutL80qkiKB5F3Gw+awdnEw1UJxHx3SGQsHp5EQw==", "optional": true, "requires": { "@particle/device-os-protobuf": "^1.2.1", @@ -17375,9 +17475,9 @@ } }, "protobufjs": { - "version": "6.11.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz", - "integrity": "sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg==", + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", "optional": true, "requires": { "@protobufjs/aspromise": "^1.1.2", diff --git a/package.json b/package.json index 54f22973a..0d5406720 100644 --- a/package.json +++ b/package.json @@ -52,9 +52,10 @@ } ], "dependencies": { - "@particle/device-constants": "^3.1.9", + "@particle/device-constants": "^3.1.11", "binary-version-reader": "^2.2.0", "chalk": "^2.4.2", + "cli-progress": "^3.12.0", "cli-spinner": "^0.2.10", "cli-table": "^0.3.1", "core-js": "^3.4.7", @@ -101,7 +102,7 @@ "sinon-chai": "^3.3.0" }, "optionalDependencies": { - "particle-usb": "^2.2.2", + "particle-usb": "^2.3.1", "serialport": "^9.2.8" }, "engines": { diff --git a/src/app/cli.js b/src/app/cli.js index fb2de139a..cbe0dc333 100644 --- a/src/app/cli.js +++ b/src/app/cli.js @@ -99,7 +99,7 @@ module.exports = class CLI { * @param {*} argv The parsed command line arguments. */ parsed(argv) { - global.isInteractive = argv.interactive === true || (process.stdin.isTTY && !argv.nonInteractive); + global.isInteractive = process.stdin.isTTY && process.stdout.isTTY; global.verboseLevel = argv.verbose+1-argv.quiet; global.outputJson = argv.json; } diff --git a/src/app/ui.js b/src/app/ui.js index 73b47162a..990b724e4 100644 --- a/src/app/ui.js +++ b/src/app/ui.js @@ -4,7 +4,7 @@ const log = require('../lib/log'); Spinner.setDefaultSpinnerString(Spinner.spinners[7]); - +// TODO: migrate all usage prompt in src/app/ui to src/lib/ui module.exports.prompt = async (question) => { if (!global.isInteractive){ throw new Error('Prompts are not allowed in non-interactive mode'); diff --git a/src/cli/flash.js b/src/cli/flash.js index 6e716cf1f..19e9d3734 100644 --- a/src/cli/flash.js +++ b/src/cli/flash.js @@ -1,3 +1,5 @@ +const unindent = require('../lib/unindent'); + module.exports = ({ commandProcessor, root }) => { commandProcessor.createCommand(root, 'flash', 'Send firmware to your device', { params: '[device|binary] [files...]', @@ -6,6 +8,10 @@ module.exports = ({ commandProcessor, root }) => { boolean: true, description: 'Flash over the air to the device. Default if no other flag provided' }, + 'local': { + boolean: true, + description: 'Flash locally, updating Device OS as needed' + }, 'usb': { boolean: true, description: 'Flash over USB using the DFU utility' @@ -27,7 +33,11 @@ module.exports = ({ commandProcessor, root }) => { description: 'Answer yes to all questions' }, 'target': { - description: 'The firmware version to compile against. Defaults to latest version, or version on device for cellular.' + description: 'The firmware version to compile against. Defaults to latest version.' + }, + 'application-only': { + boolean: true, + description: 'Do not update Device OS' }, 'port': { describe: 'Use this serial port instead of auto-detecting. Useful if there are more than 1 connected device. Only available for serial' @@ -40,10 +50,19 @@ module.exports = ({ commandProcessor, root }) => { examples: { '$0 $command red': 'Compile the source code in the current directory in the cloud and flash to device red', '$0 $command green tinker': 'Flash the default Tinker app to device green', - '$0 $command blue app.ino --target 0.6.3': 'Compile app.ino in the cloud using the 0.6.3 firmware and flash to device blue', + '$0 $command blue app.ino --target 5.0.0': 'Compile app.ino in the cloud using the 5.0.0 firmware and flash to device blue', '$0 $command cyan firmware.bin': 'Flash the pre-compiled binary to device cyan', + '$0 $command --local': 'Compile the source code in the current directory in the cloud and flash to the device connected over USB', + '$0 $command --local --target 5.0.0': 'Compile the source code in the current directory in the cloud against the target version and flash to the device connected over USB', + '$0 $command --local application.bin': 'Flash the pre-compiled binary to the device connected over USB', + '$0 $command --local application.zip': 'Flash the pre-compiled binary and assets from the bundle to the device connected over USB', + '$0 $command --local tinker': 'Flash the default Tinker app to the device connected over USB', '$0 $command --usb firmware.bin': 'Flash the binary over USB. The device needs to be in DFU mode', '$0 $command --serial firmware.bin': 'Flash the binary over virtual serial port. The device needs to be in listening mode' - } + }, + epilogue: unindent(` + When passing the --local flag, Device OS will be updated if the version on the device is outdated. + When passing both the --local and --target flash, Device OS will be updated to the target version. + `) }); }; diff --git a/src/cmd/api.js b/src/cmd/api.js index c7cccf69a..95c8666cb 100644 --- a/src/cmd/api.js +++ b/src/cmd/api.js @@ -221,6 +221,15 @@ module.exports = class ParticleApi { ); } + getDeviceOsVersions(platformId, version) { + return this._wrap( + this.api.get({ + uri: `/v1/device-os/versions/${version}?platform_id=${platformId}`, + auth: this.accessToken + }) + ); + } + _wrap(promise){ return Promise.resolve(promise) .then(result => result.body || result) diff --git a/src/cmd/bundle.js b/src/cmd/bundle.js index 43adec999..575342f2a 100644 --- a/src/cmd/bundle.js +++ b/src/cmd/bundle.js @@ -1,9 +1,10 @@ const fs = require('fs-extra'); const path = require('path'); const CLICommandBase = require('./base'); -const { createApplicationAndAssetBundle } = require('binary-version-reader'); +const { createApplicationAndAssetBundle, unpackApplicationAndAssetBundle, createAssetModule } = require('binary-version-reader'); const utilities = require('../lib/utilities'); const os = require('os'); +const temp = require('temp').track(); const specialFiles = [ '.DS_Store', @@ -121,4 +122,21 @@ module.exports = class BundleCommands extends CLICommandBase { this.ui.stdout.write(`Bundling successful.${os.EOL}`); this.ui.stdout.write(`Saved bundle to: ${bundleFilename}${os.EOL}`); } + + async extractModulesFromBundle({ bundleFilename }) { + const modulesDir = await temp.mkdir('modules'); + + const { application, assets } = await unpackApplicationAndAssetBundle(bundleFilename); + + // Write the app binary and asset modules to disk + application.path = path.join(modulesDir, application.name); + await fs.writeFile(application.path, application.data); + for (const asset of assets) { + const assetModule = await createAssetModule(asset.data, asset.name); + asset.path = path.join(modulesDir, asset.name); + await fs.writeFile(asset.path, assetModule); + } + + return [application.path, ...assets.map(asset => asset.path)]; + } }; diff --git a/src/cmd/bundle.test.js b/src/cmd/bundle.test.js index 57e2495b4..fff87446e 100644 --- a/src/cmd/bundle.test.js +++ b/src/cmd/bundle.test.js @@ -317,4 +317,17 @@ describe('BundleCommands', () => { }); }); }); + + describe('extractModulesFromBundle', () => { + it ('extracts modules from bundle', async () => { + const bundleFilename = path.join(PATH_FIXTURES_THIRDPARTY_OTA_DIR, 'bundle.zip'); + const expectedRes = ['app.bin', 'cat.txt', 'house.txt', 'water.txt']; + + const modulePaths = await bundleCommands.extractModulesFromBundle({ bundleFilename }); + + expect(modulePaths).to.be.an.instanceof(Array); + expect(modulePaths).to.have.lengthOf(4); + expect(modulePaths.map(module => path.basename(module))).to.eql(expectedRes); + }); + }); }); diff --git a/src/cmd/cloud.js b/src/cmd/cloud.js index 2912218a2..dd1221e29 100644 --- a/src/cmd/cloud.js +++ b/src/cmd/cloud.js @@ -20,6 +20,7 @@ const chalk = require('chalk'); const temp = require('temp').track(); const { ssoLogin, waitForLogin, getLoginMessage } = require('../lib/sso'); const BundleCommands = require('./bundle'); +const { sourcePatterns } = require('../lib/file-types'); const arrow = chalk.green('>'); const alert = chalk.yellow('!'); @@ -811,19 +812,7 @@ module.exports = class CloudCommand extends CLICommandBase { _getDefaultIncludes(files, dirname, { followSymlinks }) { // Recursively find source files - let includes = [ - '**/*.h', - '**/*.hpp', - '**/*.hh', - '**/*.hxx', - '**/*.ino', - '**/*.cpp', - '**/*.c', - '**/build.mk', - 'project.properties' - ]; - - const result = utilities.globList(dirname, includes, { followSymlinks }); + const result = utilities.globList(dirname, sourcePatterns, { followSymlinks }); result.forEach((file) => files.add(file)); } diff --git a/src/cmd/flash.js b/src/cmd/flash.js index 0f0a6107c..d3d6adb6a 100644 --- a/src/cmd/flash.js +++ b/src/cmd/flash.js @@ -1,51 +1,78 @@ -const fs = require('fs'); +const fs = require('fs-extra'); +const os = require('os'); +const ParticleApi = require('./api'); const VError = require('verror'); -const ModuleParser = require('binary-version-reader').HalModuleParser; -const ModuleInfo = require('binary-version-reader').ModuleInfo; +const { HalModuleParser: ModuleParser, ModuleInfo } = require('binary-version-reader'); const deviceSpecs = require('../lib/device-specs'); -const ensureError = require('../lib/utilities').ensureError; +const { ensureError, delay } = require('../lib/utilities'); +const { errors: { usageError } } = require('../app/command-processor'); const dfu = require('../lib/dfu'); +const usbUtils = require('./usb-util'); const CLICommandBase = require('./base'); +const { platformForId, PLATFORMS } = require('../lib/platform'); +const settings = require('../../settings'); +const path = require('path'); +const utilities = require('../lib/utilities'); +const CloudCommand = require('./cloud'); +const BundleCommand = require('./bundle'); +const temp = require('temp').track(); +const { knownAppNames, knownAppsForPlatform } = require('../lib/known-apps'); +const { sourcePatterns, binaryPatterns, binaryExtensions } = require('../lib/file-types'); +const deviceOsUtils = require('../lib/device-os-version-util'); +const semver = require('semver'); +const { moduleTypeToString, sortBinariesByDependency } = require('../lib/dependency-walker'); + +const FLASH_APPLY_DELAY = 3000; module.exports = class FlashCommand extends CLICommandBase { - constructor(...args){ + constructor(...args) { super(...args); } - flash(device, binary, files, { usb, serial, factory, force, target, port, yes }){ - if (!device && !binary){ + + async flash(device, binary, files, { + local, + usb, + serial, + factory, + force, + target, + port, + yes, + 'application-only': applicationOnly + }) { + if (!device && !binary && !local) { // if no device nor files are passed, show help - // TODO: Replace by UsageError - return Promise.reject(); + throw usageError('You must specify a device or a file'); } this.ui.logFirstTimeFlashWarning(); - let result; - if (usb){ - result = this.flashDfu({ binary, factory, force }); - } else if (serial){ - result = this.flashYModem({ binary, port, yes }); + if (usb) { + await this.flashDfu({ binary, factory, force }); + } else if (serial) { + await this.flashYModem({ binary, port, yes }); + } else if (local) { + let allFiles = binary ? [binary, ...files] : files; + await this.flashLocal({ files: allFiles, applicationOnly, target }); } else { - result = this.flashCloud({ device, files, target }); + await this.flashCloud({ device, files, target }); } - return result.then(() => { - this.ui.write('Flash success!'); - }); + this.ui.write('Flash success!'); } - flashCloud({ device, files, target }){ + flashCloud({ device, files, target }) { const CloudCommands = require('../cmd/cloud'); const args = { target, params: { device, files } }; return new CloudCommands().flashDevice(args); } - flashYModem({ binary, port, yes }){ + flashYModem({ binary, port, yes }) { const SerialCommands = require('../cmd/serial'); return new SerialCommands().flashDevice(binary, { port, yes }); } - flashDfu({ binary, factory, force, requestLeave }){ + flashDfu({ binary, factory, force, requestLeave }) { return Promise.resolve() .then(() => dfu.isDfuUtilInstalled()) .then(() => dfu.findCompatibleDFU()) @@ -55,17 +82,17 @@ module.exports = class FlashCommand extends CLICommandBase { try { stats = fs.statSync(binary); - } catch (error){ + } catch (error) { // file does not exist binary = dfu.checkKnownApp(binary); - if (binary === undefined){ + if (binary === undefined) { throw new Error(`file does not exist and no known app found. tried: \`${error.path}\``); } return; } - if (!stats.isFile()){ + if (!stats.isFile()) { throw new Error('You cannot flash a directory over USB'); } }) @@ -77,17 +104,17 @@ module.exports = class FlashCommand extends CLICommandBase { }); }) .then(info => { - if (info.suffixInfo.suffixSize === 65535){ + if (info.suffixInfo.suffixSize === 65535) { this.ui.write('warn: unable to verify binary info'); return; } - if (!info.crc.ok && !force){ + if (!info.crc.ok && !force) { throw new Error('CRC is invalid, use --force to override'); } const specs = deviceSpecs[dfu.dfuId]; - if (info.prefixInfo.platformID !== specs.productId && !force){ + if (info.prefixInfo.platformID !== specs.productId && !force) { throw new Error(`Incorrect platform id (expected ${specs.productId}, parsed ${info.prefixInfo.platformID}), use --force to override`); } @@ -113,5 +140,435 @@ module.exports = class FlashCommand extends CLICommandBase { throw new VError(ensureError(err), 'Error writing firmware'); }); } -}; + async flashLocal({ files, applicationOnly, target }) { + const { files: parsedFiles, deviceIdOrName, knownApp } = await this._analyzeFiles(files); + const { api, auth } = this._particleApi(); + const device = await usbUtils.getOneUsbDevice({ deviceIdOrName, api, auth, ui: this.ui }); + + const platformName = platformForId(device.platformId).name; + this.ui.write(`Flashing ${platformName} ${deviceIdOrName || device.id}`); + + let { skipDeviceOSFlash, files: filesToFlash } = await this._prepareFilesToFlash({ + knownApp, + parsedFiles, + platformId: device.platformId, + platformName, + target + }); + + filesToFlash = await this._processBundle({ filesToFlash }); + + const fileModules = await this._parseModules({ files: filesToFlash }); + await this._validateModulesForPlatform({ modules: fileModules, platformId: device.platformId, platformName }); + + const deviceOsBinaries = await this._getDeviceOsBinaries({ + currentDeviceOsVersion: device.firmwareVersion, + skipDeviceOSFlash, + target, + modules: fileModules, + platformId: device.platformId, + applicationOnly + }); + const deviceOsModules = await this._parseModules({ files: deviceOsBinaries }); + let modulesToFlash = [...fileModules, ...deviceOsModules]; + modulesToFlash = this._filterModulesToFlash({ modules: modulesToFlash, platformId: device.platformId }); + + const flashSteps = await this._createFlashSteps({ modules: modulesToFlash, isInDfuMode: device.isInDfuMode , platformId: device.platformId }); + await this._flashFiles({ device, flashSteps }); + } + + + async _analyzeFiles(files) { + const apps = knownAppNames(); + + // assume the user wants to compile/flash the current directory if no argument is passed + if (files.length === 0) { + return { + files: ['.'], + deviceIdOrName: null, + knownApp: null + }; + } + + // check if the first argument is a known app + const [knownApp] = files; + if (apps.includes(knownApp)) { + return { + files: [], + deviceIdOrName: null, + knownApp + }; + } + + // check if the second argument is a known app + if (files.length > 1) { + const [deviceIdOrName, knownApp] = files; + if (apps.includes(knownApp)) { + return { + files: [], + deviceIdOrName, + knownApp + }; + } + } + + // check if the first argument exists in the filesystem, regardless if it's a file or directory + try { + await fs.stat(files[0]); + return { + files, + deviceIdOrName: null, + knownApp: null + }; + } catch (error) { + // file doesn't exist, assume the first argument is a device + const [deviceIdOrName, ...remainingFiles] = files; + return { + files: remainingFiles, + deviceIdOrName, + knownApp: null + }; + } + } + + // Should be part fo CLICommandBase?? + _particleApi() { + const auth = settings.access_token; + const api = new ParticleApi(settings.apiUrl, { accessToken: auth } ); + return { api: api.api, auth, particleApi: api }; + } + + async _prepareFilesToFlash({ knownApp, parsedFiles, platformId, platformName, target }) { + if (knownApp) { + const knownAppPath = knownAppsForPlatform(platformName)[knownApp]; + if (knownAppPath) { + return { skipDeviceOSFlash: true, files: [knownAppPath] }; + } else { + throw new Error(`Known app ${knownApp} is not available for ${platformName}`); + } + } + + const [filePath] = parsedFiles; + let stats; + try { + stats = await fs.stat(filePath); + } catch (error) { + // ignore error + } + + // if a directory, figure out if it's a source directory that should be compiled + // or a binary directory that should be flashed directly + if (stats && stats.isDirectory()) { + const binaries = utilities.globList(filePath, binaryPatterns); + const sources = utilities.globList(filePath, sourcePatterns); + + if (binaries.length > 0 && sources.length === 0) { + // this is a binary directory so get all the binaries from all the parsedFiles + const binaries = this._findBinaries(parsedFiles); + return { skipDeviceOSFlash: false, files: binaries }; + } else if (sources.length > 0) { + // this is a source directory so compile it + const compileResult = await this._compileCode({ parsedFiles, platformId, target }); + return { skipDeviceOSFlash: false, files: compileResult }; + } else { + throw new Error('No files found to flash'); + } + } else { + // this is a file so figure out if it's a source file that should be compiled or a + // binary that should be flashed directly + if (binaryExtensions.includes(path.extname(filePath))) { + const binaries = this._findBinaries(parsedFiles); + return { skipDeviceOSFlash: false, files: binaries }; + } else { + const compileResult = await this._compileCode({ parsedFiles, platformId, target }); + return { skipDeviceOSFlash: false, files: compileResult }; + } + } + } + + async _compileCode({ parsedFiles, platformId, target }) { + const cloudCommand = new CloudCommand(); + const saveTo = temp.path({ suffix: '.zip' }); // compileCodeImpl will pick between .bin and .zip as appropriate + const { filename } = await cloudCommand.compileCodeImpl({ target, saveTo, platformId, files: parsedFiles }); + return [filename]; + } + + _findBinaries(parsedFiles) { + const binaries = new Set(); + for (const filePath of parsedFiles) { + try { + const stats = fs.statSync(filePath); + if (stats.isDirectory()) { + const found = utilities.globList(filePath, binaryPatterns); + for (const binary of found) { + binaries.add(binary); + } + } else { + binaries.add(filePath); + } + } catch (error) { + throw new Error(`I couldn't find that: ${filePath}`); + } + + } + return Array.from(binaries); + } + + async _processBundle({ filesToFlash }) { + const bundle = new BundleCommand(); + const processed = await Promise.all(filesToFlash.map(async (filename) => { + if (path.extname(filename) === '.zip') { + return bundle.extractModulesFromBundle({ bundleFilename: filename }); + } else { + return filename; + } + })); + + return processed.flat(); + } + + async _validateModulesForPlatform({ modules, platformId, platformName }) { + for (const moduleInfo of modules) { + if (moduleInfo.prefixInfo.platformID !== platformId && moduleInfo.prefixInfo.moduleFunction !== ModuleInfo.FunctionType.ASSET) { + throw new Error(`Module ${moduleInfo.filename} is not compatible with platform ${platformName}`); + } + } + + } + + async _getDeviceOsBinaries({ skipDeviceOSFlash, target, modules, currentDeviceOsVersion, platformId, applicationOnly }) { + const { particleApi } = this._particleApi(); + const { module: application, applicationDeviceOsVersion } = await this._pickApplicationBinary(modules, particleApi); + + // if files to flash include Device OS binaries, don't override them with the ones from the cloud + const includedDeviceOsModuleFunctions = [ModuleInfo.FunctionType.SYSTEM_PART, ModuleInfo.FunctionType.BOOTLOADER]; + const systemPartBinaries = modules.filter(m => includedDeviceOsModuleFunctions.includes(m.prefixInfo.moduleFunction)); + if (systemPartBinaries.length) { + return []; + } + + // no application so no need to download Device OS binaries + if (!application) { + return []; + } + + // need to get the binary required version + if (applicationOnly) { + return []; + } + + // force to flash device os binaries if target is specified + if (target) { + return deviceOsUtils.downloadDeviceOsVersionBinaries({ + api: particleApi, + platformId, + version: target, + ui: this.ui, + omitUserPart: true + }); + } + + // avoid downgrading Device OS for known application like Tinker compiled against older Device OS + if (skipDeviceOSFlash) { + return []; + } + + // if Device OS needs to be upgraded, or we don't know the current Device OS version, download the binaries + if (!currentDeviceOsVersion || semver.lt(currentDeviceOsVersion, applicationDeviceOsVersion)) { + return deviceOsUtils.downloadDeviceOsVersionBinaries({ + api: particleApi, + platformId, + version: applicationDeviceOsVersion, + ui: this.ui, + }); + } else { + // Device OS is up to date, no need to download binaries + return []; + } + } + + async _pickApplicationBinary(modules, api) { + for (const module of modules) { + // parse file and look for moduleFunction + if (module.prefixInfo.moduleFunction === ModuleInfo.FunctionType.USER_PART) { + const internalVersion = module.prefixInfo.depModuleVersion; + let applicationDeviceOsVersionData = { version: null }; + try { + applicationDeviceOsVersionData = await api.getDeviceOsVersions(module.prefixInfo.platformID, internalVersion); + } catch (error) { + // ignore if Device OS version from the application cannot be identified + } + return { module, applicationDeviceOsVersion: applicationDeviceOsVersionData.version }; + } + } + return { module: null, applicationDeviceOsVersion: null }; + } + + async _flashFiles({ device, flashSteps }) { + const progress = this._createFlashProgress({ flashSteps }); + + try { + for (const step of flashSteps) { + if (step.flashMode === 'normal') { + if (device.isInDfuMode) { + // put device in normal mode + progress({ event: 'switch-mode', mode: 'normal' }); + device = await usbUtils.reopenInNormalMode(device, { reset: true }); + } + + // flash the file in normal mode + progress({ event: 'flash-file', filename: step.name }); + await device.updateFirmware(step.data, { progress }); + + // wait for the device to apply the firmware + await delay(FLASH_APPLY_DELAY); + device = await usbUtils.reopenInNormalMode(device, { reset: false }); + } else { + if (!device.isInDfuMode) { + // put device in dfu mode + progress({ event: 'switch-mode', mode: 'DFU' }); + device = await usbUtils.reopenInDfuMode(device); + } + + // flash the file over DFU + progress({ event: 'flash-file', filename: step.name }); + // CLI always flashes to internal flash which is the DFU alt setting 0 + const altSetting = 0; + await device.writeOverDfu(step.data, { altSetting, startAddr: parseInt(step.moduleInfo.prefixInfo.moduleStartAddy, 16), progress }); + } + } + } finally { + progress({ event: 'finish' }); + await device.reset(); + await device.close(); + } + } + + _createFlashProgress({ flashSteps }) { + const NORMAL_MULTIPLIER = 10; // flashing in normal mode is slower so count each byte more + const { isInteractive } = this.ui; + let progressBar; + if (isInteractive) { + progressBar = this.ui.createProgressBar(); + // double the size to account for the erase and programming steps + const total = flashSteps.reduce((total, step) => total + step.data.length * 2 * (step.flashMode === 'normal' ? NORMAL_MULTIPLIER : 1), 0); + progressBar.start(total, 0, { description: 'Preparing to flash' }); + } + + let flashMultiplier = 1; + let eraseSize = 0; + let step = null; + let description; + return (payload) => { + switch (payload.event) { + case 'flash-file': + description = `Flashing ${payload.filename}`; + if (isInteractive) { + progressBar.update({ description }); + } else { + this.ui.stdout.write(`${description}${os.EOL}`); + } + step = flashSteps.find(step => step.name === payload.filename); + flashMultiplier = step.flashMode === 'normal' ? NORMAL_MULTIPLIER : 1; + eraseSize = 0; + break; + case 'switch-mode': + description = `Switching device to ${payload.mode} mode`; + if (isInteractive) { + progressBar.update({ description }); + } else { + this.ui.stdout.write(`${description}${os.EOL}`); + } + break; + case 'erased': + if (isInteractive) { + // In DFU, entire sectors are erased so the count of bytes can be higher than the actual size + // of the file. Ignore the extra bytes to avoid issues with the progress bar + if (step && eraseSize + payload.bytes > step.data.length) { + progressBar.increment((step.data.length - eraseSize) * flashMultiplier); + eraseSize = step.data.length; + } else { + progressBar.increment(payload.bytes * flashMultiplier); + eraseSize += payload.bytes; + } + } + break; + case 'downloaded': + if (isInteractive) { + progressBar.increment(payload.bytes * flashMultiplier); + } + break; + case 'finish': + if (isInteractive) { + progressBar.stop(); + } + break; + } + }; + } + + async _parseModules({ files }) { + return Promise.all(files.map(async (file) => { + const parser = new ModuleParser(); + const binary = await parser.parseFile(file); + return { + filename: file, + ...binary + }; + })); + + } + + _filterModulesToFlash({ modules, platformId, allowAll = false }) { + const platform = PLATFORMS.find(p => p.id === platformId); + const filteredModules = []; + // remove encrypted files + for (const moduleInfo of modules) { + const moduleType = moduleTypeToString(moduleInfo.prefixInfo.moduleFunction); + const platformModule = platform.firmwareModules.find(m => m.type === moduleType && m.index === moduleInfo.prefixInfo.moduleIndex); + // filter encrypted modules + const isEncrypted = platformModule && platformModule.encrypted; + const isRadioStack = moduleInfo.prefixInfo.moduleFunction === ModuleInfo.FunctionType.RADIO_STACK; + const isNcpFirmware = moduleInfo.prefixInfo.moduleFunction === ModuleInfo.FunctionType.NCP_FIRMWARE; + if (!isEncrypted && (!isRadioStack || allowAll) && (!isNcpFirmware || allowAll)) { + filteredModules.push(moduleInfo); + } + } + return filteredModules; + } + + async _createFlashSteps({ modules, isInDfuMode, platformId }) { + const platform = PLATFORMS.find(p => p.id === platformId); + const sortedModules = await sortBinariesByDependency(modules); + const assetModules = [], normalModules = [], dfuModules = []; + sortedModules.forEach(module => { + const data = module.prefixInfo.moduleFlags === ModuleInfo.Flags.DROP_MODULE_INFO ? module.fileBuffer.slice(module.prefixInfo.prefixSize) : module.fileBuffer; + const flashStep = { + name: path.basename(module.filename), + moduleInfo: { crc: module.crc, prefixInfo: module.prefixInfo, suffixInfo: module.suffixInfo }, + data + }; + const moduleType = moduleTypeToString(module.prefixInfo.moduleFunction); + const storage = platform.firmwareModules + .find(firmwareModule => firmwareModule.type === moduleType); + if (moduleType === 'assets') { + flashStep.flashMode = 'normal'; + assetModules.push(flashStep); + } else if (moduleType === 'bootloader' || storage.storage === 'external') { + flashStep.flashMode = 'normal'; + normalModules.push(flashStep); + } else { + flashStep.flashMode = 'dfu'; + dfuModules.push(flashStep); + } + }); + + // avoid switching to normal mode if device is already in DFU so a device with broken Device OS can get fixed + if (isInDfuMode) { + return [...dfuModules, ...normalModules, ...assetModules]; + } else { + return [...normalModules, ...dfuModules, ...assetModules]; + } + } +}; diff --git a/src/cmd/flash.test.js b/src/cmd/flash.test.js new file mode 100644 index 000000000..addabdd91 --- /dev/null +++ b/src/cmd/flash.test.js @@ -0,0 +1,564 @@ +const { expect, sinon } = require('../../test/setup'); +const fs = require('fs-extra'); // Use fs-extra instead of fs +const nock = require('nock'); +const temp = require('temp').track(); +const path = require('path'); +const FlashCommand = require('./flash'); +const BundleCommand = require('./bundle'); +const { PATH_TMP_DIR } = require('../../test/lib/env'); +const deviceOsUtils = require('../lib/device-os-version-util'); +const { firmwareTestHelper, createAssetModule, ModuleInfo, HalModuleParser } = require('binary-version-reader'); + +describe('FlashCommand', () => { + let flash; + const originalEnv = process.env; + + // returns a list of HalModule objects + const createModules = async () => { + const parser = new HalModuleParser(); + const preBootloaderBuffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.BOOTLOADER, + platformId: 6, + moduleIndex: 0, + moduleVersion: 1200, + deps: [] + }); + const preBootloader = await parser.parseBuffer({ fileBuffer: preBootloaderBuffer }); + const bootloaderBuffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.BOOTLOADER, + moduleIndex: 2, + platformId: 6, + moduleVersion: 1210, + deps: [ + { func: ModuleInfo.FunctionType.BOOTLOADER, index: 0, version: 1200 } + ] + }); + const bootloader = await parser.parseBuffer({ fileBuffer: bootloaderBuffer }); + const systemPart1Buffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.SYSTEM_PART, + moduleIndex: 1, + platformId: 6, + moduleVersion: 4100, + deps: [ + { func: ModuleInfo.FunctionType.BOOTLOADER, index: 1, version: 1210 } + ] + }); + const systemPart1 = await parser.parseBuffer({ fileBuffer: systemPart1Buffer }); + const systemPart2Buffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.SYSTEM_PART, + moduleIndex: 2, + platformId: 6, + moduleVersion: 4100, + deps: [ + { func: ModuleInfo.FunctionType.SYSTEM_PART, index: 1, version: 4100 } + ] + }); + const systemPart2 = await parser.parseBuffer({ fileBuffer: systemPart2Buffer }); + const userPart1Buffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.USER_PART, + moduleIndex: 1, + platformId: 6, + moduleVersion: 4100, + deps: [ + { func: ModuleInfo.FunctionType.SYSTEM_PART, index: 2, version: 4100 } + ] + }); + const userPart1 = await parser.parseBuffer({ fileBuffer: userPart1Buffer }); + return [ + { filename: 'preBootloader.bin', ...preBootloader }, + { filename: 'bootloader.bin', ...bootloader }, + { filename: 'systemPart1.bin', ...systemPart1 }, + { filename: 'systemPart2.bin', ...systemPart2 }, + { filename: 'userPart1.bin', ...userPart1 } + ]; + }; + + const createAssetModules = async() => { + const parser = new HalModuleParser(); + const asset1Buffer = await createAssetModule(Buffer.from('asset1'), 'asset1.txt'); + const asset1 = await parser.parseBuffer({ fileBuffer: asset1Buffer }); + const asset2Buffer = await createAssetModule(Buffer.from('asset2'), 'asset2.txt'); + const asset2 = await parser.parseBuffer({ fileBuffer: asset2Buffer }); + return [ + { filename: 'asset1.bin', ...asset1 }, + { filename: 'asset2.bin', ...asset2 } + ]; + }; + + const createExtraModules = async () => { + const parser = new HalModuleParser(); + const softDeviceBuffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.RADIO_STACK, + moduleIndex: 0, + deps: [] + }); + const softDevice = await parser.parseBuffer({ fileBuffer: softDeviceBuffer }); + const ncpBuffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.NCP_FIRMWARE, + moduleIndex: 0, + deps: [] + }); + const ncp = await parser.parseBuffer({ fileBuffer: ncpBuffer }); + const encryptedModuleBuffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.BOOTLOADER, + moduleIndex: 1, + deps: [] + }); + const encryptedModule = await parser.parseBuffer({ fileBuffer: encryptedModuleBuffer }); + return { + softDevice: { filename: 'softDevice.bin', ...softDevice }, + ncp: { filename: 'ncp.bin', ...ncp }, + encryptedModule: { filename: 'encryptedModule.bin', ...encryptedModule } + }; + }; + + beforeEach(() => { + process.env = { + ...originalEnv, + home: PATH_TMP_DIR, + }; + flash = new FlashCommand(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('_analyzeFiles', () => { + it('returns the current directory if no arguments are passed', async () => { + const files = []; + + const result = await flash._analyzeFiles(files); + + expect(result).to.eql({ files: ['.'], deviceIdOrName: null, knownApp: null }); + }); + + it('returns the known app if it is the first argument', async () => { + const files = ['tinker']; + + const result = await flash._analyzeFiles(files); + + expect(result).to.eql({ files: [], deviceIdOrName: null, knownApp: 'tinker' }); + }); + + it('returns the device name and known app if they are the first 2 arguments', async () => { + const files = ['my-device', 'tinker']; + + const result = await flash._analyzeFiles(files); + + expect(result).to.eql({ files: [], deviceIdOrName: 'my-device', knownApp: 'tinker' }); + }); + + it('returns the first argument as part of files if it exists in the filesystem', async () => { + const files = ['firmware.bin']; + sinon.stub(fs, 'stat'); + + const result = await flash._analyzeFiles(files); + + expect(result).to.eql({ files: ['firmware.bin'], deviceIdOrName: null, knownApp: null }); + }); + + it('returns the first argument as device if it does not exist in the filesystem', async () => { + const files = ['my-device', 'firmware.bin']; + const error = new Error('File not found'); + sinon.stub(fs, 'stat').rejects(error); + + const result = await flash._analyzeFiles(files); + + expect(result).to.eql({ files: ['firmware.bin'], deviceIdOrName: 'my-device', knownApp: null }); + }); + }); + + describe('_prepareFilesToFlash', () => { + it('returns the known app binary if it exists', async () => { + const knownApp = 'tinker'; + const platformName = 'photon'; + + const result = await flash._prepareFilesToFlash({ knownApp, platformName }); + + expect(result).to.have.property('skipDeviceOSFlash', true); + expect(result).to.have.property('files').with.lengthOf(1); + expect(result.files[0]).to.match(/tinker.*-photon.bin$/); + }); + + it('throws an error if there is no known app binary for the platform', async () => { + const knownApp = 'doctor'; + const platformName = 'p2'; + + let error; + try { + await flash._prepareFilesToFlash({ knownApp, platformName }); + } catch (e) { + error = e; + } + + expect(error).to.have.property('message', 'Known app doctor is not available for p2'); + }); + + it('returns a list of binaries in the directory if there are no source files', async () => { + const dir = await temp.mkdir(); + await fs.writeFile(path.join(dir, 'firmware.bin'), 'binary data'); + await fs.writeFile(path.join(dir, 'system-part1.bin'), 'binary data'); + + const result = await flash._prepareFilesToFlash({ parsedFiles: [dir] }); + + expect(result).to.eql({ + skipDeviceOSFlash: false, + files: [ + path.join(dir, 'firmware.bin'), + path.join(dir, 'system-part1.bin') + ] + }); + }); + + it('compiles and returns the binary if there are source files in the directory', async () => { + const dir = await temp.mkdir(); + await fs.writeFile(path.join(dir, 'firmware.bin'), 'binary data'); + await fs.writeFile(path.join(dir, 'project.properties'), 'project'); + const stub = sinon.stub(flash, '_compileCode').resolves(['compiled.bin']); + + const result = await flash._prepareFilesToFlash({ parsedFiles: [dir] }); + + expect(result).to.eql({ + skipDeviceOSFlash: false, + files: [ + 'compiled.bin' + ] + }); + expect(stub).to.have.been.called; + }); + + it('throws an error if the directory is empty', async () => { + const dir = await temp.mkdir(); + + let error; + try { + await flash._prepareFilesToFlash({ parsedFiles: [dir] }); + } catch (e) { + error = e; + } + + expect(error).to.have.property('message', 'No files found to flash'); + }); + + it('returns a list of binaries if binaries are passed', async () => { + const bin = await temp.path({ suffix: '.bin' }); + await fs.writeFile(bin, 'binary data'); + const dir = await temp.mkdir(); + await fs.writeFile(path.join(dir, 'system-part1.bin'), 'binary data'); + + const result = await flash._prepareFilesToFlash({ parsedFiles: [bin, dir] }); + + expect(result).to.eql({ + skipDeviceOSFlash: false, + files: [ + bin, + path.join(dir, 'system-part1.bin') + ] + }); + }); + + it('compiles and returns the binary if passed a source file', async () => { + const source = await temp.path({ suffix: '.cpp' }); + await fs.writeFile(source, 'source code'); + const dir = await temp.mkdir(); + await fs.writeFile(path.join(dir, 'project.properties'), 'project'); + const stub = sinon.stub(flash, '_compileCode').resolves(['compiled.bin']); + + const result = await flash._prepareFilesToFlash({ parsedFiles: [source, dir] }); + + expect(result).to.eql({ + skipDeviceOSFlash: false, + files: [ + 'compiled.bin' + ] + }); + expect(stub).to.have.been.called; + }); + }); + + + describe('_processBundle', () => { + it('returns a flat list of filenames after extracting bundles', async () => { + const filesToFlash = ['system-part1.bin', 'bundle.zip', 'system-part2.bin']; + sinon.stub(BundleCommand.prototype, 'extractModulesFromBundle').resolves(['application.bin', 'asset.txt']); + + const result = await flash._processBundle({ filesToFlash }); + + expect(result).to.eql(['system-part1.bin', 'application.bin', 'asset.txt', 'system-part2.bin']); + }); + }); + + describe('_validateModulesForPlatform', async () => { + let modules; + beforeEach(async () => { + modules = await createModules(); + }); + it('throws an error if a module is not for the target platform', async () => { + let error; + try { + await flash._validateModulesForPlatform({ modules, platformId: 32, platformName: 'p2' }); + } catch (e) { + error = e; + } + expect(error).to.have.property('message', 'Module preBootloader.bin is not compatible with platform p2'); + }); + it('pass in case the modules are intended for the target platform', async () => { + let error; + try { + await flash._validateModulesForPlatform({ modules, platformId: 6, platformName: 'photon' }); + } catch (e) { + error = e; + } + expect(error).to.be.undefined; + }); + it('allows asset modules for any platform', async () => { + modules = await createAssetModules(); + let error; + try { + await flash._validateModulesForPlatform({ modules, platformId: 6, platformName: 'photon' }); + } catch (e) { + error = e; + } + expect(error).to.be.undefined; + }); + }); + + describe('_filterModulesToFlash', () => { + let modules, assetModules, extraModules; + beforeEach( async () => { + modules = await createModules(); + assetModules = await createAssetModules(); + extraModules = await createExtraModules(); + }); + it('returns modules without ncp, softDevice and encrypted modules', async () => { + const filteredModules = await flash._filterModulesToFlash({ modules: [...modules, ...assetModules, extraModules.encryptedModule, extraModules.softDevice, extraModules.ncp], platformId: 32 }); + expect(filteredModules).to.have.lengthOf(7); + }); + it ('returns everything but encrypted modules if allowAll argument is passed', async () => { + const filteredModules = await flash._filterModulesToFlash({ modules: [...modules, ...assetModules, extraModules.encryptedModule, extraModules.softDevice, extraModules.ncp], platformId: 32, allowAll: true }); + expect(filteredModules).to.have.lengthOf(9); + }); + }); + + describe('_getDeviceOsBinaries', () => { + it('returns empty if there is no application binary', async () => { + const modules = await createModules(); + const userPart = modules.find(m => m.filename === 'systemPart1.bin'); + const deviceOsBinaries = await flash._getDeviceOsBinaries({ modules: [userPart] }); + expect(deviceOsBinaries).to.eql([]); + }); + it('returns empty list if applicationOnly is true', async () => { + nock('https://api.particle.io') + .intercept('/v1/device-os/versions/4100?platform_id=6', 'GET') + .reply(200, { + version: '2.3.1' + }); + const modules = await createModules(); + const userPart = modules.find(m => m.filename === 'userPart1.bin'); + const binaries = await flash._getDeviceOsBinaries({ + applicationOnly: true, + modules: [userPart] + }); + expect(binaries).to.eql([]); + }); + + it('returns empty if there is no target and skipDeviceOSFlash is true', async () => { + nock('https://api.particle.io') + .intercept('/v1/device-os/versions/1213?platform_id=12', 'GET') + .reply(200, { + version: '2.3.1' + }); + const modules = await createModules(); + const userPart = modules.find(m => m.filename === 'userPart1.bin'); + const binaries = await flash._getDeviceOsBinaries({ + skipDeviceOSFlash: true, + currentDeviceOsVersion: '0.7.0', + modules: [userPart] + }); + expect(binaries).to.eql([]); + }); + + it('returns a list of files if there is a target', async () => { + const modules = await createModules(); + const userPart = modules.find(m => m.filename === 'userPart1.bin'); + nock('https://api.particle.io') + .intercept('/v1/device-os/versions/4100?platform_id=6', 'GET') + .reply(200, { + version: '4.1.0' + }); + const stub = sinon.stub(deviceOsUtils, 'downloadDeviceOsVersionBinaries').returns([ + 'photon-bootloader@4.1.0+lto.bin', + 'photon-system-part1@4.1.0.bin' + ]); + const binaries = await flash._getDeviceOsBinaries({ + target: '4.1.0', + modules: [userPart], + platformId: 6 + }); + expect(binaries.some(file => file.includes('photon-bootloader@4.1.0+lto.bin'))).to.be.true; + expect(binaries.some(file => file.includes('photon-system-part1@4.1.0.bin'))).to.be.true; + expect(binaries).to.have.lengthOf(2); + expect(stub).to.have.been.calledOnce; + }); + + it('returns a list of files depending on user-part dependency binary', async () => { + const modules = await createModules(); + const userPart = modules.find(m => m.filename === 'userPart1.bin'); + nock('https://api.particle.io') + .intercept('/v1/device-os/versions/4100?platform_id=6', 'GET') + .reply(200, { + version: '4.1.0' + }); + const stub = sinon.stub(deviceOsUtils, 'downloadDeviceOsVersionBinaries').returns([ + 'photon-bootloader@4.1.0+lto.bin', + 'photon-system-part1@4.1.0.bin' + ]); + const binaries = await flash._getDeviceOsBinaries({ + platformId: 6, + modules: [userPart], + }); + expect(binaries.some(file => file.includes('photon-bootloader@4.1.0+lto.bin'))).to.be.true; + expect(binaries.some(file => file.includes('photon-system-part1@4.1.0.bin'))).to.be.true; + expect(binaries).to.have.lengthOf(2); + expect(stub).to.have.been.calledOnce; + }); + }); + + describe('_createFlashSteps', () => { + let preBootloaderStep, bootloaderStep, systemPart1Step, systemPart2Step, userPart1Step, modules, assetModules, asset1Step, asset2Step; + beforeEach(async() => { + modules = await createModules(); + assetModules = await createAssetModules(); + const preBootloader = modules.find( m => m.filename === 'preBootloader.bin'); + const bootloader = modules.find( m => m.filename === 'bootloader.bin'); + const systemPart1 = modules.find( m => m.filename === 'systemPart1.bin'); + const systemPart2 = modules.find( m => m.filename === 'systemPart2.bin'); + const userPart1 = modules.find( m => m.filename === 'userPart1.bin'); + const asset1 = assetModules.find( m => m.filename === 'asset1.bin'); + const asset2 = assetModules.find( m => m.filename === 'asset2.bin'); + preBootloaderStep = { + name: preBootloader.filename, + moduleInfo: { + crc: preBootloader.crc, + prefixInfo: preBootloader.prefixInfo, + suffixInfo: preBootloader.suffixInfo + }, + data: preBootloader.fileBuffer, + flashMode: 'normal' + }; + bootloaderStep = { + name: bootloader.filename, + moduleInfo: { + crc: bootloader.crc, + prefixInfo: bootloader.prefixInfo, + suffixInfo: bootloader.suffixInfo + }, + data: bootloader.fileBuffer, + flashMode: 'normal' + }; + systemPart1Step = { + name: systemPart1.filename, + moduleInfo: { + crc: systemPart1.crc, + prefixInfo: systemPart1.prefixInfo, + suffixInfo: systemPart1.suffixInfo + }, + data: systemPart1.fileBuffer, + flashMode: 'dfu' + }; + systemPart2Step = { + name: systemPart2.filename, + moduleInfo: { + crc: systemPart2.crc, + prefixInfo: systemPart2.prefixInfo, + suffixInfo: systemPart2.suffixInfo + }, + data: systemPart2.fileBuffer, + flashMode: 'dfu' + }; + userPart1Step = { + name: userPart1.filename, + moduleInfo: { + crc: userPart1.crc, + prefixInfo: userPart1.prefixInfo, + suffixInfo: userPart1.suffixInfo + }, + data: userPart1.fileBuffer, + flashMode: 'dfu' + }; + asset1Step = { + name: asset1.filename, + moduleInfo: { + crc: asset1.crc, + prefixInfo: asset1.prefixInfo, + suffixInfo: asset1.suffixInfo + }, + data: asset1.fileBuffer, + flashMode: 'normal' + }; + asset2Step = { + name: asset2.filename, + moduleInfo: { + crc: asset2.crc, + prefixInfo: asset2.prefixInfo, + suffixInfo: asset2.suffixInfo + }, + data: asset2.fileBuffer, + flashMode: 'normal' + }; + }); + + it('returns a list of flash steps', async () => { + const steps = await flash._createFlashSteps({ + modules, + platformId: 6, + isInDfuMode: false, + }); + + const expected = [ + preBootloaderStep, + bootloaderStep, + systemPart1Step, + systemPart2Step, + userPart1Step, + ]; + expect(steps).to.deep.equal(expected); + }); + + it('returns first dfu steps if isInDfuMode is true', async () => { + const steps = await flash._createFlashSteps({ + modules, + platformId: 6, + isInDfuMode: true, + }); + + const expected = [ + systemPart1Step, + systemPart2Step, + userPart1Step, + preBootloaderStep, + bootloaderStep, + ]; + expect(steps).to.deep.equal(expected); + }); + + it('returns assets at the end of the list', async () => { + const steps = await flash._createFlashSteps({ + modules: [...assetModules, ...modules], + platformId: 6, + isInDfuMode: false, + }); + const expected = [ + preBootloaderStep, + bootloaderStep, + systemPart1Step, + systemPart2Step, + userPart1Step, + asset2Step, + asset1Step, + ]; + expect(steps).to.deep.equal(expected); + }); + }); +}); diff --git a/src/cmd/usb-util.js b/src/cmd/usb-util.js index e8d0a6ca1..cab8fd3fb 100644 --- a/src/cmd/usb-util.js +++ b/src/cmd/usb-util.js @@ -1,5 +1,6 @@ const { getDevice, isDeviceId } = require('./device-util'); const { systemSupportsUdev, promptAndInstallUdevRules } = require('./udev'); +const { delay } = require('../lib/utilities'); const { getDevices, openDeviceById, @@ -8,6 +9,9 @@ const { TimeoutError } = require('../lib/require-optional')('particle-usb'); +// This timeout should be long enough to allow the bootloader apply an update +const REOPEN_TIMEOUT = 60000; + /** * USB permissions error. */ @@ -34,12 +38,15 @@ class UsbPermissionsError extends Error { * @param {Boolean} [options.dfuMode] Set to `true` if the device can be in DFU mode. * @return {Promise} */ -function openUsbDevice(usbDevice, { dfuMode = false } = {}){ +async function openUsbDevice(usbDevice, { dfuMode = false } = {}){ if (!dfuMode && usbDevice.isInDfuMode){ - return Promise.reject(new Error('The device should not be in DFU mode')); + throw new Error('The device should not be in DFU mode'); + } + try { + return await usbDevice.open(); + } catch (err) { + await handleUsbError(err); } - return Promise.resolve().then(() => usbDevice.open()) - .catch(e => handleUsbError(e)); } /** @@ -80,47 +87,37 @@ async function openUsbDeviceById(id, { displayName, dfuMode = false } = {}) { * @param {Object} api API client. * @param {String} auth Access token. * @param {Object} [options] Options. - * @param {String} [options.displayName] Device name as shown to the user. * @param {Boolean} [options.dfuMode] Set to `true` if the device can be in DFU mode. * @return {Promise} */ -function openUsbDeviceByIdOrName(idOrName, api, auth, { displayName, dfuMode = false } = {}) { - return Promise.resolve() - .then(() => { - if (isDeviceId(idOrName)) { - // Try to open the device straight away - return openDeviceById(idOrName).catch(e => { - if (!(e instanceof NotFoundError)){ - return handleUsbError(e); - } - }); - } - }) - .then(usbDevice => { - if (!usbDevice){ - return getDevice({ id: idOrName, api, auth, displayName }).then(device => { - if (device.id === idOrName){ - throw new NotFoundError(); - } - return openDeviceById(device.id).catch(e => handleUsbError(e)); - }) - .catch(e => { - if (e instanceof NotFoundError){ - throw new Error(`Unable to connect to the device ${displayName || idOrName}. Make sure the device is connected to the host computer via USB`); - } - throw e; - }); +async function openUsbDeviceByIdOrName(idOrName, api, auth, { dfuMode = false } = {}) { + let device; + if (isDeviceId(idOrName)) { + // Try to open the device straight away + try { + device = await openDeviceById(idOrName); + } catch (err) { + // continue if the device is not found + if (!(err instanceof NotFoundError)) { + await handleUsbError(err); } - return usbDevice; - }) - .then(usbDevice => { - if (!dfuMode && usbDevice.isInDfuMode){ - return usbDevice.close().then(() => { - throw new Error('The device should not be in DFU mode'); - }); - } - return usbDevice; - }); + } + } + + if (!device) { + let deviceInfo = await getDevice({ id: idOrName, api, auth }); + try { + device = await openDeviceById(deviceInfo.id); + } catch (err) { + await handleUsbError(err); + } + } + + if (!dfuMode && device.isInDfuMode){ + await device.close(); + throw new Error('The device should not be in DFU mode'); + } + return device; } /** @@ -130,22 +127,96 @@ function openUsbDeviceByIdOrName(idOrName, api, auth, { displayName, dfuMode = f * @param {Boolean} [options.dfuMode] Set to `true` to include devices in DFU mode. * @return {Promise} */ -function getUsbDevices({ dfuMode = false } = {}){ - return Promise.resolve() - .then(() => getDevices({ includeDfu: dfuMode })) - .catch((err) => handleUsbError(err)); +async function getUsbDevices({ dfuMode = false } = {}){ + try { + return await getDevices({ includeDfu: dfuMode }); + } catch (err) { + await handleUsbError(err); + } } -function handleUsbError(err){ - if (err instanceof NotAllowedError){ +async function getOneUsbDevice({ idOrName, api, auth, ui }) { + if (idOrName) { + return openUsbDeviceByIdOrName(idOrName, api, auth, { dfuMode: true }); + } + + const usbDevices = await getUsbDevices({ dfuMode: true }); + + let usbDevice; + if (usbDevices.length > 1) { + const question = { + type: 'list', + name: 'device', + message: 'Which device would you like to select?', + choices() { + return usbDevices.map((d) => { + return { + name: d.type, + value: d + }; + }); + } + }; + const nonInteractiveError = 'Multiple devices found. Connect only one device when running in non-interactive mode.'; + const ans = await ui.prompt([question], { nonInteractiveError }); + usbDevice = ans.device; + } else if (usbDevices.length === 1) { + usbDevice = usbDevices[0]; + } else { + throw new NotFoundError('No device found'); + } + + try { + await usbDevice.open(); + return usbDevice; + } catch (err) { + await handleUsbError(err); + } +} + +async function reopenInDfuMode(device) { + const { id } = device; + await device.enterDfuMode(); + await device.close(); + device = await openUsbDeviceById(id, { dfuMode: true }); + return device; +} + +async function reopenInNormalMode(device, { reset } = {}) { + const { id } = device; + if (reset && device.isOpen) { + await device.reset(); + } + await device.close(); + const start = Date.now(); + while (Date.now() - start < REOPEN_TIMEOUT) { + await delay(500); + try { + device = await openDeviceById(id); + if (device.isInDfuMode) { + await device.close(); + } else { + return device; + } + } catch (err) { + // ignore error + } + } + throw new Error('Unable to reconnect to the device. Try again or run particle update to repair the device'); +} + +async function handleUsbError(err){ + if (err instanceof NotAllowedError) { err = new UsbPermissionsError('Missing permissions to access the USB device'); - if (systemSupportsUdev()){ - return promptAndInstallUdevRules(err).catch(err => { + if (systemSupportsUdev()) { + try { + await promptAndInstallUdevRules(err); + } catch (err) { throw new UsbPermissionsError(err.message); - }); + } } } - return Promise.reject(err); + throw err; } module.exports = { @@ -153,6 +224,9 @@ module.exports = { openUsbDeviceById, openUsbDeviceByIdOrName, getUsbDevices, + getOneUsbDevice, + reopenInDfuMode, + reopenInNormalMode, UsbPermissionsError, TimeoutError }; diff --git a/src/lib/dependency-walker.js b/src/lib/dependency-walker.js new file mode 100644 index 000000000..06b2cdce0 --- /dev/null +++ b/src/lib/dependency-walker.js @@ -0,0 +1,144 @@ +const { ModuleInfo } = require('binary-version-reader'); + +class DependencyWalker { + constructor({ modules, log }) { + this._log = log; + this._modules = modules; + this._adjacencyList = new Map(); + } + + sortByDependencies(modules) { + if (modules) { + this._modules = modules; + } + + this._fillAdjacencyList(); + + // This is pretty naive but works fine for our purposes + const ordered = []; + const visited = new Set(); + for (const [m, deps] of this._adjacencyList.entries()) { + if (m in visited) { + continue; + } + const chain = []; + visited.add(m); + chain.push(m); + for (const d of deps) { + if (d in visited) { + continue; + } + visited.add(d); + chain.push(d); + } + const last = chain[chain.length - 1]; + // Place into the appropriate location in the list + ordered.splice(ordered.indexOf(last), 0, ...chain); + } + // Remove any duplicates + return new Set(ordered); + } + + _addEdgesByDependencies(module) { + // Fills the adjacency list in the reverse order of the dependencies + // e.g. system-part1 depends on bootloader: the list will contain bootloader -> system-part1 edge + for (const dep of module.dependencies) { + for (const m of this._modules) { + if (dep.func === m.prefixInfo.moduleFunction && + dep.index === m.prefixInfo.moduleIndex && + dep.version === m.prefixInfo.moduleVersion) { + // Version is ignored as we don't really known what's on device + const moduleDependencies = this._adjacencyList.get(m); + moduleDependencies.add(module); + } + } + } + } + + _fillAdjacencyList() { + this._adjacencyList = new Map(); + for (const m of this._modules) { + this._adjacencyList.set(m, new Set()); + } + for (const m of this._modules) { + this._addEdgesByDependencies(m); + } + } + +} + +async function sortBinariesByDependency(modules) { + const binariesWithDependencies = []; + // read every file and parse it + + // generate binaries before + for (const binary of modules) { + const binaryWithDependencies = { + ...binary, + dependencies: [] + }; + if (binaryWithDependencies.prefixInfo.depModuleFunction !== 0) { + const binaryDependency = + modules.find(b => + b.prefixInfo.moduleIndex === binaryWithDependencies.prefixInfo.depModuleIndex && + b.prefixInfo.moduleFunction === binaryWithDependencies.prefixInfo.depModuleFunction && + b.prefixInfo.moduleVersion === binaryWithDependencies.prefixInfo.depModuleVersion + ); + if (binaryDependency) { + binaryWithDependencies.dependencies.push({ + func: binaryDependency.prefixInfo.moduleFunction, + index: binaryDependency.prefixInfo.moduleIndex, + version: binaryDependency.prefixInfo.moduleVersion + }); + } + } + + if (binary.prefixInfo.dep2ModuleFunction !== 0) { + const binary2Dependency = + modules.find(b => + b.prefixInfo.moduleIndex === binaryWithDependencies.prefixInfo.dep2ModuleIndex && + b.prefixInfo.moduleFunction === binaryWithDependencies.prefixInfo.depModuleFunction && + b.prefixInfo.moduleVersion === binaryWithDependencies.prefixInfo.depModuleVersion + ); + if (binary2Dependency) { + binaryWithDependencies.dependencies.push({ + func: binary2Dependency.prefixInfo.moduleFunction, + index: binary2Dependency.prefixInfo.moduleIndex, + version: binary2Dependency.prefixInfo.moduleVersion + }); + } + } + binariesWithDependencies.push(binaryWithDependencies); + } + const dependencyWalker = new DependencyWalker({ modules: binariesWithDependencies }); + const sortedDependencies = dependencyWalker.sortByDependencies(binariesWithDependencies); + + return Array.from(sortedDependencies); +} + + + +function moduleTypeToString(str) { + switch (str) { + case ModuleInfo.FunctionType.BOOTLOADER: + return 'bootloader'; + case ModuleInfo.FunctionType.SYSTEM_PART: + return 'systemPart'; + case ModuleInfo.FunctionType.USER_PART: + return 'userPart'; + case ModuleInfo.FunctionType.RADIO_STACK: + return 'radioStack'; + case ModuleInfo.FunctionType.NCP_FIRMWARE: + return 'ncpFirmware'; + case ModuleInfo.FunctionType.ASSET: + return 'assets'; + default: + throw new Error(`Unknown module type: ${str}`); + } +} + +module.exports = { + DependencyWalker, + sortBinariesByDependency, + moduleTypeToString +}; diff --git a/src/lib/dependency-walker.test.js b/src/lib/dependency-walker.test.js new file mode 100644 index 000000000..3de49fce6 --- /dev/null +++ b/src/lib/dependency-walker.test.js @@ -0,0 +1,74 @@ +const { expect } = require('../../test/setup'); +const { HalModuleParser, firmwareTestHelper, ModuleInfo } = require('binary-version-reader'); +const { sortBinariesByDependency } = require('./dependency-walker'); +describe('_sortBinariesByDependency', () => { + + const createModules = async () => { + const parser = new HalModuleParser(); + const preBootloaderBuffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.BOOTLOADER, + moduleIndex: 0, + moduleVersion: 1200, + deps: [] + }); + const preBootloader = await parser.parseBuffer({ fileBuffer: preBootloaderBuffer }); + const bootloaderBuffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.BOOTLOADER, + moduleIndex: 1, + moduleVersion: 1210, + deps: [ + { func: ModuleInfo.FunctionType.BOOTLOADER, index: 0, version: 1200 } + ] + }); + const bootloader = await parser.parseBuffer({ fileBuffer: bootloaderBuffer }); + const systemPart1Buffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.SYSTEM_PART, + moduleIndex: 1, + moduleVersion: 4100, + deps: [ + { func: ModuleInfo.FunctionType.BOOTLOADER, index: 1, version: 1210 } + ] + }); + const systemPart1 = await parser.parseBuffer({ fileBuffer: systemPart1Buffer }); + const systemPart2Buffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.SYSTEM_PART, + moduleIndex: 2, + moduleVersion: 4100, + deps: [ + { func: ModuleInfo.FunctionType.SYSTEM_PART, index: 1, version: 4100 } + ] + }); + const systemPart2 = await parser.parseBuffer({ fileBuffer: systemPart2Buffer }); + const userPart1Buffer = await firmwareTestHelper.createFirmwareBinary({ + moduleFunction: ModuleInfo.FunctionType.USER_PART, + moduleIndex: 1, + moduleVersion: 4100, + deps: [ + { func: ModuleInfo.FunctionType.SYSTEM_PART, index: 2, version: 4100 } + ] + }); + const userPart1 = await parser.parseBuffer({ fileBuffer: userPart1Buffer }); + return [ + { filename: 'preBootloader.bin', ...preBootloader }, + { filename: 'bootloader.bin', ...bootloader }, + { filename: 'systemPart1.bin', ...systemPart1 }, + { filename: 'systemPart2.bin', ...systemPart2 }, + { filename: 'userPart1.bin', ...userPart1 } + ]; + }; + + it('returns a list of files sorted by dependency', async () => { + const modules = await createModules(); + const expected = [ + modules.find( m => m.filename === 'preBootloader.bin'), + modules.find( m => m.filename === 'bootloader.bin'), + modules.find( m => m.filename === 'systemPart1.bin'), + modules.find( m => m.filename === 'systemPart2.bin'), + modules.find( m => m.filename === 'userPart1.bin'), + ]; + const binaries = await sortBinariesByDependency(modules); + binaries.forEach((binary, index) => { + expect(binary.filename).to.equal(expected[index].filename); + }); + }); +}); diff --git a/src/lib/device-os-version-util.js b/src/lib/device-os-version-util.js new file mode 100644 index 000000000..6b37e2ec1 --- /dev/null +++ b/src/lib/device-os-version-util.js @@ -0,0 +1,158 @@ +const { ensureFolder } = require('../../settings'); +const deviceConstants = require('@particle/device-constants'); +const path = require('path'); +const os = require('os'); +const request = require('request'); +const fs = require('fs-extra'); +const { HalModuleParser } = require('binary-version-reader'); + +/** + * Download a file from the given url to the given directory + * @param url - the url to download from + * @param directory - the directory to download to + * @param filename - the filename to use + * @returns {Promise} - a promise that resolves when the file is downloaded + */ +async function downloadFile({ url, directory, filename }) { + let file; + try { + filename = filename || url.match(/.*\/(.*)/)[1]; + await fs.ensureDir(directory); + file = fs.createWriteStream(directory + '/' + filename); + await new Promise((resolve, reject) => { + file.on('error', (error) => { + reject(error); + }); + file.on('finish', () => { + resolve(); + }); + const req = request.get(url); + req.on('response', (response) => { + if (response.statusCode !== 200) { + req.abort(); + reject(new Error('Failed to download file: ' + response.statusCode)); + } + + response.pipe(file); + response.on('end', () => { + file.end(); + resolve(filename); + }); + }); + + req.on('error', (err) => { + reject(err); + }); + }); + return filename; + } finally { + if (file) { + file.end(); + } + } + +} + +/** + * Get the path to the binaries for the given version and platform + * @param version - the version to get the path for + * @param platformName - the platform name to get the path for + * @returns {string} - the path to the binaries + */ +function getBinaryPath(version, platformName) { + const particleDir = ensureFolder(); + return path.join(particleDir, 'device-os-flash/binaries/', version, platformName); +} +/** + * Download the binary for the given platform and module + * @param api - the api object + * @param platformName - the platform name + * @param module - the module object to download + * @param baseUrl - the base url for the api + * @param version - the version to download + * @returns {Promise} - the path to the binary + */ +async function downloadBinary({ platformName, module, baseUrl, version }) { + const binaryPath= getBinaryPath(version, platformName); + // fetch the binary + const url = `${baseUrl}/${module.filename}`; + return downloadFile({ + url, + directory: binaryPath, + filename: module.filename + }); +} + +async function isModuleDownloaded(module, version, platformName) { + // check if the module is already downloaded + const binaryPath = getBinaryPath(version, platformName); + const filePath = path.join(binaryPath, module.filename); + try { + const exits = await fs.pathExists(filePath); + if (exits) { + const parser = new HalModuleParser(); + const info = await parser.parseFile(filePath); + return info.crc.storedCrc === module.crc.storedCrc && info.crc.actualCrc === module.crc.actualCrc; + } + return false; + } catch (error) { + return false; + } +} + +/** + * Download the binaries for the given platform and version by default the latest version is downloaded + * @param {Object} api - the api object + * @param {number} platformId - the platform id + * @param {string} version - the version to download (default: latest) + * @param {Object} ui - allow us to interact in the console + * @returns {Promise<*[]>} - true if successful + */ +async function downloadDeviceOsVersionBinaries({ api, platformId, version='latest', ui }){ + try { + // get platform by id from device-constants + const platform = Object.values(deviceConstants).filter(p => p.public).find(p => p.id === platformId); + // get the device os versions + // TODO: when the machine is not connected to the internet, return without any error + const deviceOsVersion = await api.getDeviceOsVersions(platformId, version); + // omit user part application + deviceOsVersion.modules = deviceOsVersion.modules.filter(m => m.prefixInfo.moduleFunction !== 'user_part'); + + // find the modules that don't already exist on this machine + const modulesToDownload = []; + for (const module of deviceOsVersion.modules) { + const isDownloaded = await isModuleDownloaded(module, deviceOsVersion.version, platform.name); + if (!isDownloaded) { + modulesToDownload.push(module); + } + } + + // download binaries for each missing module + if (modulesToDownload.length > 0) { + const description = `Downloading Device OS ${version}`; + + await ui.showBusySpinnerUntilResolved(description, Promise.all(modulesToDownload.map(async (module) => { + await downloadBinary({ + platformName: platform.name, + module, + baseUrl: deviceOsVersion.base_url, + version: deviceOsVersion.version + }); + }))); + ui.stdout.write(`Downloaded Device OS ${version}${os.EOL}`); + } + + const binaryPath = getBinaryPath(deviceOsVersion.version, platform.name); + return deviceOsVersion.modules.map(m => path.join(binaryPath, m.filename)); + } catch (error) { + if (error.message.includes('404')) { + throw new Error(`Device OS version not found for platform: ${platformId} version: ${version}`); + } + throw new Error('Error downloading binaries for platform: ' + platformId + ' version: ' + version + ' error: ' + error.message); + } + +} + +module.exports = { + downloadDeviceOsVersionBinaries +}; diff --git a/src/lib/device-os-version-util.test.js b/src/lib/device-os-version-util.test.js new file mode 100644 index 000000000..b900edec1 --- /dev/null +++ b/src/lib/device-os-version-util.test.js @@ -0,0 +1,126 @@ +const { expect } = require('../../test/setup'); +const sinon = require('sinon'); +const fs = require('fs-extra'); +const path = require('path'); +const { downloadDeviceOsVersionBinaries } = require('./device-os-version-util'); +const nock = require('nock'); +const { PATH_TMP_DIR } = require('../../test/lib/env'); +const UI = require('./ui'); + +// stub: request, fs, api +describe('downloadDeviceOsVersionBinaries', () => { + let ui, binary; + const originalEnv = process.env; + beforeEach(async () => { + binary = await fs.readFile(path.join(__dirname, '../../test/__fixtures__/binaries/argon_stroby.bin')); + ui = new UI({ quiet: true }); + ui.chalk.enabled = false; + process.env = { + ...originalEnv, + home: PATH_TMP_DIR, + }; + await fs.ensureDir(path.join(PATH_TMP_DIR, '.particle/device-os-flash/binaries')); + }); + afterEach(async () => { + process.env = originalEnv; + sinon.restore(); + await fs.remove(path.join(PATH_TMP_DIR, '.particle/device-os-flash/binaries')); + }); + it('should download the binaries for the given platform and version by default the latest version is downloaded', async () => { + const expectedPath = path.join(PATH_TMP_DIR, '.particle/device-os-flash/binaries/2.3.1/photon'); + const api = { + getDeviceOsVersions: sinon.stub().resolves({ + version: '2.3.1', + base_url: 'https://api.particle.io/v1/firmware/device-os/v2.3.1', + modules: [ + { filename: 'photon-bootloader@2.3.1+lto.bin', prefixInfo : { moduleFunction: 'bootloader' } }, + { filename: 'photon-system-part1@2.3.1.bin', prefixInfo : { moduleFunction: 'system-part' } } + ] + }) + }; + nock('https://api.particle.io/v1/firmware/device-os/v2.3.1', ) + .intercept('/photon-bootloader@2.3.1+lto.bin', 'GET') + .reply(200, binary); + + nock('https://api.particle.io/v1/firmware/device-os/v2.3.1', ) + .intercept('/photon-system-part1@2.3.1.bin', 'GET') + .reply(200, binary); + + const data = await downloadDeviceOsVersionBinaries({ api, platformId: 6, ui }); + expect(api.getDeviceOsVersions).to.have.been.calledWith(6, 'latest'); + expect(data).to.be.an('array').with.lengthOf(2); + const files = fs.readdirSync(path.join(PATH_TMP_DIR, '.particle/device-os-flash/binaries/2.3.1/photon')); + expect(fs.existsSync(expectedPath)).to.be.true; + expect(files).to.be.an('array').with.lengthOf(2); + expect(files).to.include('photon-bootloader@2.3.1+lto.bin'); + expect(files).to.include('photon-system-part1@2.3.1.bin'); + }); + + it('should download the binaries for the given platform and version', async() => { + const api = { + getDeviceOsVersions: sinon.stub().resolves({ + version: '2.3.1', + base_url: 'https://api.particle.io/v1/firmware/device-os/v2.3.1', + modules: [ + { filename: 'photon-bootloader@2.3.1+lto.bin', prefixInfo : { moduleFunction: 'bootloader' } }, + { filename: 'photon-system-part1@2.3.1.bin', prefixInfo: { moduleFunction: 'system-part' } } + ] + }) + }; + nock('https://api.particle.io/v1/firmware/device-os/v2.3.1', ) + .intercept('/photon-bootloader@2.3.1+lto.bin', 'GET') + .reply(200, binary); + + nock('https://api.particle.io/v1/firmware/device-os/v2.3.1', ) + .intercept('/photon-system-part1@2.3.1.bin', 'GET') + .reply(200, binary); + + const data = await downloadDeviceOsVersionBinaries({ api, platformId: 6, version: '2.3.1', ui }); + expect(api.getDeviceOsVersions).to.have.been.calledWith(6, '2.3.1'); + expect(data).to.be.an('array').with.lengthOf(2); + const files = fs.readdirSync(path.join(PATH_TMP_DIR, '.particle/device-os-flash/binaries/2.3.1/photon')); + expect(files).to.be.an('array').with.lengthOf(2); + expect(files).to.include('photon-bootloader@2.3.1+lto.bin'); + expect(files).to.include('photon-system-part1@2.3.1.bin'); + + }); + + it('should fail if the platform is not supported by the requested version', async()=> { + let error; + const api = { + getDeviceOsVersions: sinon.stub().rejects(new Error('404')) + }; + try { + await downloadDeviceOsVersionBinaries({ api, platformId: 6, version: '2.3.1', ui }); + } catch (e) { + error = e; + } + expect(error.message).to.equal('Device OS version not found for platform: 6 version: 2.3.1'); + }); + + // FIXME (julien): this test was flaky so if it keeps failing, let's remove it. + // I looked for a missing await but I can't find one + // it('should fail in case of an error', async() => { + // let error; + // const api = { + // getDeviceOsVersions: sinon.stub().resolves({ + // version: '2.3.1', + // base_url: 'http://url-that-does-not-exist.com', + // modules: [ + // { filename: 'photon-bootloader@2.3.1+lto.bin', prefixInfo : { moduleFunction: 'bootloader' } }, + // { filename: 'photon-system-part1@2.3.1.bin', prefixInfo: { moduleFunction: 'system-part' } } + // ] + // }) + // }; + // const spy = sinon.spy(request, 'get'); + // + // try { + // await downloadDeviceOsVersionBinaries({ api, platformId: 6, version: '2.3.1', ui }); + // } catch (e) { + // error = e; + // } + // expect(error.message).to.equal('Error downloading binaries for platform: 6 version: 2.3.1 error: getaddrinfo ENOTFOUND url-that-does-not-exist.com'); + // expect(api.getDeviceOsVersions).to.have.been.calledWith(6, '2.3.1'); + // expect(spy).to.have.been.calledOnce; + // }); +}); diff --git a/src/lib/device-specs.js b/src/lib/device-specs.js index b2323a359..80490d1c3 100644 --- a/src/lib/device-specs.js +++ b/src/lib/device-specs.js @@ -1,6 +1,5 @@ -const path = require('path'); -const fs = require('fs'); const { PLATFORMS } = require('./platform'); +const { knownAppsForPlatform } = require('./known-apps'); /* Device specs have the following shape: @@ -152,31 +151,6 @@ function deviceIdFromSerialNumber(serialNumber) { return found[0].toLowerCase(); } } - -// Walk the assets/knownApps/${name} directory to find known app binaries for this platform -function knownAppsForPlatform(name) { - const platformKnownAppsPath = path.join(__dirname, '../../assets/knownApps', name); - try { - return fs.readdirSync(platformKnownAppsPath).reduce((knownApps, appName) => { - try { - const appPath = path.join(platformKnownAppsPath, appName); - const binaries = fs.readdirSync(appPath); - const appBinary = binaries.filter(filename => filename.match(/\.bin$/))[0]; - if (appBinary) { - knownApps[appName] = path.join(appPath, appBinary); - } - } catch (e) { - // ignore errors - } - - return knownApps; - }, {}); - } catch (e) { - // no known apps for this platform - return {}; - } -} - function generateDeviceSpecs() { return PLATFORMS.reduce((specs, device) => { const key = `${device.dfu.vendorId.replace(/0x/, '')}:${device.dfu.productId.replace(/0x/, '')}`; diff --git a/src/lib/file-types.js b/src/lib/file-types.js new file mode 100644 index 000000000..e1c3478ff --- /dev/null +++ b/src/lib/file-types.js @@ -0,0 +1,24 @@ +const sourcePatterns = [ + '**/*.h', + '**/*.hpp', + '**/*.hh', + '**/*.hxx', + '**/*.ino', + '**/*.cpp', + '**/*.c', + '**/build.mk', + 'project.properties' +]; + +const binaryExtensions = [ + '.bin', + '.zip' +]; + +const binaryPatterns = binaryExtensions.map(ext => `*${ext}`); + +module.exports = { + sourcePatterns, + binaryExtensions, + binaryPatterns +}; diff --git a/src/lib/known-apps.js b/src/lib/known-apps.js new file mode 100644 index 000000000..df4cbb2c4 --- /dev/null +++ b/src/lib/known-apps.js @@ -0,0 +1,56 @@ +const path = require('path'); +const fs = require('fs'); + + +// Walk the assets/knownApps directory to find all known apps +function knownAppNames() { + const knownAppsPath = path.join(__dirname, '../../assets/knownApps'); + const names = new Set(); + fs.readdirSync(knownAppsPath).forEach((platform) => { + const platformPath = path.join(knownAppsPath, platform); + const stat = fs.statSync(platformPath); + if (!stat.isDirectory()) { + return; + } + + fs.readdirSync(platformPath).forEach((appName) => { + const appPath = path.join(platformPath, appName); + const stat = fs.statSync(appPath); + if (!stat.isDirectory()) { + return; + } + names.add(appName); + }); + }); + + return Array.from(names); +} + +// Walk the assets/knownApps/${name} directory to find known app binaries for this platform +function knownAppsForPlatform(name) { + const platformKnownAppsPath = path.join(__dirname, '../../assets/knownApps', name); + try { + return fs.readdirSync(platformKnownAppsPath).reduce((knownApps, appName) => { + try { + const appPath = path.join(platformKnownAppsPath, appName); + const binaries = fs.readdirSync(appPath); + const appBinary = binaries.filter(filename => filename.match(/\.bin$/))[0]; + if (appBinary) { + knownApps[appName] = path.join(appPath, appBinary); + } + } catch (e) { + // ignore errors + } + + return knownApps; + }, {}); + } catch (e) { + // no known apps for this platform + return {}; + } +} + +module.exports = { + knownAppNames, + knownAppsForPlatform +}; diff --git a/src/lib/known-apps.test.js b/src/lib/known-apps.test.js new file mode 100644 index 000000000..6ba7f956f --- /dev/null +++ b/src/lib/known-apps.test.js @@ -0,0 +1,20 @@ +const { expect } = require('../../test/setup'); +const { knownAppNames, knownAppsForPlatform } = require('./known-apps'); + +describe('Known Apps', () => { + describe('knownAppsNames', () => { + it('returns all the known apps', () => { + const apps = knownAppNames(); + + expect(apps.sort()).to.eql(['doctor', 'tinker', 'tinker-usb-debugging']); + }); + }); + + describe('knownAppsForPlatform', () => { + it('returns the known apps for Photon', () => { + const apps = knownAppsForPlatform('photon'); + + expect(Object.keys(apps)).to.eql(['doctor', 'tinker']); + }); + }); +}); diff --git a/src/lib/ui/index.js b/src/lib/ui/index.js index dea8c17c8..2b8b0b5b9 100644 --- a/src/lib/ui/index.js +++ b/src/lib/ui/index.js @@ -3,14 +3,16 @@ const Chalk = require('chalk').constructor; const Spinner = require('cli-spinner').Spinner; const { platformForId, isKnownPlatformId } = require('../platform'); const settings = require('../../../settings'); - +const inquirer = require('inquirer'); +const cliProgress = require('cli-progress'); module.exports = class UI { constructor({ stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, - quiet = false + quiet = false, + isInteractive = global.isInteractive } = {}){ this.stdin = stdin; this.stdout = stdout; @@ -18,6 +20,7 @@ module.exports = class UI { this.quiet = quiet; this.chalk = new Chalk(); // TODO (mirande): explicitly enable / disable colors this.EOL = os.EOL; + this.isInteractive = isInteractive; } write(data){ @@ -30,6 +33,19 @@ module.exports = class UI { stderr.write(data + EOL); } + async prompt(question, { nonInteractiveError } = {}) { + if (!global.isInteractive){ + throw new Error(nonInteractiveError || 'Prompts are not allowed in non-interactive mode'); + } + return inquirer.prompt(question); + } + + createProgressBar() { + return new cliProgress.SingleBar({ + format: '[{bar}] {percentage}% | {description}', + }, cliProgress.Presets.shades_classic); + } + showBusySpinnerUntilResolved(text, promise){ if (this.quiet){ return promise; diff --git a/src/lib/ui/index.test.js b/src/lib/ui/index.test.js index bee16c3f1..16f7567e3 100644 --- a/src/lib/ui/index.test.js +++ b/src/lib/ui/index.test.js @@ -2,10 +2,9 @@ const stream = require('stream'); const Spinner = require('cli-spinner').Spinner; const { expect, sinon } = require('../../../test/setup'); const UI = require('./index'); - +const inquirer = require('inquirer'); describe('UI', () => { - const sandbox = sinon.createSandbox(); let stdin, stdout, stderr, ui; beforeEach(() => { @@ -30,7 +29,7 @@ describe('UI', () => { }); afterEach(() => { - sandbox.restore(); + sinon.restore(); }); describe('Writing to `stdout` and `stderr`', () => { @@ -49,10 +48,56 @@ describe('UI', () => { }); }); + describe('prompt', () => { + let mode; + beforeEach(() => { + mode = global.isInteractive; + }); + afterEach(() => { + global.isInteractive = mode; + }); + + it('throws an error when in non-interactive mode', async () => { + global.isInteractive = false; + + let error; + try { + await ui.prompt(); + } catch (e){ + error = e; + } + + expect(error).to.be.an.instanceof(Error).with.property('message', 'Prompts are not allowed in non-interactive mode'); + }); + + it('allows passing a custom non-interactive error message', async () => { + global.isInteractive = false; + + let error; + try { + await ui.prompt([], { nonInteractiveError: 'Custom error message' }); + } catch (e){ + error = e; + } + + expect(error).to.be.an.instanceof(Error).with.property('message', 'Custom error message'); + }); + + it('creates a prompt in interactive mode', async () => { + global.isInteractive = true; + const stub = sinon.stub(inquirer, 'prompt').resolves(); + const question = { name: 'test', message: 'Do it?' }; + + await ui.prompt([question]); + + expect(stub).to.have.been.calledWith([question]); + }); + }); + describe('Spinner helpers', () => { beforeEach(() => { - sandbox.spy(Spinner.prototype, 'start'); - sandbox.spy(Spinner.prototype, 'stop'); + sinon.spy(Spinner.prototype, 'start'); + sinon.spy(Spinner.prototype, 'stop'); }); it('Shows a spinner until promise is resolved', async () => { diff --git a/test/__fixtures__/binaries/argon-system-part1@4.1.0.bin b/test/__fixtures__/binaries/argon-system-part1@4.1.0.bin new file mode 100644 index 000000000..93204708e Binary files /dev/null and b/test/__fixtures__/binaries/argon-system-part1@4.1.0.bin differ diff --git a/test/e2e/flash.e2e.js b/test/e2e/flash.e2e.js index 3c266bfcf..38405464e 100644 --- a/test/e2e/flash.e2e.js +++ b/test/e2e/flash.e2e.js @@ -19,22 +19,32 @@ describe('Flash Commands [@device]', () => { ' -q, --quiet Decreases how much logging to display [count]', '', 'Options:', - ' --cloud Flash over the air to the device. Default if no other flag provided [boolean]', - ' --usb Flash over USB using the DFU utility [boolean]', - ' --serial Flash over a virtual serial port [boolean]', - ' --factory Flash user application to the factory reset location. Only available for DFU [boolean]', - ' --force Flash even when binary does not pass pre-flash checks [boolean]', - ' --yes Answer yes to all questions [boolean]', - ' --target The firmware version to compile against. Defaults to latest version, or version on device for cellular. [string]', - ' --port Use this serial port instead of auto-detecting. Useful if there are more than 1 connected device. Only available for serial [string]', + ' --cloud Flash over the air to the device. Default if no other flag provided [boolean]', + ' --local Flash locally, updating Device OS as needed [boolean]', + ' --usb Flash over USB using the DFU utility [boolean]', + ' --serial Flash over a virtual serial port [boolean]', + ' --factory Flash user application to the factory reset location. Only available for DFU [boolean]', + ' --force Flash even when binary does not pass pre-flash checks [boolean]', + ' --yes Answer yes to all questions [boolean]', + ' --target The firmware version to compile against. Defaults to latest version. [string]', + ' --application-only Do not update Device OS [boolean]', + ' --port Use this serial port instead of auto-detecting. Useful if there are more than 1 connected device. Only available for serial [string]', '', 'Examples:', ' particle flash red Compile the source code in the current directory in the cloud and flash to device red', ' particle flash green tinker Flash the default Tinker app to device green', - ' particle flash blue app.ino --target 0.6.3 Compile app.ino in the cloud using the 0.6.3 firmware and flash to device blue', + ' particle flash blue app.ino --target 5.0.0 Compile app.ino in the cloud using the 5.0.0 firmware and flash to device blue', ' particle flash cyan firmware.bin Flash the pre-compiled binary to device cyan', + ' particle flash --local Compile the source code in the current directory in the cloud and flash to the device connected over USB', + ' particle flash --local --target 5.0.0 Compile the source code in the current directory in the cloud against the target version and flash to the device connected over USB', + ' particle flash --local application.bin Flash the pre-compiled binary to the device connected over USB', + ' particle flash --local application.zip Flash the pre-compiled binary and assets from the bundle to the device connected over USB', + ' particle flash --local tinker Flash the default Tinker app to the device connected over USB', ' particle flash --usb firmware.bin Flash the binary over USB. The device needs to be in DFU mode', - ' particle flash --serial firmware.bin Flash the binary over virtual serial port. The device needs to be in listening mode' + ' particle flash --serial firmware.bin Flash the binary over virtual serial port. The device needs to be in listening mode', + '', + 'When passing the --local flag, Device OS will be updated if the version on the device is outdated.', + 'When passing both the --local and --target flash, Device OS will be updated to the target version.' ]; before(async () => { @@ -59,7 +69,7 @@ describe('Flash Commands [@device]', () => { it('Shows `help` content when run without arguments', async () => { const { stdout, stderr, exitCode } = await cli.run('flash'); - expect(stdout).to.equal(''); + expect(stdout).to.equal('You must specify a device or a file'); expect(stderr.split('\n')).to.include.members(help); expect(exitCode).to.equal(1); // TODO (mirande): should be 0? });