diff --git a/.gitignore b/.gitignore index 5ec0852e..a16b4ff3 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ balenarc.yml build/ build-bin/ build-zip/ +dist/ # Ignore fast-boot cache file **/.fast-boot.json diff --git a/.travis.yml b/.travis.yml index cc18cee2..dc306149 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,15 +3,21 @@ os: - linux - osx node_js: - - "6" -before_install: -- npm -g install npm@4 -script: npm run ci + - "10" +script: + - node --version + - npm --version + - npm run ci notifications: email: false deploy: - provider: script - script: npm run release + script: + - node --version + - npm --version + - npm run build:standalone + - npm run build:installer + - npm run release skip_cleanup: true on: tags: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..955726fa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing + +The balena CLI is an open source project and your contribution is welcome! + +After cloning this repository and running `npm install`, the CLI can be built with `npm run build` +and executed with `./bin/run`. In order to ease development: + +* `npm run build:fast` skips some of the build steps for interactive testing, or +* `./bin/balena-dev` uses `ts-node/register` and `coffeescript/register` to transpile on the fly. + +Before opening a PR, please be sure to test your changes with `npm test`. + +## Semantic versioning and commit messages + +The CLI version numbering adheres to [Semantic Versioning](http://semver.org/). The following +header/row is required in the body of a commit message, and will cause the CI build to fail if absent: + +``` +Change-type: patch|minor|major +``` + +Version numbers and commit messages are automatically added to the `CHANGELOG.md` file by the CI +build flow, after a pull request is merged. It should not be manually edited. + +## Editing documentation files (CHANGELOG, README, website...) + +The `doc/cli.markdown` file is automatically generated by running `npm run build:doc` (which also +runs as part of `npm run build`). That file is then pulled by scripts in the +[balena-io/docs](https://github.com/balena-io/docs/) GitHub repo for publishing at the [CLI +Documentation page](https://www.balena.io/docs/reference/cli/). + +The content sources for the auto generation of `doc/cli.markdown` are: + +* Selected sections of the README file. +* The CLI's command documentation in source code (both Capitano and oclif commands), for example: + * `lib/actions/build.coffee` + * `lib/actions-oclif/env/add.ts` + +The README file is manually edited, but subsections are automatically extracted for inclusion in +`doc/cli.markdown` by the `getCapitanoDoc()` function in +[`automation/capitanodoc/capitanodoc.ts`](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts). + +The `INSTALL.md` and `TROUBLESHOOTING.md` files are also manually edited. + +## Windows + +Please note that `npm run build:installer` (which generates the `.exe` executable installer on +Windows) requires [MSYS2](https://www.msys2.org/) to be installed. Other than that, the standard +Command Prompt or PowerShell can be used. + +## TypeScript vs CoffeeScript, and Capitano vs oclif + +The CLI was originally written in [CoffeeScript](https://coffeescript.org), but we decided to +migrate to [TypeScript](https://www.typescriptlang.org/) in order to take advantage of static +typing and formal programming interfaces. The migration is taking place gradually, as part of +maintenance work or the implementation of new features. + +Similarly, [Capitano](https://github.com/balena-io/capitano) was originally adopted as the CLI's +framework, but we recently decided to take advantage of [oclif](https://oclif.io/)'s features such +as native installers for Windows, macOS and Linux, and support for custom flag parsing (for +example, we're still battling with Capitano's behavior of dropping leading zeros of arguments that +look like integers such as some abbreviated UUIDs, and migrating to oclif is a solution). Again the +migration is taking place gradually, with some CLI commands parsed by oclif and others by Capitano +(a simple command line pre-parsing takes place in `app.ts` to decide whether to route full parsing +to Capitano or oclif). diff --git a/INSTALL.md b/INSTALL.md index 8ca83a7f..93ff854d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,30 +1,70 @@ # balena CLI Installation Instructions -The easiest and recommended way of installing the CLI on all platforms (Windows, Linux, macOS) is -to use the [Standalone Installation](#standalone-installation) described below. Some specific CLI -commands have a few extra installation steps: see section [Additional Dependencies](#additional-dependencies). +There are 3 options to choose from to install balena's CLI: + +* [Executable Installer](#executable-installer): the easiest method, using the traditional + graphical desktop application installers for Windows and macOS (coming soon for Linux users too). +* [Standalone Zip Package](#standalone-zip-package): these are plain zip files with the balena CLI + executable in them. Recommended for scripted installation in CI (continuous integration) + environments. +* [NPM Installation](#npm-installation): recommended for developers who may be interested in + integrating the balena CLI in their existing Node.js projects or workflow. + +Some specific CLI commands have a few extra installation steps: see section [Additional +Dependencies](#additional-dependencies). > **Windows users:** We now have a [YouTube video tutorial](https://www.youtube.com/watch?v=2LApclXFqsg) for installing and getting started with the balena CLI on Windows! -## Standalone Installation +## Executable Installer -1. Download the latest zip file for your OS from https://github.com/balena-io/balena-cli/releases. - (Note that "[Darwin](https://en.wikipedia.org/wiki/Darwin_(operating_system))" is the - appropriate zip file for macOS.) -2. Extract the zip file contents to any folder you choose. The extracted contents will include a - `balena-cli` folder. -3. Add the `balena-cli` folder to the system's `PATH` environment variable. See instructions for: - [Windows](https://www.computerhope.com/issues/ch000549.htm) | - [Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) | - [macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) +_Please note: the executable installers are in **beta** status (recently introduced)._ -Check that the installation was successful by opening or re-opening a command terminal window -(so that the PATH environment variable changes take effect), and running these commands: +1. Download the latest installer from the [releases page](https://github.com/balena-io/balena-cli/releases). + Look for a file name that ends with "installer-BETA", for example: + `balena-cli-v10.13.6-windows-x64-installer-BETA.exe` + `balena-cli-v10.13.6-macOS-x64-installer-BETA.pkg` +2. Double click to run. Your system may raise a pop-up warning that the installer is from an + "unknown publisher" or "unidentified developer". Check the following instructions for how + to get through the warnings: + [Windows](https://github.com/balena-io/balena-cli/issues/1250) or + [macOS](https://github.com/balena-io/balena-cli/issues/1251). + (We are looking at how to get the installers digitally signed to avoid the warnings.) + +After the installation completes, close and re-open any open command terminal windows so that the +changes made by the installer to the PATH environment variable can take effect. Check that the +installation was successful by running these commands: * `balena` - should print the balena CLI help * `balena version` - should print the installed CLI version +> Note: If you had previously installed the CLI using a standalone zip package, it may be a good +> idea to check your system's `PATH` environment variable for duplicate entries, as the terminal +> will use the entry that comes first. Check the [Standalone Zip Package](#standalone-zip-package) +> instructions for how to modify the PATH variable. + +By default, the CLI is installed to the following folders: + +OS | Folders +--- | --- +Windows: | `C:\Program Files\balena-cli\` +macOS: | `/usr/local/lib/balena-cli/`
`/usr/local/bin/balena` + +## Standalone Zip Package + +1. Download the latest zip file from the [releases page](https://github.com/balena-io/balena-cli/releases). + Look for a file name that ends with the word "standalone", for example: + `balena-cli-v10.13.6-linux-x64-standalone.zip` + `balena-cli-v10.13.6-macOS-x64-standalone.zip` + `balena-cli-v10.13.6-windows-x64-standalone.zip` +2. Extract the zip file contents to any folder you choose. The extracted contents will include a + `balena-cli` folder. +3. Add the `balena-cli` folder to the system's `PATH` environment variable. + See instructions for: + [Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) | + [macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) | + [Windows](https://www.computerhope.com/issues/ch000549.htm) + To update the CLI to a new version, download a new release zip file and replace the previous installation folder. To uninstall, simply delete the folder and edit the PATH environment variable as described above. diff --git a/README.md b/README.md index 60ac4ee8..0bf1e2c5 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ are supported. We are aware of users also having a good experience with alternat including: * Microsoft's [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) - (a.k.a. Microsoft's "bash for Windows 10"). -* [Git for Windows](https://git-for-windows.github.io/). -* [MinGW](http://www.mingw.org): install the `msys-rsync` and `msys-openssh` packages too. + (a.k.a. Microsoft's "bash for Windows 10") +* [Git for Windows](https://git-for-windows.github.io/) +* [MSYS](http://www.mingw.org/wiki/MSYS) and [MSYS2](https://www.msys2.org/) (install the + `msys-rsync` and `msys-openssh` packages too) On **macOS** and **Linux,** the standard terminal window is supported. _Optionally,_ `bash` command auto completion may be enabled by copying the @@ -52,14 +53,14 @@ $ balena login HTTP(S) proxies can be configured through any of the following methods, in order of preference: -* Set the \`BALENARC_PROXY\` environment variable in URL format (with protocol, host, port, and +* Set the `BALENARC_PROXY` environment variable in URL format (with protocol, host, port, and optionally basic auth). * Alternatively, use the [balena config file](https://www.npmjs.com/package/balena-settings-client#documentation) - (project-specific or user-level) and set the \`proxy\` setting. It can be: + (project-specific or user-level) and set the `proxy` setting. It can be: * A string in URL format, or * An object in the [global-tunnel-ng options format](https://www.npmjs.com/package/global-tunnel-ng#options) (which allows more control). -* Alternatively, set the conventional \`https_proxy\` / \`HTTPS_PROXY\` / \`http_proxy\` / \`HTTP_PROXY\` -environment variable (in the same standard URL format). +* Alternatively, set the conventional `https_proxy` / `HTTPS_PROXY` / `http_proxy` / `HTTP_PROXY` + environment variable (in the same standard URL format). To get a proxy to work with the `balena ssh` command, check the [installation instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md). @@ -78,30 +79,11 @@ If you come across any problems or would like to get in touch: * For bug reports or feature requests, [have a look at the GitHub issues or create a new one](https://github.com/balena-io/balena-cli/issues/). -## Contributing +## Contributing (including editing documentation files) -The balena CLI is an open source project and your contribution is welcome! - -The CLI was originally written in [CoffeeScript](https://coffeescript.org), but we have decided to -migrate to [TypeScript](https://www.typescriptlang.org/) in order to take advantage of static -typing and formal programming interfaces. The migration is taking place gradually, as part of -maintenance work or the implementation of new features. - -After cloning this repository and running `npm install` you can build the CLI using `npm run build`. -You can then run the generated build using `./bin/balena`. -In order to ease development: - -* you can build the CLI using the `npm run build:fast` variant which skips some of the build steps or -* you can use `./bin/balena-dev` which live transpiles the sources of the CLI. - -In either case, before opening a PR be sure to also test your changes with `npm test`. - -## Note on editing this README document - -This file is edited/created by hand, but it is then automatically parsed to extract selected -subsections for the [CLI's web documentation page](https://www.balena.io/docs/reference/cli/). -The code that parses this file is in [`automation/capitanodoc/capitanodoc.ts` -](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts). +Please have a look at the [CONTRIBUTING.md](./CONTRIBUTING.md) file for some guidance before +submitting a pull request or updating documentation (because some files are automatically +generated). Thank you for your help and interest! ## License diff --git a/appveyor.yml b/appveyor.yml index ab9ebe80..8eb60841 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,8 @@ # appveyor file # http://www.appveyor.com/docs/appveyor-yml +image: Visual Studio 2017 + init: - git config --global core.autocrlf input @@ -14,12 +16,12 @@ matrix: # what combinations to test environment: matrix: - - nodejs_version: 6 + - nodejs_version: 10 install: - ps: Install-Product node $env:nodejs_version x64 - - npm install -g npm@4 - set PATH=%APPDATA%\npm;%PATH% + - npm config set python 'C:\Python27\python.exe' - npm install build: off @@ -27,8 +29,12 @@ build: off test_script: - node --version - npm --version - - cmd: npm test + - npm test deploy_script: + - node --version + - npm --version + - npm run build:standalone + - npm run build:installer - IF "%APPVEYOR_REPO_TAG%" == "true" (npm run release) - IF NOT "%APPVEYOR_REPO_TAG%" == "true" (echo 'Not tagged, skipping deploy') diff --git a/automation/build-bin.ts b/automation/build-bin.ts old mode 100755 new mode 100644 index 506dd536..43348c69 --- a/automation/build-bin.ts +++ b/automation/build-bin.ts @@ -14,50 +14,106 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { run as oclifRun } from '@oclif/dev-cli'; import * as Bluebird from 'bluebird'; import * as filehound from 'filehound'; import * as fs from 'fs-extra'; import * as path from 'path'; import { exec as execPkg } from 'pkg'; +import * as rimraf from 'rimraf'; -const ROOT = path.join(__dirname, '..'); +export const ROOT = path.join(__dirname, '..'); -console.log('Building package...\n'); +/** + * Use the 'pkg' module to create a single large executable file with + * the contents of 'node_modules' and the CLI's javascript code. + * Also copy a number of native modules (binary '.node' files) that are + * compiled during 'npm install' to the 'build-bin' folder, alongside + * the single large executable file created by pkg. (This is necessary + * because of a pkg limitation that does not allow binary executables + * to be directly executed from inside another binary executable.) + */ +export async function buildPkg() { + console.log('Building package...\n'); -execPkg(['--target', 'host', '--output', 'build-bin/balena', 'package.json']) - .then(() => { - const xpaths: Array<[string, string[]]> = [ - // [platform, [path, to, file]] - ['*', ['opn', 'xdg-open']], - ['darwin', ['denymount', 'bin', 'denymount']], - ]; - return Bluebird.map(xpaths, ([platform, xpath]) => { - if (platform === '*' || platform === process.platform) { - // eg copy from node_modules/opn/xdg-open to build-bin/xdg-open - return fs.copy( - path.join(ROOT, 'node_modules', ...xpath), - path.join(ROOT, 'build-bin', xpath.pop()!), - ); - } - }).return(); - }) - .then(() => { - return filehound - .create() - .paths(path.join(ROOT, 'node_modules')) - .ext(['node', 'dll']) - .find(); - }) - .then(nativeExtensions => { - console.log(`\nCopying to build-bin:\n${nativeExtensions.join('\n')}`); - - return nativeExtensions.map(extPath => { + await execPkg([ + '--target', + 'host', + '--output', + 'build-bin/balena', + 'package.json', + ]); + const xpaths: Array<[string, string[]]> = [ + // [platform, [path, to, file]] + ['*', ['opn', 'xdg-open']], + ['darwin', ['denymount', 'bin', 'denymount']], + ]; + await Bluebird.map(xpaths, ([platform, xpath]) => { + if (platform === '*' || platform === process.platform) { + // eg copy from node_modules/opn/xdg-open to build-bin/xdg-open return fs.copy( - extPath, - extPath.replace( - path.join(ROOT, 'node_modules'), - path.join(ROOT, 'build-bin'), - ), + path.join(ROOT, 'node_modules', ...xpath), + path.join(ROOT, 'build-bin', xpath.pop()!), ); - }); + } }); + const nativeExtensionPaths: string[] = await filehound + .create() + .paths(path.join(ROOT, 'node_modules')) + .ext(['node', 'dll']) + .find(); + + console.log(`\nCopying to build-bin:\n${nativeExtensionPaths.join('\n')}`); + + await Bluebird.map(nativeExtensionPaths, extPath => + fs.copy( + extPath, + extPath.replace( + path.join(ROOT, 'node_modules'), + path.join(ROOT, 'build-bin'), + ), + ), + ); +} + +/** + * Run the `oclif-dev pack:win` or `pack:macos` command (depending on the value + * of process.platform) to generate the native installers (which end up under + * the 'dist' folder). There are some harcoded options such as selecting only + * 64-bit binaries under Windows. + */ +export async function buildOclifInstaller() { + console.log(`buildOclifInstaller cwd="${process.cwd()}" ROOT="${ROOT}"`); + let packOS = ''; + let packOpts = ['-r', ROOT]; + if (process.platform === 'darwin') { + packOS = 'macos'; + } else if (process.platform === 'win32') { + packOS = 'win'; + packOpts = packOpts.concat('-t', 'win32-x64'); + } + if (packOS) { + const packCmd = `pack:${packOS}`; + const dirs = [path.join(ROOT, 'dist', packOS)]; + if (packOS === 'win') { + dirs.push(path.join(ROOT, 'tmp', 'win*')); + } + for (const dir of dirs) { + console.log(`rimraf(${dir})`); + await Bluebird.fromCallback(cb => rimraf(dir, cb)); + } + console.log('======================================================='); + console.log(`oclif-dev "${packCmd}" [${packOpts}]`); + console.log('======================================================='); + oclifRun([packCmd].concat(...packOpts)); + } +} + +/** + * Convert e.g. 'C:\myfolder' -> '/C/myfolder' so that the path can be given + * as argument to "unix tools" like 'tar' under MSYS or MSYS2 on Windows. + */ +export function fixPathForMsys(p: string): string { + return p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, '/$1'); +} diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index 423d5dbb..c98b66d7 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -48,7 +48,10 @@ const capitanoDoc = { }, { title: 'Environment Variables', - files: ['build/actions/environment-variables.js'], + files: [ + 'build/actions/environment-variables.js', + 'build/actions-oclif/env/add.js', + ], }, { title: 'Tags', @@ -70,10 +73,6 @@ const capitanoDoc = { title: 'Logs', files: ['build/actions/logs.js'], }, - { - title: 'Sync', - files: ['build/actions/sync.js'], - }, { title: 'SSH', files: ['build/actions/ssh.js', 'build/actions/tunnel.js'], diff --git a/automation/capitanodoc/doc-types.d.ts b/automation/capitanodoc/doc-types.d.ts index 536cf6c8..195fa47b 100644 --- a/automation/capitanodoc/doc-types.d.ts +++ b/automation/capitanodoc/doc-types.d.ts @@ -1,4 +1,23 @@ -import { CommandDefinition } from 'capitano'; +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Command as OclifCommandClass } from '@oclif/command'; +import { CommandDefinition as CapitanoCommand } from 'capitano'; + +type OclifCommand = typeof OclifCommandClass; export interface Document { title: string; @@ -8,7 +27,7 @@ export interface Document { export interface Category { title: string; - commands: CommandDefinition[]; + commands: Array; } -export { CommandDefinition as Command }; +export { CapitanoCommand, OclifCommand }; diff --git a/automation/capitanodoc/index.ts b/automation/capitanodoc/index.ts index d0d08864..150f27d2 100644 --- a/automation/capitanodoc/index.ts +++ b/automation/capitanodoc/index.ts @@ -18,7 +18,7 @@ import * as _ from 'lodash'; import * as path from 'path'; import { getCapitanoDoc } from './capitanodoc'; -import { Category, Document } from './doc-types'; +import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types'; import * as markdown from './markdown'; /** @@ -39,25 +39,40 @@ export async function renderMarkdown(): Promise { commands: [], }; - for (const file of commandCategory.files) { - const actions: any = require(path.join(process.cwd(), file)); - - if (actions.signature) { - category.commands.push(_.omit(actions, 'action')); - } else { - for (const actionName of Object.keys(actions)) { - const actionCommand = actions[actionName]; - category.commands.push(_.omit(actionCommand, 'action')); - } - } + for (const jsFilename of commandCategory.files) { + category.commands.push( + ...(jsFilename.includes('actions-oclif') + ? importOclifCommands(jsFilename) + : importCapitanoCommands(jsFilename)), + ); } - result.categories.push(category); } return markdown.render(result); } +function importCapitanoCommands(jsFilename: string): CapitanoCommand[] { + const actions = require(path.join(process.cwd(), jsFilename)); + const commands: CapitanoCommand[] = []; + + if (actions.signature) { + commands.push(_.omit(actions, 'action')); + } else { + for (const actionName of Object.keys(actions)) { + const actionCommand = actions[actionName]; + commands.push(_.omit(actionCommand, 'action')); + } + } + return commands; +} + +function importOclifCommands(jsFilename: string): OclifCommand[] { + const command: OclifCommand = require(path.join(process.cwd(), jsFilename)) + .default as OclifCommand; + return [command]; +} + /** * Print the CLI docs markdown to stdout. * See package.json for how the output is redirected to a file. diff --git a/automation/capitanodoc/markdown.ts b/automation/capitanodoc/markdown.ts index 20b4b6c9..974d7b09 100644 --- a/automation/capitanodoc/markdown.ts +++ b/automation/capitanodoc/markdown.ts @@ -14,81 +14,139 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { flagUsages } from '@oclif/parser'; import * as ent from 'ent'; import * as _ from 'lodash'; -import { Category, Command, Document } from './doc-types'; +import { getManualSortCompareFunction } from '../../lib/utils/helpers'; +import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types'; import * as utils from './utils'; -export function renderCommand(command: Command) { - let result = `## ${ent.encode(command.signature)}\n\n${command.help}\n`; +function renderCapitanoCommand(command: CapitanoCommand): string[] { + const result = [`## ${ent.encode(command.signature)}`, command.help]; if (!_.isEmpty(command.options)) { - result += '\n### Options'; + result.push('### Options'); for (const option of command.options!) { - result += `\n\n#### ${utils.parseSignature(option)}\n\n${ - option.description - }`; + if (option == null) { + throw new Error(`Undefined option in markdown generation!`); + } + result.push( + `#### ${utils.parseCapitanoOption(option)}`, + option.description, + ); } - - result += '\n'; } - return result; } -export function renderCategory(category: Category) { - let result = `# ${category.title}\n`; +function renderOclifCommand(command: OclifCommand): string[] { + const result = [`## ${ent.encode(command.usage)}`]; + const description = (command.description || '') + .split('\n') + .slice(1) // remove the first line, which oclif uses as help header + .join('\n') + .trim(); + result.push(description); + if (!_.isEmpty(command.examples)) { + result.push('Examples:', command.examples!.map(v => `\t${v}`).join('\n')); + } + + if (!_.isEmpty(command.args)) { + result.push('### Arguments'); + for (const arg of command.args!) { + result.push(`#### ${arg.name.toUpperCase()}`, arg.description || ''); + } + } + + if (!_.isEmpty(command.flags)) { + result.push('### Options'); + for (const [name, flag] of Object.entries(command.flags!)) { + if (name === 'help') { + continue; + } + flag.name = name; + const flagUsage = flagUsages([flag]) + .map(([usage, _description]) => usage) + .join() + .trim(); + result.push(`#### ${flagUsage}`); + result.push(flag.description || ''); + } + } + return result; +} + +function renderCategory(category: Category): string[] { + const result = [`# ${category.title}`]; for (const command of category.commands) { - result += `\n${renderCommand(command)}`; + result.push( + ...(typeof command === 'object' + ? renderCapitanoCommand(command) + : renderOclifCommand(command)), + ); } - return result; } -function getAnchor(command: Command) { - return ( - '#' + - command.signature - .replace(/\s/g, '-') - .replace(//g, '-') - .replace(/\[/g, '-') - .replace(/\]/g, '-') - .replace(/-+/g, '-') - .replace(/-$/, '') - .replace(/\.\.\./g, '') - .replace(/\|/g, '') - .toLowerCase() - ); +function getAnchor(cmdSignature: string): string { + return `#${_.trim(cmdSignature.replace(/\W+/g, '-'), '-').toLowerCase()}`; } -export function renderToc(categories: Category[]) { - let result = `# CLI Command Reference\n`; +function renderToc(categories: Category[]): string[] { + const result = [`# CLI Command Reference`]; for (const category of categories) { - result += `\n- ${category.title}\n\n`; + result.push(`- ${category.title}`); + result.push( + category.commands + .map(command => { + const signature = + typeof command === 'object' + ? command.signature // Capitano + : utils.capitanoizeOclifUsage(command.usage); // oclif + return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`; + }) + .join('\n'), + ); + } + return result; +} - for (const command of category.commands) { - result += `\t- [${ent.encode(command.signature)}](${getAnchor( - command, - )})\n`; +const manualCategorySorting: { [category: string]: string[] } = { + 'Environment Variables': ['envs', 'env rm', 'env add', 'env rename'], +}; + +function sortCommands(doc: Document): void { + for (const category of doc.categories) { + if (category.title in manualCategorySorting) { + category.commands = category.commands.sort( + getManualSortCompareFunction( + manualCategorySorting[category.title], + (cmd: CapitanoCommand | OclifCommand, x: string) => + typeof cmd === 'object' // Capitano vs oclif command + ? cmd.signature.replace(/\W+/g, ' ').includes(x) + : (cmd.usage || '') + .toString() + .replace(/\W+/g, ' ') + .includes(x), + ), + ); } } - - return result; } export function render(doc: Document) { - let result = `# ${doc.title}\n\n${doc.introduction}\n\n${renderToc( - doc.categories, - )}`; - + sortCommands(doc); + const result = [ + `# ${doc.title}`, + doc.introduction, + ...renderToc(doc.categories), + ]; for (const category of doc.categories) { - result += `\n${renderCategory(category)}`; + result.push(...renderCategory(category)); } - - return result; + return result.join('\n\n'); } diff --git a/automation/capitanodoc/utils.ts b/automation/capitanodoc/utils.ts index 060cf6f8..2eca5c7b 100644 --- a/automation/capitanodoc/utils.ts +++ b/automation/capitanodoc/utils.ts @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { OptionDefinition } from 'capitano'; import * as ent from 'ent'; import * as fs from 'fs'; @@ -32,7 +33,7 @@ export function getOptionSignature(signature: string) { return `${getOptionPrefix(signature)}${signature}`; } -export function parseSignature(option: OptionDefinition) { +export function parseCapitanoOption(option: OptionDefinition): string { let result = getOptionSignature(option.signature); if (_.isArray(option.alias)) { @@ -50,6 +51,16 @@ export function parseSignature(option: OptionDefinition) { return ent.encode(result); } +/** Convert e.g. 'env add NAME [VALUE]' to 'env add [value]' */ +export function capitanoizeOclifUsage( + oclifUsage: string | string[] | undefined, +): string { + return (oclifUsage || '') + .toString() + .replace(/(?<=\s)[A-Z]+(?=(\s|$))/g, match => `<${match}>`) + .toLowerCase(); +} + export class MarkdownFileParser { constructor(public mdFilePath: string) {} diff --git a/automation/custom-types.d.ts b/automation/custom-types.d.ts deleted file mode 100644 index 21ec5fad..00000000 --- a/automation/custom-types.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -declare module 'pkg' { - export function exec(args: string[]): Promise; -} - -declare module 'filehound' { - export function create(): FileHound; - - export interface FileHound { - paths(paths: string[]): FileHound; - paths(...paths: string[]): FileHound; - ext(extensions: string[]): FileHound; - ext(...extensions: string[]): FileHound; - find(): Promise; - } -} - -declare module 'publish-release' { - interface PublishOptions { - token: string; - owner: string; - repo: string; - tag: string; - name: string; - reuseRelease?: boolean; - assets: string[]; - } - - interface Release { - html_url: string; - } - - let publishRelease: ( - args: PublishOptions, - callback: (e: Error, release: Release) => void, - ) => void; - - export = publishRelease; -} diff --git a/automation/deploy-bin.ts b/automation/deploy-bin.ts index 8201f8ae..15852ed7 100644 --- a/automation/deploy-bin.ts +++ b/automation/deploy-bin.ts @@ -15,70 +15,133 @@ * limitations under the License. */ import * as archiver from 'archiver'; -import * as Promise from 'bluebird'; +import * as Bluebird from 'bluebird'; import * as fs from 'fs-extra'; -import * as mkdirp from 'mkdirp'; -import * as os from 'os'; +import * as _ from 'lodash'; import * as path from 'path'; import * as publishRelease from 'publish-release'; -import * as packageJSON from '../package.json'; - -const publishReleaseAsync = Promise.promisify(publishRelease); -const mkdirpAsync = Promise.promisify(mkdirp); - const { GITHUB_TOKEN } = process.env; const ROOT = path.join(__dirname, '..'); - +// Note: the following 'tslint disable' line was only required to +// satisfy ts-node under Appveyor's MSYS2 on Windows -- oddly specific. +// Maybe something to do with '/' vs '\' in paths in some tslint file. +// tslint:disable-next-line:no-var-requires +const packageJSON = require(path.join(ROOT, 'package.json')); const version = 'v' + packageJSON.version; -const outputFile = path.join( - ROOT, - 'build-zip', - `balena-cli-${version}-${os.platform()}-${os.arch()}.zip`, -); +const arch = process.arch; -mkdirpAsync(path.dirname(outputFile)) - .then( - () => - new Promise((resolve, reject) => { - console.log('Zipping build...'); +function dPath(...paths: string[]) { + return path.join(ROOT, 'dist', ...paths); +} - const archive = archiver('zip', { - zlib: { level: 7 }, - }); - archive.directory(path.join(ROOT, 'build-bin'), 'balena-cli'); +interface PathByPlatform { + [platform: string]: string; +} - const outputStream = fs.createWriteStream(outputFile); +const standaloneZips: PathByPlatform = { + linux: dPath(`balena-cli-${version}-linux-${arch}-standalone.zip`), + darwin: dPath(`balena-cli-${version}-macOS-${arch}-standalone.zip`), + win32: dPath(`balena-cli-${version}-windows-${arch}-standalone.zip`), +}; - outputStream.on('close', resolve); - outputStream.on('error', reject); +const oclifInstallers: PathByPlatform = { + darwin: dPath('macos', `balena-${version}.pkg`), + win32: dPath('win', `balena-${version}-${arch}.exe`), +}; - archive.on('error', reject); - archive.on('warning', console.warn); +const renamedOclifInstallers: PathByPlatform = { + darwin: dPath(`balena-cli-${version}-macOS-${arch}-installer-BETA.pkg`), + win32: dPath(`balena-cli-${version}-windows-${arch}-installer-BETA.exe`), +}; - archive.pipe(outputStream); - archive.finalize(); - }), - ) - .then(() => { - console.log('Build zipped'); - console.log('Publishing build...'); +const finalReleaseAssets: { [platform: string]: string[] } = { + win32: [standaloneZips['win32'], renamedOclifInstallers['win32']], + darwin: [standaloneZips['darwin'], renamedOclifInstallers['darwin']], + linux: [standaloneZips['linux']], +}; - return publishReleaseAsync({ +/** + * Create the zip file for the standalone 'pkg' bundle previously created + * by the buildPkg() function in 'build-bin.ts'. + */ +export async function zipStandaloneInstaller() { + const outputFile = standaloneZips[process.platform]; + if (!outputFile) { + throw new Error( + `Standalone installer unavailable for platform "${process.platform}"`, + ); + } + await fs.mkdirp(path.dirname(outputFile)); + await new Bluebird((resolve, reject) => { + console.log(`Zipping build to "${outputFile}"...`); + + const archive = archiver('zip', { + zlib: { level: 7 }, + }); + archive.directory(path.join(ROOT, 'build-bin'), 'balena-cli'); + + const outputStream = fs.createWriteStream(outputFile); + + outputStream.on('close', resolve); + outputStream.on('error', reject); + + archive.on('error', reject); + archive.on('warning', console.warn); + + archive.pipe(outputStream); + archive.finalize(); + }); + console.log('Build zipped'); +} + +/** + * Create or update a release in GitHub's releases page, uploading the + * installer files (standalone zip + native oclif installers). + */ +export async function createGitHubRelease() { + console.log(`Publishing release ${version} to GitHub`); + const ghRelease = await Bluebird.fromCallback( + publishRelease.bind(null, { token: GITHUB_TOKEN || '', owner: 'balena-io', repo: 'balena-cli', tag: version, name: `balena-CLI ${version}`, reuseRelease: true, - assets: [outputFile], - }); - }) - .then(release => { - console.log(`Release ${version} successful: ${release.html_url}`); - }) - .catch(err => { + assets: finalReleaseAssets[process.platform], + }), + ); + console.log(`Release ${version} successful: ${ghRelease.html_url}`); +} + +/** + * Top-level function to create a CLI release in GitHub's releases page: + * call zipStandaloneInstaller(), rename the files as we'd like them to + * display on the releases page, and call createGitHubRelease() to upload + * the files. + */ +export async function release() { + console.log(`Creating release assets for CLI ${version}`); + try { + await zipStandaloneInstaller(); + } catch (error) { + console.log(`Error creating standalone installer zip file: ${error}`); + process.exit(1); + } + if (process.platform === 'win32' || process.platform === 'darwin') { + if (await fs.pathExists(oclifInstallers[process.platform])) { + await fs.rename( + oclifInstallers[process.platform], + renamedOclifInstallers[process.platform], + ); + } + } + try { + await createGitHubRelease(); + } catch (err) { console.error('Release failed'); console.error(err); process.exit(1); - }); + } +} diff --git a/automation/run.ts b/automation/run.ts new file mode 100644 index 00000000..08beb0e2 --- /dev/null +++ b/automation/run.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { spawn } from 'child_process'; +import * as _ from 'lodash'; +import * as shellEscape from 'shell-escape'; + +import { + buildOclifInstaller, + buildPkg, + fixPathForMsys, + ROOT, +} from './build-bin'; +import { release } from './deploy-bin'; + +/** + * Run the MSYS2 bash.exe shell in a child process (child_process.spawn()). + * The given argv arguments are escaped using the 'shell-escape' package, + * so that backslashes in Windows paths, and other bash-special characters, + * are preserved. If argv is not provided, defaults to process.argv, to the + * effect that this current (parent) process is re-executed under MSYS2 bash. + * This is useful to change the default shell from cmd.exe to MSYS2 bash on + * Windows. + * @param argv Arguments to be shell-escaped and given to MSYS2 bash.exe. + */ +export async function runUnderMsys(argv?: string[]) { + const newArgv = argv || process.argv; + await new Promise((resolve, reject) => { + const cmd = 'C:\\msys64\\usr\\bin\\bash.exe'; + const args = ['-lc', shellEscape(newArgv)]; + const child = spawn(cmd, args, { stdio: 'inherit' }); + child.on('close', code => { + if (code) { + console.log(`runUnderMsys: child process exited with code ${code}`); + reject(code); + } else { + resolve(); + } + }); + }); +} + +/** + * Trivial command-line parser. Check whether the command-line argument is one + * of the following strings, then call the appropriate functions: + * 'build:installer' (to build a native oclif installer) + * 'build:standalone' (to build a standalone pkg package) + * 'release' (to create/update a GitHub release) + * + * In the case of 'build:installer', also call runUnderMsys() to switch the + * shell from cmd.exe to MSYS2 bash.exe. + * + * @param args Arguments to parse (default is process.argv.slice(2)) + */ +export async function run(args?: string[]) { + args = args || process.argv.slice(2); + console.log(`automation/run.ts process.argv=[${process.argv}]\n`); + console.log(`automation/run.ts args=[${args}]`); + if (_.isEmpty(args)) { + console.error('Error: missing args'); + process.exit(1); + } + const commands: { [cmd: string]: () => void } = { + 'build:installer': buildOclifInstaller, + 'build:standalone': buildPkg, + release, + }; + for (const arg of args) { + if (!commands.hasOwnProperty(arg)) { + throw new Error(`Error: unknown build target: ${arg}`); + } + } + + // If runUnderMsys() is called to re-execute this script under MSYS2, + // the current working dir becomes the MSYS2 homedir, so we change back. + process.chdir(ROOT); + + for (const arg of args) { + if (arg === 'build:installer' && process.platform === 'win32') { + // ensure running under MSYS2 + if (!process.env.MSYSTEM) { + process.env.MSYS2_PATH_TYPE = 'inherit'; + await runUnderMsys([ + fixPathForMsys(process.argv[0]), + fixPathForMsys(process.argv[1]), + arg, + ]); + continue; + } + if (process.env.MSYS2_PATH_TYPE !== 'inherit') { + throw new Error('the MSYS2_PATH_TYPE env var must be set to "inherit"'); + } + } + await commands[arg](); + } +} + +run(); diff --git a/automation/tsconfig.json b/automation/tsconfig.json index 938fd3d7..83d916dc 100644 --- a/automation/tsconfig.json +++ b/automation/tsconfig.json @@ -1,16 +1,19 @@ { "compilerOptions": { "module": "commonjs", - "target": "es2015", + "target": "es2017", "strict": true, + "strictPropertyInitialization": false, "noUnusedLocals": true, "noUnusedParameters": true, "preserveConstEnums": true, "removeComments": true, - "sourceMap": true - }, - "include": [ - "./**/*.ts", - "../typings/*.d.ts" - ] + "resolveJsonModule": true, + "sourceMap": true, + "skipLibCheck": true, + "typeRoots" : [ + "../node_modules/@types", + "../typings" + ] + } } diff --git a/balena-completion.bash b/balena-completion.bash index 498bc65b..a1500b6c 100644 --- a/balena-completion.bash +++ b/balena-completion.bash @@ -7,7 +7,7 @@ _balena_complete() # Valid top-level completions commands="app apps build config deploy device devices env envs help key \ keys local login logout logs note os preload quickstart settings \ - signup ssh sync util version whoami" + scan ssh util version whoami" # Sub-completions app_cmds="create restart rm" config_cmds="generate inject read reconfigure write" @@ -16,7 +16,7 @@ _balena_complete() device_public_url_cmds="disable enable status" env_cmds="add rename rm" key_cmds="add rm" - local_cmds="configure flash logs push scan ssh stop" + local_cmds="configure flash" os_cmds="build-config configure download initialize versions" util_cmds="available-drives" diff --git a/bin/balena b/bin/balena index cc67e77c..69f81d46 100755 --- a/bin/balena +++ b/bin/balena @@ -8,4 +8,5 @@ process.env.UV_THREADPOOL_SIZE = '64'; require('fast-boot2').start({ cacheFile: __dirname + '/.fast-boot.json' }) -require('../build/app'); +// Run the CLI +require('../build/app').run(); diff --git a/bin/balena-dev b/bin/balena-dev index c00fc85d..2b0dfdb4 100755 --- a/bin/balena-dev +++ b/bin/balena-dev @@ -12,8 +12,12 @@ process.env.UV_THREADPOOL_SIZE = '64'; // Use fast-boot to cache require lookups, speeding up startup require('fast-boot2').start({ cacheFile: '.fast-boot.json' -}) -process.env['TS_NODE_PROJECT'] = require('path').dirname(__dirname); +}); require('coffeescript/register'); -require('ts-node/register'); -require('../lib/app'); +// Note: before ts-node v6.0.0, 'transpile-only' (no type checking) was the +// default option. We upgraded ts-node and found that adding 'transpile-only' +// was necessary to avoid a mysterious 'null' error message. On the plus side, +// it is supposed to run faster. We still benefit from type checking when +// running 'npm run build'. +require('ts-node/register/transpile-only'); +require('../lib/app').run(); diff --git a/doc/cli.markdown b/doc/cli.markdown index a2b4196e..6503fc02 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -21,9 +21,10 @@ are supported. We are aware of users also having a good experience with alternat including: * Microsoft's [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) - (a.k.a. Microsoft's "bash for Windows 10"). -* [Git for Windows](https://git-for-windows.github.io/). -* [MinGW](http://www.mingw.org): install the `msys-rsync` and `msys-openssh` packages too. + (a.k.a. Microsoft's "bash for Windows 10") +* [Git for Windows](https://git-for-windows.github.io/) +* [MSYS](http://www.mingw.org/wiki/MSYS) and [MSYS2](https://www.msys2.org/) (install the + `msys-rsync` and `msys-openssh` packages too) On **macOS** and **Linux,** the standard terminal window is supported. _Optionally,_ `bash` command auto completion may be enabled by copying the @@ -44,14 +45,14 @@ $ balena login HTTP(S) proxies can be configured through any of the following methods, in order of preference: -* Set the \`BALENARC_PROXY\` environment variable in URL format (with protocol, host, port, and +* Set the `BALENARC_PROXY` environment variable in URL format (with protocol, host, port, and optionally basic auth). * Alternatively, use the [balena config file](https://www.npmjs.com/package/balena-settings-client#documentation) - (project-specific or user-level) and set the \`proxy\` setting. It can be: + (project-specific or user-level) and set the `proxy` setting. It can be: * A string in URL format, or * An object in the [global-tunnel-ng options format](https://www.npmjs.com/package/global-tunnel-ng#options) (which allows more control). -* Alternatively, set the conventional \`https_proxy\` / \`HTTPS_PROXY\` / \`http_proxy\` / \`HTTP_PROXY\` -environment variable (in the same standard URL format). +* Alternatively, set the conventional `https_proxy` / `HTTPS_PROXY` / `http_proxy` / `HTTP_PROXY` + environment variable (in the same standard URL format). To get a proxy to work with the `balena ssh` command, check the [installation instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md). @@ -84,7 +85,6 @@ If you come across any problems or would like to get in touch: - [login](#login) - [logout](#logout) - - [signup](#signup) - [whoami](#whoami) - Device @@ -109,7 +109,7 @@ If you come across any problems or would like to get in touch: - [envs](#envs) - [env rm <id>](#env-rm-id) - - [env add <key> [value]](#env-add-key-value) + - [env add <name> [value]](#env-add-name-value) - [env rename <id> <value>](#env-rename-id-value) - Tags @@ -137,13 +137,9 @@ If you come across any problems or would like to get in touch: - [logs <uuidOrDevice>](#logs-uuidordevice) -- Sync - - - [sync [uuid]](#sync-uuid) - - SSH - - [ssh [uuid]](#ssh-uuid) + - [ssh <applicationOrDevice> [serviceName]](#ssh-applicationordevice-servicename) - [tunnel <uuid>](#tunnel-uuid) - Notes @@ -182,11 +178,6 @@ If you come across any problems or would like to get in touch: - [local configure <target>](#local-configure-target) - [local flash <image>](#local-flash-image) - - [local logs [deviceIp]](#local-logs-deviceip) - - [local scan](#local-scan) - - [local ssh [deviceIp]](#local-ssh-deviceip) - - [local push [deviceIp]](#local-push-deviceip) - - [local stop [deviceIp]](#local-stop-deviceip) - Deploy @@ -338,21 +329,6 @@ Examples: $ balena logout -## signup - -Use this command to signup for a balena account. - -If signup is successful, you'll be logged in to your new user automatically. - -Examples: - - $ balena signup - Email: johndoe@acme.com - Password: *********** - - $ balena whoami - johndoe - ## whoami Use this command to find out the current logged in username and email address. @@ -633,43 +609,52 @@ confirm non interactively device -## env add <key> [value] +## env add NAME [VALUE] -Use this command to add an enviroment or config variable to an application -or device. +Add an environment or config variable to an application or device, as selected +by the respective command-line options. -If value is omitted, the tool will attempt to use the variable's value -as defined in your host machine. +If VALUE is omitted, the CLI will attempt to use the value of the environment +variable of same name in the CLI process' environment. In this case, a warning +message will be printed. Use `--quiet` to suppress it. -Use the `--device` option if you want to assign the environment variable -to a specific device. - -If the value is grabbed from the environment, a warning message will be printed. -Use `--quiet` to remove it. - -Service-specific variables are not currently supported. The following -examples set variables that apply to all services in an app or device. +Service-specific variables are not currently supported. The given command line +examples variables that apply to all services in an app or device. Examples: - $ balena env add EDITOR vim --application MyApp $ balena env add TERM --application MyApp + $ balena env add EDITOR vim --application MyApp $ balena env add EDITOR vim --device 7cf02a6 +### Arguments + +#### NAME + +environment or config variable name + +#### VALUE + +variable value; if omitted, use value from CLI's environment + ### Options -#### --application, -a, --app <application> +#### -a, --application APPLICATION application name -#### --device, -d <device> +#### -d, --device DEVICE -device uuid +device UUID + +#### -q, --quiet + +suppress warning messages ## env rename <id> <value> Use this command to change the value of an application or device -enviroment variable. +environment variable. The --device option selects a device instead of an application. @@ -865,6 +850,7 @@ Examples: $ balena logs 192.168.0.31 $ balena logs 192.168.0.31 --service my-service + $ balena logs 192.168.0.31 --service my-service-1 --service my-service-2 $ balena logs 23c73a1.local --system $ balena logs 23c73a1.local --system --service my-service @@ -877,140 +863,56 @@ continuously stream output #### --service, -s <service> -Only show logs for a single service. This can be used in combination with --system +Reject logs not originating from this service. +This can be used in combination with --system or other --service flags. #### --system, -S Only show system logs. This can be used in combination with --service. -# Sync - -## sync [uuid] - -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Deprecation notice: please note that `balena sync` is deprecated and will -be removed in a future release of the CLI. We are working on an exciting -replacement that will be released soon! -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Warning: 'balena sync' requires an openssh-compatible client and 'rsync' to -be correctly installed in your shell environment. For more information (including -Windows support) please check the README here: https://github.com/balena-io/balena-cli - -Use this command to sync your local changes to a certain device on the fly. - -After every 'balena sync' the updated settings will be saved in -'/.balena-sync.yml' and will be used in later invocations. You can -also change any option by editing '.balena-sync.yml' directly. - -Here is an example '.balena-sync.yml' : - - $ cat $PWD/.balena-sync.yml - uuid: 7cf02a6 - destination: '/usr/src/app' - before: 'echo Hello' - after: 'echo Done' - ignore: - - .git - - node_modules/ - -Command line options have precedence over the ones saved in '.balena-sync.yml'. - -If '.gitignore' is found in the source directory then all explicitly listed files will be -excluded from the syncing process. You can choose to change this default behavior with the -'--skip-gitignore' option. - -Examples: - - $ balena sync 7cf02a6 --source . --destination /usr/src/app - $ balena sync 7cf02a6 -s /home/user/myBalenaProject -d /usr/src/app --before 'echo Hello' --after 'echo Done' - $ balena sync --ignore lib/ - $ balena sync --verbose false - $ balena sync - -### Options - -#### --source, -s <path> - -local directory path to synchronize to device - -#### --destination, -d <path> - -destination path on device - -#### --ignore, -i <paths> - -comma delimited paths to ignore when syncing - -#### --skip-gitignore - -do not parse excluded/included files from .gitignore - -#### --skip-restart - -do not restart container after syncing - -#### --before, -b <command> - -execute a command before syncing - -#### --after, -a <command> - -execute a command after syncing - -#### --port, -t <port> - -ssh port - -#### --progress, -p - -show progress - -#### --verbose, -v - -increase verbosity - # SSH -## ssh [uuid] +## ssh <applicationOrDevice> [serviceName] + +This command can be used to start a shell on a local or remote device. + +If a service name is not provided, a shell will be opened on the host OS. + +If an application name is provided, all online devices in the application +will be presented, and the chosen device will then have a shell opened on +in it's service container or host OS. + +For local devices, the ip address and .local domain name are supported. + +Examples: + balena ssh MyApp + + balena ssh f49cefd + balena ssh f49cefd my-service + balena ssh f49cefd --port + + balena ssh 192.168.0.1 --verbose + balena ssh f49cefd.local my-service Warning: 'balena ssh' requires an openssh-compatible client to be correctly installed in your shell environment. For more information (including Windows -support) please check the README here: https://github.com/balena-io/balena-cli - -Use this command to get a shell into the running application container of -your device. - -Examples: - - $ balena ssh MyApp - $ balena ssh 7cf02a6 - $ balena ssh 7cf02a6 --port 8080 - $ balena ssh 7cf02a6 -v - $ balena ssh 7cf02a6 -s - $ balena ssh 7cf02a6 --noninteractive +support) please check: + https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies ### Options #### --port, -p <port> -ssh gateway port +SSH gateway port #### --verbose, -v -increase verbosity - -#### --host, -s - -access host OS (for devices with balenaOS >= 2.0.0+rev1) +Increase verbosity #### --noproxy -don't use the proxy configuration for this connection. Only makes sense if you've configured proxy globally. - -#### --noninteractive - -run command non-interactively, do not automatically suggest devices to connect to if UUID not found +Don't use the proxy configuration for this connection. This flag +only make sense if you've configured a proxy globally. ## tunnel <uuid> @@ -1404,7 +1306,7 @@ Disables check for matching architecture in image and application #### --pin-device-to-release, -p -Pin the preloaded device (not application) to the preloaded release on provision +Pin the preloaded device to the preloaded release on provision #### --add-certificate <certificate.crt> @@ -1421,7 +1323,7 @@ Path to a local docker socket (e.g. /var/run/docker.sock) Docker daemon hostname or IP address (dev machine or balena device) -#### --dockerPort, -p <dockerPort> +#### --dockerPort <dockerPort> Docker daemon TCP port number (hint: 2375 for balena devices) @@ -1458,9 +1360,12 @@ The logs from only a single service can be shown with the --service flag, and showing only the system logs can be achieved with --system. Note that these flags can be used together. -It is also possible to run a push to a local mode device in live mode. -This will watch for changes in the source directory and perform an -in-place build in the running containers [BETA]. +When pushing to a local device a live session will be started. +The project source folder is watched for filesystem events, and changes +to files and folders are automatically synchronized to the running +containers. The synchronisation is only in one direction, from this machine to +the device, and changes made on the device itself may be overwritten. +This feature requires a device running supervisor version v9.7.0 or greater. The --registry-secrets option specifies a JSON or YAML file containing private Docker registry usernames and passwords to be used when pulling base images. @@ -1486,6 +1391,7 @@ Examples: $ balena push 10.0.0.1 --source $ balena push 10.0.0.1 --service my-service $ balena push 10.0.0.1 --env MY_ENV_VAR=value --env my-service:SERVICE_VAR=value + $ balena push 10.0.0.1 --nolive $ balena push 23c73a1.local --system $ balena push 23c73a1.local --system --service my-service @@ -1512,16 +1418,11 @@ Don't use cache when building this project Path to a local YAML or JSON file containing Docker registry passwords used to pull base images -#### --live, -l +#### --nolive -Note this feature is in beta. - -Start a live session with the containers pushed to a local mode device. -The project source folder is watched for filesystem events, and changes -to files and folders are automatically synchronized to the running -containers. The synchronisation is only in one direction, from this machine to -the device, and changes made on the device itself may be overwritten. -This feature requires a device running supervisor version v9.7.0 or greater. +Don't run a live session on this push. The filesystem will not be monitored, and changes +will not be synchronised to any running containers. Note that both this flag and --detached +and required to cause the process to end once the initial build has completed. #### --detached, -d @@ -1529,7 +1430,8 @@ Don't tail application logs when pushing to a local mode device #### --service <service> -Only show logs from a single service. This can be used in combination with --system. +Reject logs not originating from this service. +This can be used in combination with --system and other --service flags. Only valid when pushing to a local mode device. #### --system @@ -1588,212 +1490,6 @@ confirm non-interactively drive -## local logs [deviceIp] - - -Examples: - - $ balena local logs - $ balena local logs -f - $ balena local logs 192.168.1.10 - $ balena local logs 192.168.1.10 -f - $ balena local logs 192.168.1.10 -f --app-name myapp - -### Options - -#### --follow, -f - -follow log - -#### --app-name, -a <name> - -name of container to get logs from - -## local scan - - -Examples: - - $ balena local scan - $ balena local scan --timeout 120 - $ balena local scan --verbose - -### Options - -#### --verbose, -v - -Display full info - -#### --timeout, -t <timeout> - -Scan timeout in seconds - -## local ssh [deviceIp] - -Warning: 'balena local ssh' requires an openssh-compatible client to be correctly -installed in your shell environment. For more information (including Windows -support) please check the README here: https://github.com/balena-io/balena-cli - -Use this command to get a shell into the running application container of -your device. - -The '--host' option will get you a shell into the Host OS of the balenaOS device. -No option will return a list of containers to enter or you can explicitly select -one by passing its name to the --container option - -Examples: - - $ balena local ssh - $ balena local ssh --host - $ balena local ssh --container chaotic_water - $ balena local ssh --container chaotic_water --port 22222 - $ balena local ssh --verbose - -### Options - -#### --verbose, -v - -increase verbosity - -#### --host, -s - -get a shell into the host OS - -#### --container, -c <container> - -name of container to access - -#### --port, -p <port> - -ssh port number (default: 22222) - -## local push [deviceIp] - -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Deprecation notice: `balena local push` is deprecated and will be removed in a -future release of the CLI. Please use `balena push ` instead. -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Use this command to push your local changes to a container on a LAN-accessible -balenaOS device on the fly. - -This command requires an openssh-compatible 'ssh' client and 'rsync' to be -available in the executable PATH of the shell environment. For more information -(including Windows support) please check the README at: -https://github.com/balena-io/balena-cli - -If `Dockerfile` or any file in the 'build-triggers' list is changed, -a new container will be built and run on your device. -If not, changes will simply be synced with `rsync` into the application container. - -After every 'balena local push' the updated settings will be saved in -'/.balena-sync.yml' and will be used in later invocations. You can -also change any option by editing '.balena-sync.yml' directly. - -Here is an example '.balena-sync.yml' : - - $ cat $PWD/.balena-sync.yml - local_balenaos: - app-name: local-app - build-triggers: - - Dockerfile: file-hash-abcdefabcdefabcdefabcdefabcdefabcdef - - package.json: file-hash-abcdefabcdefabcdefabcdefabcdefabcdef - environment: - - MY_VARIABLE=123 - - -Command line options have precedence over the ones saved in '.balena-sync.yml'. - -If '.gitignore' is found in the source directory then all explicitly listed files will be -excluded when using rsync to update the container. You can choose to change this default behavior with the -'--skip-gitignore' option. - -Examples: - - $ balena local push - $ balena local push --app-name test-server --build-triggers package.json,requirements.txt - $ balena local push --force-build - $ balena local push --force-build --skip-logs - $ balena local push --ignore lib/ - $ balena local push --verbose false - $ balena local push 192.168.2.10 --source . --destination /usr/src/app - $ balena local push 192.168.2.10 -s /home/user/balenaProject -d /usr/src/app --before 'echo Hello' --after 'echo Done' - -### Options - -#### --source, -s <path> - -root of project directory to push - -#### --destination, -d <path> - -destination path on device container - -#### --ignore, -i <paths> - -comma delimited paths to ignore when syncing with 'rsync' - -#### --skip-gitignore - -do not parse excluded/included files from .gitignore - -#### --before, -b <command> - -execute a command before pushing - -#### --after, -a <command> - -execute a command after pushing - -#### --progress, -p - -show progress - -#### --skip-logs - -do not stream logs after push - -#### --verbose, -v - -increase verbosity - -#### --app-name, -n <name> - -application name - may contain lowercase characters, digits and one or more dashes. It may not start or end with a dash. - -#### --build-triggers, -r <files> - -comma delimited file list that will trigger a container rebuild if changed - -#### --force-build, -f - -force a container build and run - -#### --env, -e <env> - -environment variable (e.g. --env 'ENV=value'). Multiple --env parameters are supported. - -## local stop [deviceIp] - - -Examples: - - $ balena local stop - $ balena local stop --app-name myapp - $ balena local stop --all - $ balena local stop 192.168.1.10 - $ balena local stop 192.168.1.10 --app-name myapp - -### Options - -#### --all - -stop all containers - -#### --app-name, -a <name> - -name of container to stop - # Deploy ## build [source] @@ -2082,4 +1778,3 @@ Examples: Use this command to list your machine's drives usable for writing the OS image to. Skips the system drives. - diff --git a/lib/actions-oclif/env/add.ts b/lib/actions-oclif/env/add.ts new file mode 100644 index 00000000..0a9a6a1d --- /dev/null +++ b/lib/actions-oclif/env/add.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command, flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; + +import { CommandHelp } from '../../utils/oclif-utils'; + +interface FlagsDef { + application?: string; + device?: string; + help: void; + quiet: boolean; +} + +interface ArgsDef { + name: string; + value?: string; +} + +export default class EnvAddCmd extends Command { + public static description = stripIndent` + Add an environment or config variable to an application or device. + + Add an environment or config variable to an application or device, as selected + by the respective command-line options. + + If VALUE is omitted, the CLI will attempt to use the value of the environment + variable of same name in the CLI process' environment. In this case, a warning + message will be printed. Use \`--quiet\` to suppress it. + + Service-specific variables are not currently supported. The given command line + examples variables that apply to all services in an app or device. +`; + public static examples = [ + '$ balena env add TERM --application MyApp', + '$ balena env add EDITOR vim --application MyApp', + '$ balena env add EDITOR vim --device 7cf02a6', + ]; + + public static args = [ + { + name: 'name', + required: true, + description: 'environment or config variable name', + }, + { + name: 'value', + required: false, + description: + "variable value; if omitted, use value from CLI's environment", + }, + ]; + + // hardcoded 'env add' to avoid oclif's 'env:add' topic syntax + public static usage = + 'env add ' + new CommandHelp({ args: EnvAddCmd.args }).defaultUsage(); + + public static flags = { + application: flags.string({ + char: 'a', + description: 'application name', + exclusive: ['device'], + }), + device: flags.string({ + char: 'd', + description: 'device UUID', + exclusive: ['application'], + }), + help: flags.help({ char: 'h' }), + quiet: flags.boolean({ + char: 'q', + description: 'suppress warning messages', + default: false, + }), + }; + + public async run() { + const { args: params, flags: options } = this.parse( + EnvAddCmd, + ); + const Bluebird = await import('bluebird'); + const _ = await import('lodash'); + const balena = (await import('balena-sdk')).fromSharedOptions(); + const { exitWithExpectedError } = await import('../../utils/patterns'); + + const cmd = this; + + await Bluebird.try(async function() { + if (params.value == null) { + params.value = process.env[params.name]; + + if (params.value == null) { + throw new Error( + `Environment value not found for variable: ${params.name}`, + ); + } else if (!options.quiet) { + cmd.warn( + `Using ${params.name}=${params.value} from CLI process environment`, + ); + } + } + + const reservedPrefixes = await getReservedPrefixes(); + const isConfigVar = _.some(reservedPrefixes, prefix => + _.startsWith(params.name, prefix), + ); + + if (options.application) { + return balena.models.application[ + isConfigVar ? 'configVar' : 'envVar' + ].set(options.application, params.name, params.value); + } else if (options.device) { + return balena.models.device[isConfigVar ? 'configVar' : 'envVar'].set( + options.device, + params.name, + params.value, + ); + } else { + exitWithExpectedError('You must specify an application or device'); + } + }); + } +} + +async function getReservedPrefixes(): Promise { + const balena = (await import('balena-sdk')).fromSharedOptions(); + const settings = await balena.settings.getAll(); + + const response = await balena.request.send({ + baseUrl: settings.apiUrl, + url: '/config/vars', + }); + + return response.body.reservedNamespaces; +} diff --git a/lib/actions/auth.coffee b/lib/actions/auth.coffee index a55ccae5..52dba9be 100644 --- a/lib/actions/auth.coffee +++ b/lib/actions/auth.coffee @@ -102,8 +102,9 @@ exports.login = return patterns.askLoginType().then (loginType) -> if loginType is 'register' - { runCommand } = require('../utils/helpers') - return runCommand('signup') + signupUrl = 'https://dashboard.balena-cloud.com/signup' + require('opn')(signupUrl, { wait: false }) + patterns.exitWithExpectedError("Please sign up at #{signupUrl}") options[loginType] = true return login(options) @@ -139,47 +140,6 @@ exports.logout = balena = require('balena-sdk').fromSharedOptions() balena.auth.logout().nodeify(done) -exports.signup = - signature: 'signup' - description: 'signup to balena' - help: ''' - Use this command to signup for a balena account. - - If signup is successful, you'll be logged in to your new user automatically. - - Examples: - - $ balena signup - Email: johndoe@acme.com - Password: *********** - - $ balena whoami - johndoe - ''' - action: (params, options, done) -> - balena = require('balena-sdk').fromSharedOptions() - form = require('resin-cli-form') - validation = require('../utils/validation') - - balena.settings.get('balenaUrl').then (balenaUrl) -> - console.log("\nRegistering to #{balenaUrl}") - - form.run [ - message: 'Email:' - name: 'email' - type: 'input' - validate: validation.validateEmail - , - message: 'Password:' - name: 'password' - type: 'password', - validate: validation.validatePassword - ] - - .then(balena.auth.register) - .then(balena.auth.loginWithToken) - .nodeify(done) - exports.whoami = signature: 'whoami' description: 'get current username and email address' diff --git a/lib/actions/environment-variables.ts b/lib/actions/environment-variables.ts index 03b12df7..7ae16cd1 100644 --- a/lib/actions/environment-variables.ts +++ b/lib/actions/environment-variables.ts @@ -13,26 +13,14 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ - import { ApplicationVariable, DeviceVariable } from 'balena-sdk'; +import * as Bluebird from 'bluebird'; import { CommandDefinition } from 'capitano'; import { stripIndent } from 'common-tags'; import { normalizeUuidProp } from '../utils/normalization'; import * as commandOptions from './command-options'; -const getReservedPrefixes = async (): Promise => { - const balena = (await import('balena-sdk')).fromSharedOptions(); - const settings = await balena.settings.getAll(); - - const response = await balena.request.send({ - baseUrl: settings.apiUrl, - url: '/config/vars', - }); - - return response.body.reservedNamespaces; -}; - export const list: CommandDefinition< {}, { @@ -73,14 +61,13 @@ export const list: CommandDefinition< permission: 'user', async action(_params, options, done) { normalizeUuidProp(options, 'device'); - const Bluebird = await import('bluebird'); const _ = await import('lodash'); const balena = (await import('balena-sdk')).fromSharedOptions(); const visuals = await import('resin-cli-visuals'); const { exitWithExpectedError } = await import('../utils/patterns'); - return Bluebird.try(function(): Promise< + return Bluebird.try(function(): Bluebird< DeviceVariable[] | ApplicationVariable[] > { if (options.application) { @@ -151,7 +138,7 @@ export const remove: CommandDefinition< return patterns .confirm( - options.yes, + options.yes || false, 'Are you sure you want to delete the environment variable?', ) .then(function() { @@ -171,87 +158,6 @@ export const remove: CommandDefinition< }, }; -export const add: CommandDefinition< - { - key: string; - value?: string; - }, - { - application?: string; - device?: string; - } -> = { - signature: 'env add [value]', - description: 'add an environment or config variable', - help: stripIndent` - Use this command to add an enviroment or config variable to an application - or device. - - If value is omitted, the tool will attempt to use the variable's value - as defined in your host machine. - - Use the \`--device\` option if you want to assign the environment variable - to a specific device. - - If the value is grabbed from the environment, a warning message will be printed. - Use \`--quiet\` to remove it. - - Service-specific variables are not currently supported. The following - examples set variables that apply to all services in an app or device. - - Examples: - - $ balena env add EDITOR vim --application MyApp - $ balena env add TERM --application MyApp - $ balena env add EDITOR vim --device 7cf02a6 - `, - options: [commandOptions.optionalApplication, commandOptions.optionalDevice], - permission: 'user', - async action(params, options, done) { - normalizeUuidProp(options, 'device'); - const Bluebird = await import('bluebird'); - const _ = await import('lodash'); - const balena = (await import('balena-sdk')).fromSharedOptions(); - - const { exitWithExpectedError } = await import('../utils/patterns'); - - return Bluebird.try(async function() { - if (params.value == null) { - params.value = process.env[params.key]; - - if (params.value == null) { - throw new Error(`Environment value not found for key: ${params.key}`); - } else { - console.info( - `Warning: using ${params.key}=${ - params.value - } from host environment`, - ); - } - } - - const reservedPrefixes = await getReservedPrefixes(); - const isConfigVar = _.some(reservedPrefixes, prefix => - _.startsWith(params.key, prefix), - ); - - if (options.application) { - return balena.models.application[ - isConfigVar ? 'configVar' : 'envVar' - ].set(options.application, params.key, params.value); - } else if (options.device) { - return balena.models.device[isConfigVar ? 'configVar' : 'envVar'].set( - options.device, - params.key, - params.value, - ); - } else { - exitWithExpectedError('You must specify an application or device'); - } - }).nodeify(done); - }, -}; - export const rename: CommandDefinition< { id: number; @@ -265,7 +171,7 @@ export const rename: CommandDefinition< description: 'rename an environment variable', help: stripIndent` Use this command to change the value of an application or device - enviroment variable. + environment variable. The --device option selects a device instead of an application. @@ -280,7 +186,6 @@ export const rename: CommandDefinition< permission: 'user', options: [commandOptions.booleanDevice], async action(params, options, done) { - const Bluebird = await import('bluebird'); const balena = (await import('balena-sdk')).fromSharedOptions(); return Bluebird.try(function() { diff --git a/lib/actions/help.coffee b/lib/actions/help.coffee index adb5e57d..503021aa 100644 --- a/lib/actions/help.coffee +++ b/lib/actions/help.coffee @@ -17,11 +17,14 @@ limitations under the License. _ = require('lodash') capitano = require('capitano') columnify = require('columnify') + messages = require('../utils/messages') +{ getManualSortCompareFunction } = require('../utils/helpers') { exitWithExpectedError } = require('../utils/patterns') +{ getOclifHelpLinePairs } = require('./help_ts') parse = (object) -> - return _.fromPairs _.map(object, (item) -> + return _.map object, (item) -> # Hacky way to determine if an object is # a function or a command @@ -33,18 +36,37 @@ parse = (object) -> return [ signature item.description - ]).sort() + ] indent = (text) -> text = _.map text.split('\n'), (line) -> return ' ' + line return text.join('\n') -print = (data) -> - console.log indent columnify data, +print = (usageDescriptionPairs) -> + console.log indent columnify _.fromPairs(usageDescriptionPairs), showHeaders: false minWidth: 35 +manuallySortedPrimaryCommands = [ + 'help', + 'login', + 'push', + 'logs', + 'ssh', + 'apps', + 'app', + 'devices', + 'device', + 'tunnel', + 'preload', + 'build', + 'deploy', + 'join', + 'leave', + 'local scan', +] + general = (params, options, done) -> console.log('Usage: balena [COMMAND] [OPTIONS]\n') console.log(messages.reachingOut) @@ -60,17 +82,21 @@ general = (params, options, done) -> return 'primary' return 'secondary' - print(parse(groupedCommands.primary)) + print parse(groupedCommands.primary).sort(getManualSortCompareFunction( + manuallySortedPrimaryCommands, + ([signature, description], manualItem) -> + signature == manualItem or signature.startsWith("#{manualItem} ") + )) if options.verbose console.log('\nAdditional commands:\n') - print(parse(groupedCommands.secondary)) + print parse(groupedCommands.secondary).concat(getOclifHelpLinePairs()).sort() else console.log('\nRun `balena help --verbose` to list additional commands') if not _.isEmpty(capitano.state.globalOptions) console.log('\nGlobal Options:\n') - print(parse(capitano.state.globalOptions)) + print parse(capitano.state.globalOptions).sort() return done() @@ -90,7 +116,7 @@ command = (params, options, done) -> if not _.isEmpty(command.options) console.log('\nOptions:\n') - print(parse(command.options)) + print parse(command.options).sort() return done() diff --git a/lib/actions/help_ts.ts b/lib/actions/help_ts.ts new file mode 100644 index 00000000..172e7550 --- /dev/null +++ b/lib/actions/help_ts.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command } from '@oclif/command'; +import * as _ from 'lodash'; + +import EnvAddCmd from '../actions-oclif/env/add'; + +export function getOclifHelpLinePairs(): [[string, string]] { + return [getCmdUsageDescriptionLinePair(EnvAddCmd)]; +} + +function getCmdUsageDescriptionLinePair(cmd: typeof Command): [string, string] { + const usage = (cmd.usage || '').toString().toLowerCase(); + let description = ''; + const matches = /\s*(.+?)\n.*/s.exec(cmd.description || ''); + if (matches && matches.length > 1) { + description = _.lowerFirst(_.trimEnd(matches[1], '.')); + } + return [usage, description]; +} diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index 7ccad6ca..b3b3ffcf 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -25,12 +25,12 @@ module.exports = keys: require('./keys') logs: require('./logs') local: require('./local') + scan: require('./scan') notes: require('./notes') help: require('./help') os: require('./os') settings: require('./settings') config: require('./config') - sync: require('./sync') ssh: require('./ssh') internal: require('./internal') build: require('./build') diff --git a/lib/actions/local/configure.coffee b/lib/actions/local/configure.coffee index 5b9c2aa8..129f14eb 100644 --- a/lib/actions/local/configure.coffee +++ b/lib/actions/local/configure.coffee @@ -205,6 +205,7 @@ module.exports = root: true action: (params, options, done) -> Promise = require('bluebird') + path = require('path') umount = require('umount') umountAsync = Promise.promisify(umount.umount) isMountedAsync = Promise.promisify(umount.isMounted) @@ -217,7 +218,12 @@ module.exports = return if not isMounted umountAsync(params.target) .then (configurationSchema) -> - denymount params.target, (cb) -> + dmOpts = {} + if process.pkg + # when running in a standalone pkg install, the 'denymount' + # executable is placed on the same folder as process.execPath + dmOpts.executablePath = path.join(path.dirname(process.execPath), 'denymount') + dmHandler = (cb) -> reconfix.readConfiguration(configurationSchema, params.target) .then(getConfiguration) .then (answers) -> @@ -225,6 +231,7 @@ module.exports = removeHostname(configurationSchema) reconfix.writeConfiguration(configurationSchema, answers, params.target) .asCallback(cb) + denymount params.target, dmHandler, dmOpts .then -> console.log('Done!') .asCallback(done) diff --git a/lib/actions/local/index.coffee b/lib/actions/local/index.coffee index 9557997e..e089ffaa 100644 --- a/lib/actions/local/index.coffee +++ b/lib/actions/local/index.coffee @@ -16,8 +16,3 @@ limitations under the License. exports.configure = require('./configure') exports.flash = require('./flash').flash -exports.logs = require('./logs') -exports.scan = require('./scan') -exports.ssh = require('./ssh') -exports.push = require('./push') -exports.stop = require('./stop') diff --git a/lib/actions/logs.ts b/lib/actions/logs.ts index f553a189..907a9728 100644 --- a/lib/actions/logs.ts +++ b/lib/actions/logs.ts @@ -38,9 +38,9 @@ export const logs: CommandDefinition< uuidOrDevice: string; }, { - tail: boolean; - service: string; - system: boolean; + tail?: boolean; + service?: [string] | string; + system?: boolean; } > = { signature: 'logs ', @@ -66,6 +66,7 @@ export const logs: CommandDefinition< $ balena logs 192.168.0.31 $ balena logs 192.168.0.31 --service my-service + $ balena logs 192.168.0.31 --service my-service-1 --service my-service-2 $ balena logs 23c73a1.local --system $ balena logs 23c73a1.local --system --service my-service`, @@ -78,8 +79,9 @@ export const logs: CommandDefinition< }, { signature: 'service', - description: - 'Only show logs for a single service. This can be used in combination with --system', + description: stripIndent` + Reject logs not originating from this service. + This can be used in combination with --system or other --service flags.`, parameter: 'service', alias: 's', }, @@ -91,21 +93,30 @@ export const logs: CommandDefinition< 'Only show system logs. This can be used in combination with --service.', }, ], - permission: 'user', primary: true, async action(params, options, done) { normalizeUuidProp(params); const balena = (await import('balena-sdk')).fromSharedOptions(); + const isArray = await import('lodash/isArray'); const { serviceIdToName } = await import('../utils/cloud'); const { displayDeviceLogs, displayLogObject } = await import( '../utils/device/logs' ); const { validateIPAddress } = await import('../utils/validation'); - const { exitWithExpectedError } = await import('../utils/patterns'); + const { exitIfNotLoggedIn, exitWithExpectedError } = await import( + '../utils/patterns' + ); const Logger = await import('../utils/logger'); const logger = new Logger(); + const servicesToDisplay = + options.service != null + ? isArray(options.service) + ? options.service + : [options.service] + : undefined; + const displayCloudLog = async (line: CloudLog) => { if (!line.isSystem) { let serviceName = await serviceIdToName(balena, line.serviceId); @@ -116,14 +127,14 @@ export const logs: CommandDefinition< { serviceName, ...line }, logger, options.system || false, - options.service, + servicesToDisplay, ); } else { displayLogObject( line, logger, options.system || false, - options.service, + servicesToDisplay, ); } }; @@ -150,9 +161,10 @@ export const logs: CommandDefinition< logStream, logger, options.system || false, - options.service, + servicesToDisplay, ); } else { + exitIfNotLoggedIn(); if (options.tail) { return balena.logs .subscribe(params.uuidOrDevice, { count: 100 }) diff --git a/lib/actions/preload.coffee b/lib/actions/preload.coffee index 910ee08e..f6c52cb6 100644 --- a/lib/actions/preload.coffee +++ b/lib/actions/preload.coffee @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. ### +_ = require('lodash') + dockerUtils = require('../utils/docker') allDeviceTypes = undefined @@ -131,6 +133,54 @@ offerToDisableAutomaticUpdates = (application, commit, pinDevice) -> body: should_track_latest_release: false +preloadOptions = dockerUtils.appendConnectionOptions [ + { + signature: 'app' + parameter: 'appId' + description: 'id of the application to preload' + alias: 'a' + } + { + signature: 'commit' + parameter: 'hash' + description: ''' + The commit hash for a specific application release to preload, use "current" to specify the current + release (ignored if no appId is given). The current release is usually also the latest, but can be + manually pinned using https://github.com/balena-io-projects/staged-releases . + ''' + alias: 'c' + } + { + signature: 'splash-image' + parameter: 'splashImage.png' + description: 'path to a png image to replace the splash screen' + alias: 's' + } + { + signature: 'dont-check-arch' + boolean: true + description: 'Disables check for matching architecture in image and application' + } + { + signature: 'pin-device-to-release' + boolean: true + description: 'Pin the preloaded device to the preloaded release on provision' + alias: 'p' + } + { + signature: 'add-certificate' + parameter: 'certificate.crt' + description: ''' + Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container. + The file name must end with '.crt' and must not be already contained in the preloader's + /etc/ssl/certs folder. + Can be repeated to add multiple certificates. + ''' + } +] +# Remove dockerPort `-p` alias as it conflicts with pin-device-to-release +delete _.find(preloadOptions, signature: 'dockerPort').alias + module.exports = signature: 'preload ' description: 'preload an app on a disk image (or Edison zip archive)' @@ -149,51 +199,7 @@ module.exports = ''' permission: 'user' primary: true - options: dockerUtils.appendConnectionOptions [ - { - signature: 'app' - parameter: 'appId' - description: 'id of the application to preload' - alias: 'a' - } - { - signature: 'commit' - parameter: 'hash' - description: ''' - The commit hash for a specific application release to preload, use "current" to specify the current - release (ignored if no appId is given). The current release is usually also the latest, but can be - manually pinned using https://github.com/balena-io-projects/staged-releases . - ''' - alias: 'c' - } - { - signature: 'splash-image' - parameter: 'splashImage.png' - description: 'path to a png image to replace the splash screen' - alias: 's' - } - { - signature: 'dont-check-arch' - boolean: true - description: 'Disables check for matching architecture in image and application' - } - { - signature: 'pin-device-to-release' - boolean: true - description: 'Pin the preloaded device (not application) to the preloaded release on provision' - alias: 'p' - } - { - signature: 'add-certificate' - parameter: 'certificate.crt' - description: ''' - Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container. - The file name must end with '.crt' and must not be already contained in the preloader's - /etc/ssl/certs folder. - Can be repeated to add multiple certificates. - ''' - } - ] + options: preloadOptions action: (params, options, done) -> _ = require('lodash') Promise = require('bluebird') diff --git a/lib/actions/push.ts b/lib/actions/push.ts index 593148be..3157d82b 100644 --- a/lib/actions/push.ts +++ b/lib/actions/push.ts @@ -105,16 +105,16 @@ export const push: CommandDefinition< applicationOrDevice_raw: string; }, { - source: string; - emulated: boolean; - dockerfile: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile) - nocache: boolean; - 'registry-secrets': string; - live: boolean; - detached: boolean; - service: string; - system: boolean; - env: string | string[]; + source?: string; + emulated?: boolean; + dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile) + nocache?: boolean; + 'registry-secrets'?: string; + nolive?: boolean; + detached?: boolean; + service?: string | string[]; + system?: boolean; + env?: string | string[]; } > = { signature: 'push ', @@ -139,9 +139,12 @@ export const push: CommandDefinition< showing only the system logs can be achieved with --system. Note that these flags can be used together. - It is also possible to run a push to a local mode device in live mode. - This will watch for changes in the source directory and perform an - in-place build in the running containers [BETA]. + When pushing to a local device a live session will be started. + The project source folder is watched for filesystem events, and changes + to files and folders are automatically synchronized to the running + containers. The synchronisation is only in one direction, from this machine to + the device, and changes made on the device itself may be overwritten. + This feature requires a device running supervisor version v9.7.0 or greater. ${registrySecretsHelp.split('\n').join('\n\t\t')} @@ -155,6 +158,7 @@ export const push: CommandDefinition< $ balena push 10.0.0.1 --source $ balena push 10.0.0.1 --service my-service $ balena push 10.0.0.1 --env MY_ENV_VAR=value --env my-service:SERVICE_VAR=value + $ balena push 10.0.0.1 --nolive $ balena push 23c73a1.local --system $ balena push 23c73a1.local --system --service my-service @@ -193,18 +197,12 @@ export const push: CommandDefinition< Path to a local YAML or JSON file containing Docker registry passwords used to pull base images`, }, { - signature: 'live', - alias: 'l', + signature: 'nolive', boolean: true, description: stripIndent` - Note this feature is in beta. - - Start a live session with the containers pushed to a local mode device. - The project source folder is watched for filesystem events, and changes - to files and folders are automatically synchronized to the running - containers. The synchronisation is only in one direction, from this machine to - the device, and changes made on the device itself may be overwritten. - This feature requires a device running supervisor version v9.7.0 or greater.`, + Don't run a live session on this push. The filesystem will not be monitored, and changes + will not be synchronised to any running containers. Note that both this flag and --detached + and required to cause the process to end once the initial build has completed.`, }, { signature: 'detached', @@ -215,7 +213,8 @@ export const push: CommandDefinition< { signature: 'service', description: stripIndent` - Only show logs from a single service. This can be used in combination with --system. + Reject logs not originating from this service. + This can be used in combination with --system and other --service flags. Only valid when pushing to a local mode device.`, parameter: 'service', }, @@ -243,6 +242,7 @@ export const push: CommandDefinition< async action(params, options, done) { const sdk = (await import('balena-sdk')).fromSharedOptions(); const Bluebird = await import('bluebird'); + const isArray = await import('lodash/isArray'); const remote = await import('../utils/remote-build'); const deviceDeploy = await import('../utils/device/deploy'); const { exitIfNotLoggedIn, exitWithExpectedError } = await import( @@ -277,9 +277,9 @@ export const push: CommandDefinition< switch (buildTarget) { case BuildTarget.Cloud: // Ensure that the live argument has not been passed to a cloud build - if (options.live) { + if (options.nolive != null) { exitWithExpectedError( - 'The --live flag is only valid when pushing to a local mode device', + 'The --nolive flag is only valid when pushing to a local mode device', ); } if (options.detached) { @@ -312,8 +312,8 @@ export const push: CommandDefinition< async (token, baseUrl, owner) => { const opts = { dockerfilePath, - emulated: options.emulated, - nocache: options.nocache, + emulated: options.emulated || false, + nocache: options.nocache || false, registrySecrets, }; const args = { @@ -332,6 +332,12 @@ export const push: CommandDefinition< break; case BuildTarget.Device: const device = appOrDevice; + const servicesToDisplay = + options.service != null + ? isArray(options.service) + ? options.service + : [options.service] + : undefined; // TODO: Support passing a different port await Bluebird.resolve( deviceDeploy.deployToDevice({ @@ -340,9 +346,9 @@ export const push: CommandDefinition< dockerfilePath, registrySecrets, nocache: options.nocache || false, - live: options.live || false, + nolive: options.nolive || false, detached: options.detached || false, - service: options.service, + services: servicesToDisplay, system: options.system || false, env: typeof options.env === 'string' diff --git a/lib/actions/local/scan.coffee b/lib/actions/scan.coffee similarity index 90% rename from lib/actions/local/scan.coffee rename to lib/actions/scan.coffee index 999df1b3..9254335a 100644 --- a/lib/actions/local/scan.coffee +++ b/lib/actions/scan.coffee @@ -33,15 +33,15 @@ dockerVersionProperties = [ ] module.exports = - signature: 'local scan' + signature: 'scan' description: 'Scan for balenaOS devices in your local network' help: ''' Examples: - $ balena local scan - $ balena local scan --timeout 120 - $ balena local scan --verbose + $ balena scan + $ balena scan --timeout 120 + $ balena scan --verbose ''' options: [ signature: 'verbose' @@ -62,9 +62,9 @@ module.exports = prettyjson = require('prettyjson') { discover } = require('balena-sync') { SpinnerPromise } = require('resin-cli-visuals') - { dockerPort, dockerTimeout } = require('./common') - dockerUtils = require('../../utils/docker') - { exitWithExpectedError } = require('../../utils/patterns') + { dockerPort, dockerTimeout } = require('./local/common') + dockerUtils = require('../utils/docker') + { exitWithExpectedError } = require('../utils/patterns') if options.timeout? options.timeout *= 1000 diff --git a/lib/actions/ssh.coffee b/lib/actions/ssh.coffee deleted file mode 100644 index 5bb55f6a..00000000 --- a/lib/actions/ssh.coffee +++ /dev/null @@ -1,149 +0,0 @@ -### -Copyright 2016-2017 Balena - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -### - -commandOptions = require('./command-options') -{ normalizeUuidProp } = require('../utils/normalization') - -module.exports = - signature: 'ssh [uuid]' - description: 'get a shell into the running app container of a device' - help: ''' - Warning: 'balena ssh' requires an openssh-compatible client to be correctly - installed in your shell environment. For more information (including Windows - support) please check the README here: https://github.com/balena-io/balena-cli - - Use this command to get a shell into the running application container of - your device. - - Examples: - - $ balena ssh MyApp - $ balena ssh 7cf02a6 - $ balena ssh 7cf02a6 --port 8080 - $ balena ssh 7cf02a6 -v - $ balena ssh 7cf02a6 -s - $ balena ssh 7cf02a6 --noninteractive - ''' - permission: 'user' - primary: true - options: [ - signature: 'port' - parameter: 'port' - description: 'ssh gateway port' - alias: 'p' - , - signature: 'verbose' - boolean: true - description: 'increase verbosity' - alias: 'v' - commandOptions.hostOSAccess, - signature: 'noproxy' - boolean: true - description: "don't use the proxy configuration for this connection. - Only makes sense if you've configured proxy globally." - , - signature: 'noninteractive' - boolean: true - description: 'run command non-interactively, do not automatically suggest devices to connect to if UUID not found' - ] - action: (params, options, done) -> - normalizeUuidProp(params) - child_process = require('child_process') - Promise = require('bluebird') - balena = require('balena-sdk').fromSharedOptions() - _ = require('lodash') - bash = require('bash') - hasbin = require('hasbin') - { getSubShellCommand } = require('../utils/helpers') - patterns = require('../utils/patterns') - - options.port ?= 22 - - verbose = if options.verbose then '-vvv' else '' - - proxyConfig = global.PROXY_CONFIG - useProxy = !!proxyConfig and not options.noproxy - - getSshProxyCommand = (hasTunnelBin) -> - return '' if not useProxy - - if not hasTunnelBin - console.warn(''' - Proxy is enabled but the `proxytunnel` binary cannot be found. - Please install it if you want to route the `balena ssh` requests through the proxy. - Alternatively you can pass `--noproxy` param to the `balena ssh` command to ignore the proxy config - for the `ssh` requests. - - Attemmpting the unproxied request for now. - ''') - return '' - - tunnelOptions = - proxy: "#{proxyConfig.host}:#{proxyConfig.port}" - dest: '%h:%p' - { proxyAuth } = proxyConfig - if proxyAuth - i = proxyAuth.indexOf(':') - _.assign tunnelOptions, - user: proxyAuth.substring(0, i) - pass: proxyAuth.substring(i + 1) - proxyCommand = "proxytunnel #{bash.args(tunnelOptions, '--', '=')}" - return "-o #{bash.args({ ProxyCommand: proxyCommand }, '', '=')}" - - Promise.try -> - return false if not params.uuid - return balena.models.device.has(params.uuid) - .then (uuidExists) -> - return params.uuid if uuidExists - if options.noninteractive - console.error("Could not find device: #{params.uuid}") - process.exit(1) - return patterns.inferOrSelectDevice() - .then (uuid) -> - console.info("Connecting to: #{uuid}") - balena.models.device.get(uuid) - .then (device) -> - patterns.exitWithExpectedError('Device is not online') if not device.is_online - - Promise.props - username: balena.auth.whoami() - uuid: device.uuid - # get full uuid - containerId: if options.host then '' else balena.models.device.getApplicationInfo(device.uuid).get('containerId') - proxyUrl: balena.settings.get('proxyUrl') - - hasTunnelBin: if useProxy then hasbin('proxytunnel') else null - .then ({ username, uuid, containerId, proxyUrl, hasTunnelBin }) -> - throw new Error('Did not find running application container') if not containerId? - Promise.try -> - sshProxyCommand = getSshProxyCommand(hasTunnelBin) - - if options.host - accessCommand = "host #{uuid}" - else - accessCommand = "enter #{uuid} #{containerId}" - - command = "ssh #{verbose} -t \ - -o LogLevel=ERROR \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - #{sshProxyCommand} \ - -p #{options.port} #{username}@ssh.#{proxyUrl} #{accessCommand}" - - subShellCommand = getSubShellCommand(command) - child_process.spawn subShellCommand.program, subShellCommand.args, - stdio: 'inherit' - .nodeify(done) diff --git a/lib/actions/ssh.ts b/lib/actions/ssh.ts new file mode 100644 index 00000000..7540a5f9 --- /dev/null +++ b/lib/actions/ssh.ts @@ -0,0 +1,504 @@ +/* +Copyright 2016-2019 Balena + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import * as BalenaSdk from 'balena-sdk'; +import { CommandDefinition } from 'capitano'; +import { stripIndent } from 'common-tags'; + +import { BalenaApplicationNotFound, BalenaDeviceNotFound } from 'balena-errors'; +import { + validateApplicationName, + validateDotLocalUrl, + validateIPAddress, + validateShortUuid, + validateUuid, +} from '../utils/validation'; + +enum SSHTarget { + APPLICATION, + DEVICE, + LOCAL_DEVICE, +} + +async function getSSHTarget( + sdk: BalenaSdk.BalenaSDK, + applicationOrDevice: string, +): Promise<{ + target: SSHTarget; + deviceChecked?: boolean; + applicationChecked?: boolean; + device?: BalenaSdk.Device; +} | null> { + if ( + validateDotLocalUrl(applicationOrDevice) || + validateIPAddress(applicationOrDevice) + ) { + return { target: SSHTarget.LOCAL_DEVICE }; + } + + const appTest = validateApplicationName(applicationOrDevice); + const uuidTest = validateUuid(applicationOrDevice); + if (appTest || uuidTest) { + // Do some further processing to work out which it is + if (appTest && !uuidTest) { + return { + target: SSHTarget.APPLICATION, + applicationChecked: false, + }; + } + if (uuidTest && !appTest) { + return { + target: SSHTarget.DEVICE, + deviceChecked: false, + }; + } + + // This is the harder part, we have a string that + // fulfills both the uuid and application name + // requirements. We should go away and test for both a + // device with that uuid, and an application with that + // name, and choose the appropriate one + try { + await sdk.models.application.get(applicationOrDevice); + return { target: SSHTarget.APPLICATION, applicationChecked: true }; + } catch (e) { + if (e instanceof BalenaApplicationNotFound) { + // Here we want to check for a device with that UUID + try { + const device = await sdk.models.device.get(applicationOrDevice, { + $select: ['id', 'uuid', 'supervisor_version', 'is_online'], + }); + return { target: SSHTarget.DEVICE, deviceChecked: true, device }; + } catch (err) { + if (err instanceof BalenaDeviceNotFound) { + throw new Error( + `Device or application not found: ${applicationOrDevice}`, + ); + } + throw err; + } + } + throw e; + } + } + return null; +} + +async function getContainerId( + sdk: BalenaSdk.BalenaSDK, + uuid: string, + serviceName: string, + sshOpts: { + port?: number; + proxyCommand?: string; + proxyUrl: string; + username: string; + }, + version?: string, + id?: number, +): Promise { + const semver = await import('resin-semver'); + + if (version == null || id == null) { + const device = await sdk.models.device.get(uuid, { + $select: ['id', 'supervisor_version'], + }); + version = device.supervisor_version; + id = device.id; + } + + let containerId: string | undefined; + if (semver.gte(version, '8.6.0')) { + const apiUrl = await sdk.settings.get('apiUrl'); + // TODO: Move this into the SDKs device model + const request = await sdk.request.send({ + method: 'POST', + url: '/supervisor/v2/containerId', + baseUrl: apiUrl, + body: { + method: 'GET', + deviceId: id, + }, + }); + if (request.status !== 200) { + throw new Error( + `There was an error connecting to device ${uuid}, HTTP response code: ${ + request.status + }.`, + ); + } + const body = request.body; + if (body.status !== 'success') { + throw new Error( + `There was an error communicating with device ${uuid}.\n\tError: ${ + body.message + }`, + ); + } + containerId = body.services[serviceName]; + } else { + console.log(stripIndent` + Using legacy method to detect container ID. This will be slow. + To speed up this process, please update your device to an OS + which has a supervisor version of at least v8.6.0. + `); + // We need to execute a balena ps command on the device, + // and parse the output, looking for a specific + // container + const { child_process } = await import('mz'); + const escapeRegex = await import('lodash/escapeRegExp'); + const { getSubShellCommand } = await import('../utils/helpers'); + const { deviceContainerEngineBinary } = await import('../utils/device/ssh'); + + const command = generateVpnSshCommand({ + uuid, + verbose: false, + port: sshOpts.port, + command: `host ${uuid} '"${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"'`, + proxyCommand: sshOpts.proxyCommand, + proxyUrl: sshOpts.proxyUrl, + username: sshOpts.username, + }); + + const subShellCommand = getSubShellCommand(command); + const subprocess = child_process.spawn( + subShellCommand.program, + subShellCommand.args, + { + stdio: [null, 'pipe', null], + }, + ); + const containers = await new Promise((resolve, reject) => { + let output = ''; + subprocess.stdout.on('data', chunk => (output += chunk.toString())); + subprocess.on('close', (code: number) => { + if (code !== 0) { + reject( + new Error( + `Non-zero error code when looking for service container: ${code}`, + ), + ); + } else { + resolve(output); + } + }); + }); + + const lines = containers.split('\n'); + const regex = new RegExp(`\\/?${escapeRegex(serviceName)}_\\d+_\\d+`); + for (const container of lines) { + const [cId, name] = container.split(' '); + if (regex.test(name)) { + containerId = cId; + break; + } + } + } + + if (containerId == null) { + throw new Error( + `Could not find a service ${serviceName} on device ${uuid}.`, + ); + } + return containerId; +} + +function generateVpnSshCommand(opts: { + uuid: string; + command: string; + verbose: boolean; + port?: number; + username: string; + proxyUrl: string; + proxyCommand?: string; +}) { + return ( + `ssh ${ + opts.verbose ? '-vvv' : '' + } -t -o LogLevel=ERROR -o StrictHostKeyChecking=no ` + + `-o UserKnownHostsFile=/dev/null ` + + `${opts.proxyCommand != null ? opts.proxyCommand : ''} ` + + `${opts.port != null ? `-p ${opts.port}` : ''} ` + + `${opts.username}@ssh.${opts.proxyUrl} ${opts.command}` + ); +} + +export const ssh: CommandDefinition< + { + applicationOrDevice: string; + serviceName?: string; + }, + { + port: string; + service: string; + verbose: true | undefined; + noProxy: boolean; + } +> = { + signature: 'ssh [serviceName]', + description: 'SSH into the host or application container of a device', + primary: true, + help: stripIndent` + This command can be used to start a shell on a local or remote device. + + If a service name is not provided, a shell will be opened on the host OS. + + If an application name is provided, all online devices in the application + will be presented, and the chosen device will then have a shell opened on + in it's service container or host OS. + + For local devices, the ip address and .local domain name are supported. + + Examples: + balena ssh MyApp + + balena ssh f49cefd + balena ssh f49cefd my-service + balena ssh f49cefd --port + + balena ssh 192.168.0.1 --verbose + balena ssh f49cefd.local my-service + + Warning: 'balena ssh' requires an openssh-compatible client to be correctly + installed in your shell environment. For more information (including Windows + support) please check: + https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies`, + options: [ + { + signature: 'port', + parameter: 'port', + description: 'SSH gateway port', + alias: 'p', + }, + { + signature: 'verbose', + boolean: true, + description: 'Increase verbosity', + alias: 'v', + }, + { + signature: 'noproxy', + boolean: true, + description: stripIndent` + Don't use the proxy configuration for this connection. This flag + only make sense if you've configured a proxy globally.`, + }, + ], + action: async (params, options) => { + const map = await import('lodash/map'); + const bash = await import('bash'); + // TODO: Make this typed + const hasbin = require('hasbin'); + const { getSubShellCommand } = await import('../utils/helpers'); + const { child_process } = await import('mz'); + const { exitIfNotLoggedIn } = await import('../utils/patterns'); + + const { exitWithExpectedError, selectFromList } = await import( + '../utils/patterns' + ); + const sdk = BalenaSdk.fromSharedOptions(); + + const verbose = options.verbose === true; + // ugh TODO: Fix this + const proxyConfig = (global as any).PROXY_CONFIG; + const useProxy = !!proxyConfig && !options.noProxy; + const port = options.port != null ? parseInt(options.port, 10) : undefined; + + const getSshProxyCommand = (hasTunnelBin: boolean) => { + if (!useProxy) { + return ''; + } + + if (!hasTunnelBin) { + console.warn(stripIndent` + Proxy is enabled but the \`proxytunnel\` binary cannot be found. + Please install it if you want to route the \`balena ssh\` requests through the proxy. + Alternatively you can pass \`--noproxy\` param to the \`balena ssh\` command to ignore the proxy config + for the \`ssh\` requests. + + Attempting the unproxied request for now.`); + return ''; + } + + let tunnelOptions: Dictionary = { + proxy: `${proxyConfig.host}:${proxyConfig.port}`, + dest: '%h:%p', + }; + const { proxyAuth } = proxyConfig; + if (proxyAuth) { + const i = proxyAuth.indexOf(':'); + tunnelOptions = { + user: proxyAuth.substring(0, i), + pass: proxyAuth.substring(i + 1), + ...tunnelOptions, + }; + } + + const proxyCommand = `proxytunnel ${bash.args(tunnelOptions, '--', '=')}`; + return `-o ${bash.args({ ProxyCommand: proxyCommand }, '', '=')}`; + }; + + // Detect what type of SSH we're doing + const maybeParamChecks = await getSSHTarget( + sdk, + params.applicationOrDevice, + ); + if (maybeParamChecks == null) { + exitWithExpectedError( + new Error(stripIndent` + Could not parse SSH target. + You can provide an application name, IP address or .local address`), + ); + } + const paramChecks = maybeParamChecks!; + + switch (paramChecks.target) { + case SSHTarget.APPLICATION: + exitIfNotLoggedIn(); + // Here what we want to do is fetch all device which + // are part of this application, and online + try { + const devices = await sdk.models.device.getAllByApplication( + params.applicationOrDevice, + { $filter: { is_online: true }, $select: ['device_name', 'uuid'] }, + ); + const choice = await selectFromList( + 'Please choose an online device to SSH into:', + map(devices, ({ device_name, uuid: uuidToChoose }) => ({ + name: `${device_name} [${uuidToChoose.substr(0, 7)}]`, + uuid: uuidToChoose, + })), + ); + // A little bit hacky, but it means we can fall + // through to the next handling mechanism + params.applicationOrDevice = choice.uuid; + } catch (e) { + if (e instanceof BalenaApplicationNotFound) { + exitWithExpectedError( + `Could not find an application named ${ + params.applicationOrDevice + }`, + ); + } + throw e; + } + case SSHTarget.DEVICE: + exitIfNotLoggedIn(); + // We want to do two things here; firstly, check + // that the device exists and is accessible, and + // also convert a short uuid to a long one if + // necessary + let uuid = params.applicationOrDevice; + let version: string | undefined; + let id: number | undefined; + let isOnline: boolean | undefined; + // We also want to avoid checking for a device if we + // know it exists + if (!paramChecks.deviceChecked || validateShortUuid(uuid)) { + try { + const device = await sdk.models.device.get(uuid, { + $select: ['id', 'uuid', 'supervisor_version', 'is_online'], + }); + uuid = device.uuid; + version = device.supervisor_version; + id = device.id; + isOnline = device.is_online; + } catch (e) { + if (e instanceof BalenaDeviceNotFound) { + exitWithExpectedError(`Could not find device: ${uuid}`); + } + } + } else { + version = paramChecks.device!.supervisor_version; + uuid = paramChecks.device!.uuid; + id = paramChecks.device!.id; + isOnline = paramChecks.device!.is_online; + } + + if (!isOnline) { + throw new Error(`Device ${uuid} is not online.`); + } + + const [hasTunnelBin, username, proxyUrl] = await Promise.all([ + useProxy ? await hasbin('proxytunnel') : undefined, + sdk.auth.whoami(), + sdk.settings.get('proxyUrl'), + ]); + const proxyCommand = getSshProxyCommand(hasTunnelBin); + + if (username == null) { + exitWithExpectedError( + `Opening an SSH connection to a remote device requires you to be logged in.`, + ); + } + + // At this point, we have a long uuid with a device + // that we know exists and is accessible + let containerId: string | undefined; + if (params.serviceName != null) { + containerId = await getContainerId( + sdk, + uuid, + params.serviceName, + { + port, + proxyCommand, + proxyUrl, + username: username!, + }, + version, + id, + ); + } + + let accessCommand: string; + if (containerId != null) { + accessCommand = `enter ${uuid} ${containerId}`; + } else { + accessCommand = `host ${uuid}`; + } + + const command = generateVpnSshCommand({ + uuid, + command: accessCommand, + verbose, + port, + proxyCommand, + proxyUrl, + username: username!, + }); + + const subShellCommand = getSubShellCommand(command); + await child_process.spawn( + subShellCommand.program, + subShellCommand.args, + { + stdio: 'inherit', + }, + ); + + break; + case SSHTarget.LOCAL_DEVICE: + const { performLocalDeviceSSH } = await import('../utils/device/ssh'); + await performLocalDeviceSSH({ + address: params.applicationOrDevice, + port, + verbose, + service: params.serviceName, + }); + break; + } + }, +}; diff --git a/lib/actions/sync.ts b/lib/actions/sync.ts deleted file mode 100644 index d646480c..00000000 --- a/lib/actions/sync.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* -Copyright 2016-2019 Balena - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import * as BalenaSync from 'balena-sync'; -import { CommandDefinition } from 'capitano'; -import { stripIndent } from 'common-tags'; - -export = deprecateSyncCmd(BalenaSync.capitano('balena-cli')); - -const deprecationMsg = stripIndent`\ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Deprecation notice: please note that \`balena sync\` is deprecated and will - be removed in a future release of the CLI. We are working on an exciting - replacement that will be released soon! - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -function deprecateSyncCmd(syncCmd: CommandDefinition): CommandDefinition { - syncCmd.primary = false; - syncCmd.description = syncCmd.description.replace( - '(beta)', - '[deprecated: see "help sync"]', - ); - syncCmd.help = deprecationMsg + '\n\n' + syncCmd.help; - const originalAction = syncCmd.action; - syncCmd.action = (params, options, done): void => { - console.log(deprecationMsg); - originalAction(params, options, done); - }; - return syncCmd; -} diff --git a/lib/actions/tunnel.ts b/lib/actions/tunnel.ts index 7080cb52..f58b64a1 100644 --- a/lib/actions/tunnel.ts +++ b/lib/actions/tunnel.ts @@ -180,8 +180,8 @@ export const tunnel: CommandDefinition = { return handler(client) .then(() => { logConnection( - client.remoteAddress, - client.remotePort, + client.remoteAddress || '', + client.remotePort || 0, client.localAddress, client.localPort, device.vpn_address || '', @@ -190,8 +190,8 @@ export const tunnel: CommandDefinition = { }) .catch(err => logConnection( - client.remoteAddress, - client.remotePort, + client.remoteAddress || '', + client.remotePort || 0, client.localAddress, client.localPort, device.vpn_address || '', diff --git a/lib/app.coffee b/lib/app-capitano.coffee similarity index 63% rename from lib/app.coffee rename to lib/app-capitano.coffee index 99083be8..e582a0a4 100644 --- a/lib/app.coffee +++ b/lib/app-capitano.coffee @@ -1,5 +1,5 @@ ### -Copyright 2016-2017 Balena +Copyright 2016-2019 Balena Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,77 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. ### -Raven = require('raven') -Raven.disableConsoleAlerts() -Raven.config require('./config').sentryDsn, - captureUnhandledRejections: true, - autoBreadcrumbs: true, - release: require('../package.json').version -.install (logged, error) -> - console.error(error) - process.exit(1) -Raven.setContext - extra: - args: process.argv - node_version: process.version - -validNodeVersions = require('../package.json').engines.node -if not require('semver').satisfies(process.version, validNodeVersions) - console.warn """ - Warning: this version of Node does not match the requirements of this package. - This package expects #{validNodeVersions}, but you're using #{process.version}. - This may cause unexpected behaviour. - - To upgrade your Node, visit https://nodejs.org/en/download/ - - """ - - -# Doing this before requiring any other modules, -# including the 'balena-sdk', to prevent any module from reading the http proxy config -# before us -globalTunnel = require('global-tunnel-ng') -settings = require('balena-settings-client') -try - proxy = settings.get('proxy') or null -catch - proxy = null -# Init the tunnel even if the proxy is not configured -# because it can also get the proxy from the http(s)_proxy env var -# If that is not set as well the initialize will do nothing -globalTunnel.initialize(proxy) - -# TODO: make this a feature of capitano https://github.com/balena-io/capitano/issues/48 -global.PROXY_CONFIG = globalTunnel.proxyConfig - Promise = require('bluebird') capitano = require('capitano') -capitanoExecuteAsync = Promise.promisify(capitano.execute) - -# We don't yet use balena-sdk directly everywhere, but we set up shared -# options correctly so we can do safely in submodules -BalenaSdk = require('balena-sdk') -BalenaSdk.setSharedOptions( - apiUrl: settings.get('apiUrl') - imageMakerUrl: settings.get('imageMakerUrl') - dataDirectory: settings.get('dataDirectory') - retries: 2 -) - actions = require('./actions') -errors = require('./errors') events = require('./events') -update = require('./utils/update') -{ exitIfNotLoggedIn } = require('./utils/patterns') - -# Assign bluebird as the global promise library -# stream-to-promise will produce native promises if not -# for this module, which could wreak havoc in this -# bluebird-only codebase. -require('any-promise/register/bluebird') capitano.permission 'user', (done) -> - exitIfNotLoggedIn() + require('./utils/patterns').exitIfNotLoggedIn() .then(done, done) capitano.command @@ -116,7 +52,6 @@ capitano.command(actions.app.info) # ---------- Auth Module ---------- capitano.command(actions.auth.login) capitano.command(actions.auth.logout) -capitano.command(actions.auth.signup) capitano.command(actions.auth.whoami) # ---------- Device Module ---------- @@ -147,7 +82,6 @@ capitano.command(actions.keys.remove) # ---------- Env Module ---------- capitano.command(actions.env.list) -capitano.command(actions.env.add) capitano.command(actions.env.rename) capitano.command(actions.env.remove) @@ -176,9 +110,6 @@ capitano.command(actions.settings.list) # ---------- Logs Module ---------- capitano.command(actions.logs.logs) -# ---------- Sync Module ---------- -capitano.command(actions.sync) - # ---------- Tunnel Module ---------- capitano.command(actions.tunnel.tunnel) @@ -186,16 +117,12 @@ capitano.command(actions.tunnel.tunnel) capitano.command(actions.preload) # ---------- SSH Module ---------- -capitano.command(actions.ssh) +capitano.command(actions.ssh.ssh) # ---------- Local balenaOS Module ---------- capitano.command(actions.local.configure) capitano.command(actions.local.flash) -capitano.command(actions.local.logs) -capitano.command(actions.local.push) -capitano.command(actions.local.ssh) -capitano.command(actions.local.scan) -capitano.command(actions.local.stop) +capitano.command(actions.scan) # ---------- Public utils ---------- capitano.command(actions.util.availableDrives) @@ -216,14 +143,13 @@ capitano.command(actions.push.push) capitano.command(actions.join.join) capitano.command(actions.leave.leave) -update.notify() - cli = capitano.parse(process.argv) runCommand = -> + capitanoExecuteAsync = Promise.promisify(capitano.execute) if cli.global?.help capitanoExecuteAsync(command: "help #{cli.command ? ''}") else capitanoExecuteAsync(cli) Promise.all([events.trackCommand(cli), runCommand()]) -.catch(errors.handle) +.catch(require('./errors').handleError) diff --git a/lib/app-common.ts b/lib/app-common.ts new file mode 100644 index 00000000..a3589c4d --- /dev/null +++ b/lib/app-common.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Sentry.io setup + * @see https://docs.sentry.io/clients/node/ + */ +function setupRaven() { + const Raven = require('raven'); + Raven.disableConsoleAlerts(); + Raven.config(require('./config').sentryDsn, { + captureUnhandledRejections: true, + autoBreadcrumbs: true, + release: require('../package.json').version, + }).install(function(_logged: any, error: Error) { + console.error(error); + return process.exit(1); + }); + + Raven.setContext({ + extra: { + args: process.argv, + node_version: process.version, + }, + }); +} + +function checkNodeVersion() { + const validNodeVersions = require('../package.json').engines.node; + if (!require('semver').satisfies(process.version, validNodeVersions)) { + const { stripIndent } = require('common-tags'); + console.warn(stripIndent` + ------------------------------------------------------------------------------ + Warning: Node version "${ + process.version + }" does not match required versions "${validNodeVersions}". + This may cause unexpected behaviour. To upgrade Node, visit: + https://nodejs.org/en/download/ + ------------------------------------------------------------------------------ + `); + } +} + +function setupGlobalHttpProxy() { + // Doing this before requiring any other modules, + // including the 'balena-sdk', to prevent any module from reading the http proxy config + // before us + const globalTunnel = require('global-tunnel-ng'); + const settings = require('balena-settings-client'); + let proxy; + try { + proxy = settings.get('proxy') || null; + } catch (error1) { + proxy = null; + } + + // Init the tunnel even if the proxy is not configured + // because it can also get the proxy from the http(s)_proxy env var + // If that is not set as well the initialize will do nothing + globalTunnel.initialize(proxy); + + // TODO: make this a feature of capitano https://github.com/balena-io/capitano/issues/48 + (global as any).PROXY_CONFIG = globalTunnel.proxyConfig; +} + +function setupBalenaSdkSharedOptions() { + // We don't yet use balena-sdk directly everywhere, but we set up shared + // options correctly so we can do safely in submodules + const BalenaSdk = require('balena-sdk'); + const settings = require('balena-settings-client'); + BalenaSdk.setSharedOptions({ + apiUrl: settings.get('apiUrl'), + imageMakerUrl: settings.get('imageMakerUrl'), + dataDirectory: settings.get('dataDirectory'), + retries: 2, + }); +} + +export function globalInit() { + setupRaven(); + checkNodeVersion(); + setupGlobalHttpProxy(); + setupBalenaSdkSharedOptions(); + + // Assign bluebird as the global promise library. + // stream-to-promise will produce native promises if not for this module, + // which is likely to lead to errors as much of the CLI coffeescript code + // expects bluebird promises. + require('any-promise/register/bluebird'); + + // check for CLI updates once a day + require('./utils/update').notify(); +} diff --git a/lib/app-oclif.ts b/lib/app-oclif.ts new file mode 100644 index 00000000..5ade084b --- /dev/null +++ b/lib/app-oclif.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ExitError } from '@oclif/errors'; + +import { handleError } from './errors'; + +/** + * oclif CLI entrypoint + */ +export function run(argv: string[]) { + process.argv = argv; + require('@oclif/command') + .run() + .then(require('@oclif/command/flush')) + .catch((error: Error) => { + // oclif sometimes exits with ExitError code 0 (not an error) + if (error instanceof ExitError && error.oclif.exit === 0) { + return; + } + handleError(error); + }); +} diff --git a/lib/app.ts b/lib/app.ts new file mode 100644 index 00000000..6353aca8 --- /dev/null +++ b/lib/app.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { stripIndent } from 'common-tags'; + +import { exitWithExpectedError } from './utils/patterns'; + +/** + * Simple command-line pre-parsing to choose between oclif or Capitano. + * @param argv process.argv + */ +function routeCliFramework(argv: string[]): void { + if (process.env.DEBUG) { + console.log( + `Debug: original argv0="${process.argv0}" argv=[${argv}] length=${ + argv.length + }`, + ); + } + const cmdSlice = argv.slice(2); + let isOclif = false; + + // Look for commands that have been deleted, to print a notice + checkDeletedCommand(cmdSlice); + + if (cmdSlice.length > 1) { + // convert e.g. 'balena help env add' to 'balena env add --help' + if (cmdSlice[0] === 'help') { + cmdSlice.shift(); + cmdSlice.push('--help'); + } + + // Look for commands that have been transitioned to oclif + isOclif = isOclifCommand(cmdSlice); + if (isOclif) { + // convert space-separated commands to oclif's topic:command syntax + argv = [ + argv[0], + argv[1], + cmdSlice[0] + ':' + cmdSlice[1], + ...cmdSlice.slice(2), + ]; + } + } + if (isOclif) { + if (process.env.DEBUG) { + console.log(`Debug: oclif new argv=[${argv}] length=${argv.length}`); + } + require('./app-oclif').run(argv); + } else { + require('./app-capitano'); + } +} + +/** + * + * @param argvSlice process.argv.slice(2) + */ +function checkDeletedCommand(argvSlice: string[]): void { + if (argvSlice[0] === 'help') { + argvSlice = argvSlice.slice(1); + } + function replaced( + oldCmd: string, + alternative: string, + version: string, + verb = 'replaced', + ) { + exitWithExpectedError(stripIndent` + Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}. + Please use "balena ${alternative}" instead. + `); + } + function removed(oldCmd: string, alternative: string, version: string) { + let msg = `Note: the command "balena ${oldCmd}" was removed in CLI version ${version}.`; + if (alternative) { + msg = [msg, alternative].join('\n'); + } + exitWithExpectedError(msg); + } + const stopAlternative = + 'Please use "balena ssh -s" to access the host OS, then use `balena-engine stop`.'; + const cmds: { [cmd: string]: [(...args: any) => void, ...string[]] } = { + sync: [replaced, 'push', 'v11.0.0', 'removed'], + 'local logs': [replaced, 'logs', 'v11.0.0'], + 'local push': [replaced, 'push', 'v11.0.0'], + 'local scan': [replaced, 'scan', 'v11.0.0'], + 'local ssh': [replaced, 'ssh', 'v11.0.0'], + 'local stop': [removed, stopAlternative, 'v11.0.0'], + }; + let cmd: string | undefined; + if (argvSlice.length > 1) { + cmd = [argvSlice[0], argvSlice[1]].join(' '); + } else if (argvSlice.length > 0) { + cmd = argvSlice[0]; + } + if (cmd && Object.getOwnPropertyNames(cmds).includes(cmd)) { + cmds[cmd][0](cmd, ...cmds[cmd].slice(1)); + } +} + +/** + * Determine whether the CLI command has been converted from Capitano to ocif. + * @param argvSlice process.argv.slice(2) + */ +function isOclifCommand(argvSlice: string[]): boolean { + // Look for commands that have been transitioned to oclif + if (argvSlice.length > 1) { + // balena env add + if (argvSlice[0] === 'env' && argvSlice[1] === 'add') { + return true; + } + } + return false; +} + +/** + * CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which + * call this function. + */ +export function run(): void { + // globalInit() must be called very early on (before other imports) because + // it sets up Sentry error reporting, global HTTP proxy settings, balena-sdk + // shared options, and performs node version requirement checks. + require('./app-common').globalInit(); + routeCliFramework(process.argv); +} diff --git a/lib/errors.ts b/lib/errors.ts index 728b65ec..e68c3338 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -104,7 +104,7 @@ const messages: { $ balena login`, }; -exports.handle = function(error: any) { +export function handleError(error: any) { let message = interpret(error); if (message == null) { return; @@ -122,4 +122,4 @@ exports.handle = function(error: any) { // Ignore any errors (from error logging, or timeouts) }) .finally(() => process.exit(error.exitCode || 1)); -}; +} diff --git a/lib/global.d.ts b/lib/global.d.ts new file mode 100644 index 00000000..5d702e1a --- /dev/null +++ b/lib/global.d.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +interface Dictionary { + [key: string]: T; +} + +declare module '*/package.json' { + export const name: string; + export const version: string; +} diff --git a/lib/utils/device/deploy.ts b/lib/utils/device/deploy.ts index 7beec4b6..d3f83daf 100644 --- a/lib/utils/device/deploy.ts +++ b/lib/utils/device/deploy.ts @@ -46,9 +46,9 @@ export interface DeviceDeployOptions { dockerfilePath?: string; registrySecrets: RegistrySecrets; nocache: boolean; - live: boolean; + nolive: boolean; detached: boolean; - service?: string; + services?: string[]; system: boolean; env: string[]; } @@ -149,10 +149,11 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise { if (!semver.satisfies(version, '>=7.21.4')) { exitWithExpectedError(versionError); } - if (opts.live && !semver.satisfies(version, '>=9.7.0')) { - exitWithExpectedError( - new Error('Using livepush requires a supervisor >= v9.7.0'), + if (!opts.nolive && !semver.satisfies(version, '>=9.7.0')) { + globalLogger.logWarn( + `Using livepush requires a balena supervisor version >= 9.7.0. A live session will not be started.`, ); + opts.nolive = true; } } catch { exitWithExpectedError(versionError); @@ -180,7 +181,7 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise { const deviceInfo = await api.getDeviceInformation(); let buildLogs: Dictionary | undefined; - if (opts.live) { + if (!opts.nolive) { buildLogs = {}; } const buildTasks = await performBuilds( @@ -216,7 +217,7 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise { // Now that we've set the target state, the device will do it's thing // so we can either just display the logs, or start a livepush session // (whilst also display logs) - if (opts.live) { + if (!opts.nolive) { const livepush = new LivepushManager({ api, buildContext: opts.source, @@ -228,14 +229,15 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise { deployOpts: opts, }); - const promises = [livepush.init()]; + globalLogger.logLivepush('Watching for file changes...'); + const promises: Array | Promise> = [livepush.init()]; // Only show logs if we're not detaching if (!opts.detached) { console.log(); const logStream = await api.getLogStream(); globalLogger.logInfo('Streaming device logs...'); promises.push( - displayDeviceLogs(logStream, globalLogger, opts.system, opts.service), + displayDeviceLogs(logStream, globalLogger, opts.system, opts.services), ); } else { globalLogger.logLivepush( @@ -254,7 +256,12 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise { // Now all we need to do is stream back the logs const logStream = await api.getLogStream(); globalLogger.logInfo('Streaming device logs...'); - await displayDeviceLogs(logStream, globalLogger, opts.system, opts.service); + await displayDeviceLogs( + logStream, + globalLogger, + opts.system, + opts.services, + ); } } diff --git a/lib/utils/device/logs.ts b/lib/utils/device/logs.ts index 165cd915..0a88e2f5 100644 --- a/lib/utils/device/logs.ts +++ b/lib/utils/device/logs.ts @@ -37,11 +37,11 @@ export function displayDeviceLogs( logs: Readable, logger: Logger, system: boolean, - filterService?: string, + filterServices?: string[], ): Bluebird { return new Bluebird((resolve, reject) => { logs.on('data', log => { - displayLogLine(log, logger, system, filterService); + displayLogLine(log, logger, system, filterServices); }); logs.on('error', reject); @@ -64,11 +64,11 @@ function displayLogLine( log: string | Buffer, logger: Logger, system: boolean, - filterService?: string, + filterServices?: string[], ): void { try { const obj: Log = JSON.parse(log.toString()); - displayLogObject(obj, logger, system, filterService); + displayLogObject(obj, logger, system, filterServices); } catch (e) { logger.logDebug(`Dropping device log due to failed parsing: ${e}`); } @@ -78,7 +78,7 @@ export function displayLogObject( obj: T, logger: Logger, system: boolean, - filterService?: string, + filterServices?: string[], ): void { let toPrint: string; if (obj.timestamp != null) { @@ -88,8 +88,8 @@ export function displayLogObject( } if (obj.serviceName != null) { - if (filterService) { - if (obj.serviceName !== filterService) { + if (filterServices) { + if (!_.includes(filterServices, obj.serviceName)) { return; } } else if (system) { @@ -99,7 +99,7 @@ export function displayLogObject( const colourFn = getServiceColourFn(obj.serviceName); toPrint += ` ${colourFn(`[${obj.serviceName}]`)}`; - } else if (filterService != null && !system) { + } else if (filterServices != null && !system) { // We have a system log here but we are filtering based // on a service, so drop this too return; diff --git a/lib/utils/device/ssh.ts b/lib/utils/device/ssh.ts new file mode 100644 index 00000000..2621949b --- /dev/null +++ b/lib/utils/device/ssh.ts @@ -0,0 +1,128 @@ +/* +Copyright 2016-2019 Balena + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { ContainerInfo } from 'dockerode'; + +export interface DeviceSSHOpts { + address: string; + port?: number; + verbose: boolean; + service?: string; +} + +export const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`; + +export async function performLocalDeviceSSH( + opts: DeviceSSHOpts, +): Promise { + const childProcess = await import('child_process'); + const reduce = await import('lodash/reduce'); + const { getSubShellCommand } = await import('../helpers'); + const { exitWithExpectedError } = await import('../patterns'); + const { stripIndent } = await import('common-tags'); + const os = await import('os'); + + let command = ''; + + if (opts.service != null) { + // Get the containers which are on-device. Currently we + // are single application, which means we can assume any + // container which fulfills the form of + // $serviceName_$appId_$releaseId is what we want. Once + // we have multi-app, we should show a dialog which + // allows the user to choose the correct container + + const Docker = await import('dockerode'); + const escapeRegex = await import('lodash/escapeRegExp'); + const docker = new Docker({ + host: opts.address, + port: 2375, + }); + + const regex = new RegExp(`\\/?${escapeRegex(opts.service)}_\\d+_\\d+`); + const nameRegex = /\/?([a-zA-Z0-9_]+)_\d+_\d+/; + let allContainers: ContainerInfo[]; + try { + allContainers = await docker.listContainers(); + } catch (_e) { + exitWithExpectedError(stripIndent` + Could not access docker daemon on device ${opts.address}. + Please ensure the device is in local mode.`); + return; + } + + const serviceNames: string[] = []; + const containers = allContainers + .map(container => { + for (const name of container.Names) { + if (regex.test(name)) { + return { id: container.Id, name }; + } + const match = name.match(nameRegex); + if (match) { + serviceNames.push(match[1]); + } + } + return; + }) + .filter(c => c != null); + + if (containers.length === 0) { + exitWithExpectedError( + `Could not find a service on device with name ${opts.service}. ${ + serviceNames.length > 0 + ? `Available services:\n${reduce( + serviceNames, + (str, name) => `${str}\t${name}\n`, + '', + )}` + : '' + }`, + ); + } + if (containers.length > 1) { + exitWithExpectedError(stripIndent` + Found more than one container with a service name ${opts.service}. + This state is not supported, please contact support. + `); + } + + // Getting a command to work on all platforms is a pain, + // so we just define slightly different ones for windows + if (os.platform() !== 'win32') { + const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`; + command = `'${deviceContainerEngineBinary}' exec -ti ${ + containers[0]!.id + } '${shellCmd}'`; + } else { + const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`; + command = `${deviceContainerEngineBinary} exec -ti ${ + containers[0]!.id + } ${shellCmd}`; + } + } + // Generate the SSH command + const sshCommand = `ssh \ + ${opts.verbose ? '-vvv' : ''} \ + -t \ + -p ${opts.port ? opts.port : 22222} \ + -o LogLevel=ERROR \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + root@${opts.address} ${command}`; + + const subShell = getSubShellCommand(sshCommand); + childProcess.spawn(subShell.program, subShell.args, { stdio: 'inherit' }); +} diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts index a387a488..beab4ba5 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -223,3 +223,50 @@ export function retry( } return promise; } + +/** + * Return a compare(a, b) function suitable for use as the argument for the + * sort() method of an array. That function will use the given manuallySortedArray + * as "sorting guidance": + * - If both a and b are found in the manuallySortedArray, the returned + * compare(a, b) function will follow that ordering. + * - If neither a nor b are found in the manuallySortedArray, the returned + * compare(a, b) function will compare a and b using the standard '<' and + * '>' Javascript operators. + * - If only a or only b are found in the manuallySortedArray, the returned + * compare(a, b) function will treat the element that was found as being + * "smaller than" the not-found element (i.e. found elements appear before + * not-found elements in sorted order). + * + * The equalityFunc(a, x) argument is a function used to compare the items + * being sorted against the items in the manuallySortedArray. For example, if + * equalityFunc was (a, x) => a.startsWith(x), where a is an item being sorted + * and x is an item in the manuallySortedArray, then the manuallySortedArray + * could contain prefix substrings to guide the sorting. + * + * @param manuallySortedArray A pre-sorted array to guide the sorting + * @param equalityFunc An optional function used to compare the items being + * sorted against items in manuallySortedArray. It should return true if + * the two items compare equal, otherwise false. The arguments are the + * same as provided by the standard Javascript array.findIndex() method. + */ +export function getManualSortCompareFunction( + manuallySortedArray: U[], + equalityFunc: (a: T, x: U, index: number, array: U[]) => boolean, +): (a: T, b: T) => number { + return function(a: T, b: T): number { + const indexA = manuallySortedArray.findIndex((x, index, array) => + equalityFunc(a, x, index, array), + ); + const indexB = manuallySortedArray.findIndex((x, index, array) => + equalityFunc(b, x, index, array), + ); + if (indexA >= 0 && indexB >= 0) { + return indexA - indexB; + } else if (indexA < 0 && indexB < 0) { + return a < b ? -1 : a > b ? 1 : 0; + } else { + return indexA < 0 ? 1 : -1; + } + }; +} diff --git a/lib/utils/oclif-utils.ts b/lib/utils/oclif-utils.ts new file mode 100644 index 00000000..e507cffe --- /dev/null +++ b/lib/utils/oclif-utils.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as Config from '@oclif/config'; + +export const convertedCommands = { + 'env:add': 'env add', +}; + +/** + * This class is a partial copy-and-paste of + * @oclif/plugin-help/command/CommandHelp, which is used to generate oclif's + * command help output. + */ +export class CommandHelp { + constructor(public command: { args: any[] }) {} + + protected arg(arg: Config.Command['args'][0]): string { + const name = arg.name.toUpperCase(); + if (arg.required) { + return `${name}`; + } + return `[${name}]`; + } + + public defaultUsage(): string { + return CommandHelp.compact([ + // this.command.id, + this.command.args + .filter(a => !a.hidden) + .map(a => this.arg(a)) + .join(' '), + ]).join(' '); + } + + public static compact(array: Array): T[] { + return array.filter((a): a is T => !!a); + } +} diff --git a/lib/utils/remote-build.ts b/lib/utils/remote-build.ts index 9854ce96..4e8f9057 100644 --- a/lib/utils/remote-build.ts +++ b/lib/utils/remote-build.ts @@ -98,7 +98,7 @@ export async function startRemoteBuild(build: RemoteBuild): Promise { output: process.stdout, }); - rl.on('SIGINT', () => process.emit('SIGINT')); + rl.on('SIGINT', () => process.emit('SIGINT' as any)); } return new Bluebird((resolve, reject) => { diff --git a/lib/utils/ssh.ts b/lib/utils/ssh.ts index 1542c063..36e86863 100644 --- a/lib/utils/ssh.ts +++ b/lib/utils/ssh.ts @@ -15,7 +15,7 @@ * limitations under the License. */ import * as Bluebird from 'bluebird'; -import { spawn } from 'child_process'; +import { spawn, StdioOptions } from 'child_process'; import { TypedError } from 'typed-error'; import { getSubShellCommand } from './helpers'; @@ -45,7 +45,7 @@ export async function exec( root@${deviceIp} \ ${cmd}`; - const stdio = ['ignore', stdout ? 'pipe' : 'inherit', 'ignore']; + const stdio: StdioOptions = ['ignore', stdout ? 'pipe' : 'inherit', 'ignore']; const { program, args } = getSubShellCommand(command); const exitCode = await new Bluebird((resolve, reject) => { diff --git a/lib/utils/sudo.ts b/lib/utils/sudo.ts index 2f6cc49f..8c6cd9eb 100644 --- a/lib/utils/sudo.ts +++ b/lib/utils/sudo.ts @@ -1,4 +1,20 @@ -import { spawn } from 'child_process'; +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { spawn, StdioOptions } from 'child_process'; import * as Bluebird from 'bluebird'; import * as rindle from 'rindle'; @@ -7,9 +23,14 @@ export async function executeWithPrivileges( command: string[], stderr?: NodeJS.WritableStream, ): Promise { + const stdio: StdioOptions = [ + 'inherit', + 'inherit', + stderr ? 'pipe' : 'inherit', + ]; const opts = { - stdio: ['inherit', 'inherit', stderr ? 'pipe' : 'inherit'], env: process.env, + stdio, }; const args = process.argv diff --git a/lib/utils/validation.ts b/lib/utils/validation.ts index 9b33dcfb..2e13065e 100644 --- a/lib/utils/validation.ts +++ b/lib/utils/validation.ts @@ -22,6 +22,7 @@ const IP_REGEX = new RegExp( /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/, ); const DOTLOCAL_REGEX = new RegExp(/^([a-zA-Z0-9-]+\.)+local$/); +const UUID_REGEX = new RegExp(/^[0-9a-f]+$/); export function validateEmail(input: string) { if (!validEmail(input)) { @@ -54,3 +55,21 @@ export function validateIPAddress(input: string): boolean { export function validateDotLocalUrl(input: string): boolean { return DOTLOCAL_REGEX.test(input); } + +export function validateLongUuid(input: string): boolean { + if (input.length !== 32 && input.length !== 64) { + return false; + } + return UUID_REGEX.test(input); +} + +export function validateShortUuid(input: string): boolean { + if (input.length !== 7) { + return false; + } + return UUID_REGEX.test(input); +} + +export function validateUuid(input: string): boolean { + return validateLongUuid(input) || validateShortUuid(input); +} diff --git a/package.json b/package.json index 9e2fa0d4..679ce057 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "preferGlobal": true, "files": [ - "bin/balena", + "bin/run", "build/", "doc/", "lib/" @@ -31,13 +31,15 @@ ] }, "scripts": { - "prebuild": "rimraf build/ build-bin/ build-zip/ && patch-package", - "build": "npm run build:src && npm run build:bin", + "postinstall": "patch-package", + "prebuild": "rimraf build/ build-bin/", + "build": "npm run build:src", "build:src": "npm run prettify && npm run lint && npm run build:fast && npm run build:doc", "build:fast": "gulp build && tsc", - "build:doc": "mkdirp doc/ && ts-node automation/capitanodoc/index.ts > doc/cli.markdown", - "build:bin": "ts-node --type-check -P automation automation/build-bin.ts", - "release": "npm run build && ts-node --type-check -P automation automation/deploy-bin.ts", + "build:doc": "mkdirp doc/ && ts-node --type-check -P automation/tsconfig.json automation/capitanodoc/index.ts > doc/cli.markdown", + "build:standalone": "ts-node --type-check -P automation/tsconfig.json automation/run.ts build:standalone", + "build:installer": "ts-node --type-check -P automation/tsconfig.json automation/run.ts build:installer", + "release": "ts-node --type-check -P automation/tsconfig.json automation/run.ts release", "pretest": "npm run build", "test": "gulp test", "test:fast": "npm run build:fast && gulp test", @@ -45,7 +47,6 @@ "watch": "gulp watch", "prettify": "prettier --write \"{lib,tests,automation,typings}/**/*.ts\" --config ./node_modules/resin-lint/config/.prettierrc", "lint": "resin-lint lib/ tests/ && resin-lint --typescript automation/ lib/ typings/ tests/", - "prepublish": "require-npm4-to-publish", "prepublishOnly": "npm run build" }, "keywords": [ @@ -60,33 +61,44 @@ "author": "Juan Cruz Viotti ", "license": "Apache-2.0", "engines": { - "node": ">=6.0" + "node": ">=8.0" + }, + "oclif": { + "bin": "balena", + "commands": "./build/actions-oclif", + "macos": { + "identifier": "io.balena.cli" + } }, "devDependencies": { + "@oclif/config": "^1.12.12", + "@oclif/dev-cli": "1.22.0", + "@oclif/parser": "^3.7.3", "@types/archiver": "2.1.2", "@types/bluebird": "3.5.21", "@types/chokidar": "^1.7.5", "@types/common-tags": "1.4.0", "@types/dockerode": "2.5.5", - "@types/es6-promise": "0.0.32", - "@types/fs-extra": "5.0.4", + "@types/fs-extra": "7.0.0", "@types/is-root": "1.0.0", "@types/lodash": "4.14.112", "@types/mixpanel": "2.14.0", "@types/mkdirp": "0.5.2", "@types/mz": "0.0.32", "@types/net-keepalive": "^0.4.0", - "@types/node": "6.14.2", + "@types/node": "10.14.5", "@types/prettyjson": "0.0.28", "@types/raven": "2.5.1", "@types/request": "2.48.1", + "@types/rimraf": "^2.0.2", + "@types/shell-escape": "^0.2.0", "@types/stream-to-promise": "2.2.0", "@types/tar-stream": "1.6.0", "@types/through2": "2.0.33", "catch-uncommitted": "^1.3.0", "ent": "^2.2.0", "filehound": "^1.17.0", - "fs-extra": "^5.0.0", + "fs-extra": "^8.0.1", "gulp": "^4.0.1", "gulp-coffee": "^2.2.0", "gulp-inline-source": "^2.1.0", @@ -96,14 +108,16 @@ "patch-package": "^6.1.2", "pkg": "~4.3.8", "prettier": "^1.17.0", - "publish-release": "^1.3.3", - "require-npm4-to-publish": "^1.0.0", + "publish-release": "^1.6.0", "resin-lint": "^3.0.1", "rewire": "^3.0.2", - "ts-node": "^4.0.1", - "typescript": "2.8.1" + "shell-escape": "^0.2.0", + "ts-node": "^8.1.0", + "typescript": "3.4.3" }, "dependencies": { + "@oclif/command": "^1.5.12", + "@oclif/errors": "^1.2.2", "@resin.io/valid-email": "^0.1.0", "@zeit/dockerignore": "0.0.3", "JSONStream": "^1.0.3", @@ -128,7 +142,7 @@ "color-hash": "^1.0.3", "columnify": "^1.5.2", "common-tags": "^1.7.2", - "denymount": "~2.2.0", + "denymount": "^2.3.0", "docker-progress": "^4.0.0", "docker-qemu-transpose": "^0.5.3", "docker-toolbelt": "^3.3.7", @@ -154,8 +168,9 @@ "mkdirp": "^0.5.1", "moment": "^2.24.0", "moment-duration-format": "~2.2.2", - "mz": "^2.6.0", + "mz": "^2.7.0", "node-cleanup": "^2.1.2", + "oclif": "^1.13.1", "opn": "^5.5.0", "prettyjson": "^1.1.3", "progress-stream": "^2.0.0", @@ -189,7 +204,6 @@ }, "optionalDependencies": { "net-keepalive": "^1.2.1", - "removedrive": "^1.0.0", "windosu": "^0.2.0" } } diff --git a/patches/@oclif+dev-cli+1.22.0.patch b/patches/@oclif+dev-cli+1.22.0.patch new file mode 100644 index 00000000..dc71f7ae --- /dev/null +++ b/patches/@oclif+dev-cli+1.22.0.patch @@ -0,0 +1,224 @@ +diff --git a/node_modules/@oclif/dev-cli/lib/commands/pack/win.js b/node_modules/@oclif/dev-cli/lib/commands/pack/win.js +index a9d4276..75c2f8b 100644 +--- a/node_modules/@oclif/dev-cli/lib/commands/pack/win.js ++++ b/node_modules/@oclif/dev-cli/lib/commands/pack/win.js +@@ -3,11 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true }); + const command_1 = require("@oclif/command"); + const qq = require("qqjs"); + const Tarballs = require("../../tarballs"); ++const { fixPath } = require("../../util"); ++ + class PackWin extends command_1.Command { + async run() { + await this.checkForNSIS(); + const { flags } = this.parse(PackWin); +- const buildConfig = await Tarballs.buildConfig(flags.root); ++ const targets = flags.targets !== undefined ? flags.targets.split(',') : undefined; ++ const buildConfig = await Tarballs.buildConfig(flags.root, {targets}); + const { config } = buildConfig; + await Tarballs.build(buildConfig, { platform: 'win32', pack: false }); + const arches = buildConfig.targets.filter(t => t.platform === 'win32').map(t => t.arch); +@@ -17,7 +20,7 @@ class PackWin extends command_1.Command { + await qq.write([installerBase, `bin/${config.bin}`], scripts.sh(config)); + await qq.write([installerBase, `${config.bin}.nsi`], scripts.nsis(config, arch)); + await qq.mv(buildConfig.workspace({ platform: 'win32', arch }), [installerBase, 'client']); +- await qq.x(`makensis ${installerBase}/${config.bin}.nsi | grep -v "\\[compress\\]" | grep -v "^File: Descending to"`); ++ await qq.x(`makensis ${fixPath(installerBase)}/${config.bin}.nsi | grep -v "\\[compress\\]" | grep -v "^File: Descending to"`) + const o = buildConfig.dist(`win/${config.bin}-v${buildConfig.version}-${arch}.exe`); + await qq.mv([installerBase, 'installer.exe'], o); + this.log(`built ${o}`); +@@ -40,6 +43,7 @@ class PackWin extends command_1.Command { + PackWin.description = 'create windows installer from oclif CLI'; + PackWin.flags = { + root: command_1.flags.string({ char: 'r', description: 'path to oclif CLI root', default: '.', required: true }), ++ targets: command_1.flags.string({char: 't', description: 'comma-separated targets to pack (e.g.: win32-x86,win32-x64)'}), + }; + exports.default = PackWin; + const scripts = { +diff --git a/node_modules/@oclif/dev-cli/lib/tarballs/build.js b/node_modules/@oclif/dev-cli/lib/tarballs/build.js +index 3e613e0..621d52b 100644 +--- a/node_modules/@oclif/dev-cli/lib/tarballs/build.js ++++ b/node_modules/@oclif/dev-cli/lib/tarballs/build.js +@@ -19,6 +19,9 @@ const pack = async (from, to) => { + async function build(c, options = {}) { + const { xz, config } = c; + const prevCwd = qq.cwd(); ++ ++ console.log(`[patched @oclif/dev-cli] cwd="${prevCwd}"\n c.root="${c.root}" c.workspace()="${c.workspace()}"`); ++ + const packCLI = async () => { + const stdout = await qq.x.stdout('npm', ['pack', '--unsafe-perm'], { cwd: c.root }); + return path.join(c.root, stdout.split('\n').pop()); +@@ -34,6 +37,30 @@ async function build(c, options = {}) { + await qq.mv(f, '.'); + await qq.rm('package', tarball, 'bin/run.cmd'); + }; ++ const copyCLI = async() => { ++ const ws = c.workspace(); ++ await qq.emptyDir(ws); ++ qq.cd(ws); ++ const sources = [ ++ 'bin', 'build', 'patches', 'typings', 'CHANGELOG.md', 'INSTALL.md', ++ 'LICENSE', 'package.json', 'package-lock.json', 'README.md', ++ 'TROUBLESHOOTING.md', ++ ]; ++ for (const source of sources) { ++ console.log(`cp "${source}" -> "${ws}"`); ++ try { ++ await qq.cp(path.join(c.root, source), ws); ++ } catch (err) { ++ // OK if package-lock.json doesn't exist ++ if (source !== 'package-lock.json') { ++ throw err; ++ } ++ } ++ } ++ // rename the original balena-cli ./bin/balena entry point for oclif compatibility ++ await qq.mv('bin/balena', 'bin/run'); ++ await qq.rm('bin/run.cmd'); ++ } + const updatePJSON = async () => { + qq.cd(c.workspace()); + const pjson = await qq.readJSON('package.json'); +@@ -55,7 +82,11 @@ async function build(c, options = {}) { + if (!await qq.exists(lockpath)) { + lockpath = qq.join(c.root, 'npm-shrinkwrap.json'); + } +- await qq.cp(lockpath, '.'); ++ try { ++ await qq.cp(lockpath, '.'); ++ } catch (err) { ++ console.log('WARNING: found neiter package-lock.json nor npm-shrinkwrap.json') ++ } + await qq.x('npm install --production'); + } + }; +@@ -124,7 +155,8 @@ async function build(c, options = {}) { + await qq.writeJSON(c.dist(config.s3Key('manifest')), manifest); + }; + log_1.log(`gathering workspace for ${config.bin} to ${c.workspace()}`); +- await extractCLI(await packCLI()); ++ // await extractCLI(await packCLI()); ++ await copyCLI(); + await updatePJSON(); + await addDependencies(); + await bin_1.writeBinScripts({ config, baseWorkspace: c.workspace(), nodeVersion: c.nodeVersion }); +diff --git a/node_modules/@oclif/dev-cli/lib/tarballs/node.js b/node_modules/@oclif/dev-cli/lib/tarballs/node.js +index 343eb00..865d5a5 100644 +--- a/node_modules/@oclif/dev-cli/lib/tarballs/node.js ++++ b/node_modules/@oclif/dev-cli/lib/tarballs/node.js +@@ -1,19 +1,45 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + const errors_1 = require("@oclif/errors"); ++const { isMSYS2 } = require('qqjs'); + const path = require("path"); + const qq = require("qqjs"); + const log_1 = require("../log"); +-async function checkFor7Zip() { +- try { +- await qq.x('7z', { stdio: [0, null, 2] }); ++const { fixPath } = require("../util"); ++let try_install_7zip = true; ++async function checkFor7Zip(projectRootPath) { ++ let zPaths = [ ++ fixPath(path.join(projectRootPath, 'node_modules', '7zip', '7zip-lite', '7z.exe')), ++ '7z', ++ ]; ++ let foundPath = ''; ++ for (const zPath of zPaths) { ++ try { ++ console.log(`probing 7zip at "${zPath}"...`); ++ await qq.x(zPath, { stdio: [0, null, 2] }); ++ foundPath = zPath; ++ break; ++ } ++ catch (err) {} + } +- catch (err) { +- if (err.code === 127) +- errors_1.error('install 7-zip to package windows tarball'); +- else +- throw err; ++ if (foundPath) { ++ console.log(`found 7zip at "${foundPath}"`); ++ } else if (try_install_7zip) { ++ try_install_7zip = false; ++ console.log(`attempting "npm install 7zip"...`); ++ qq.pushd(projectRootPath); ++ try { ++ await qq.x('npm', ['install', '--no-save', '7zip']); ++ } catch (err) { ++ errors_1.error('install 7-zip to package windows tarball', true); ++ } finally { ++ qq.popd(); ++ } ++ return checkFor7Zip(projectRootPath); ++ } else { ++ errors_1.error('install 7-zip to package windows tarball', true); + } ++ return foundPath; + } + async function fetchNodeBinary({ nodeVersion, output, platform, arch, tmp }) { + if (arch === 'arm') +@@ -21,8 +47,9 @@ async function fetchNodeBinary({ nodeVersion, output, platform, arch, tmp }) { + let nodeBase = `node-v${nodeVersion}-${platform}-${arch}`; + let tarball = path.join(tmp, 'node', `${nodeBase}.tar.xz`); + let url = `https://nodejs.org/dist/v${nodeVersion}/${nodeBase}.tar.xz`; +- if (platform === 'win32') { +- await checkFor7Zip(); ++ let zPath = ''; ++ if (platform === 'win32') { ++ zPath = await checkFor7Zip(path.join(tmp, '..')); + nodeBase = `node-v${nodeVersion}-win-${arch}`; + tarball = path.join(tmp, 'node', `${nodeBase}.7z`); + url = `https://nodejs.org/dist/v${nodeVersion}/${nodeBase}.7z`; +@@ -40,7 +67,8 @@ async function fetchNodeBinary({ nodeVersion, output, platform, arch, tmp }) { + const basedir = path.dirname(tarball); + await qq.mkdirp(basedir); + await qq.download(url, tarball); +- await qq.x(`grep ${path.basename(tarball)} ${shasums} | shasum -a 256 -c -`, { cwd: basedir }); ++ const shaCmd = isMSYS2 ? 'sha256sum -c -' : 'shasum -a 256 -c -'; ++ await qq.x(`grep ${path.basename(tarball)} ${fixPath(shasums)} | ${shaCmd}`, { cwd: basedir }); + }; + const extract = async () => { + log_1.log(`extracting ${nodeBase}`); +@@ -50,7 +78,7 @@ async function fetchNodeBinary({ nodeVersion, output, platform, arch, tmp }) { + await qq.mkdirp(path.dirname(cache)); + if (platform === 'win32') { + qq.pushd(nodeTmp); +- await qq.x(`7z x -bd -y ${tarball} > /dev/null`); ++ await qq.x(`"${zPath}" x -bd -y ${fixPath(tarball)} > /dev/null`); + await qq.mv([nodeBase, 'node.exe'], cache); + qq.popd(); + } +diff --git a/node_modules/@oclif/dev-cli/lib/util.js b/node_modules/@oclif/dev-cli/lib/util.js +index 17368b4..9d3fcf9 100644 +--- a/node_modules/@oclif/dev-cli/lib/util.js ++++ b/node_modules/@oclif/dev-cli/lib/util.js +@@ -1,6 +1,7 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + const _ = require("lodash"); ++const { isCygwin, isMinGW, isMSYS2 } = require('qqjs'); + function castArray(input) { + if (input === undefined) + return []; +@@ -40,3 +41,17 @@ function sortBy(arr, fn) { + } + exports.sortBy = sortBy; + exports.template = (context) => (t) => _.template(t || '')(context); ++ ++function fixPath(badPath) { ++ console.log(`fixPath MSYSTEM=${process.env.MSYSTEM} OSTYPE=${process.env.OSTYPE} isMSYS2=${isMSYS2} isMingGW=${isMinGW} isCygwin=${isCygwin}`); ++ // 'c:\myfolder' -> '/c/myfolder' or '/cygdrive/c/myfolder' ++ let fixed = badPath.replace(/\\/g, '/'); ++ if (isMSYS2 || isMinGW) { ++ fixed = fixed.replace(/^([a-zA-Z]):/, '/$1'); ++ } else if (isCygwin) { ++ fixed = fixed.replace(/^([a-zA-Z]):/, '/cygdrive/$1'); ++ } ++ console.log(`[patched @oclif/dev-cli] fixPath before="${badPath}" after="${fixed}"`); ++ return fixed; ++} ++exports.fixPath = fixPath; diff --git a/patches/denymount+2.2.0.patch b/patches/denymount+2.2.0.patch deleted file mode 100644 index 2e166ab4..00000000 --- a/patches/denymount+2.2.0.patch +++ /dev/null @@ -1,38 +0,0 @@ -diff --git a/node_modules/denymount/lib/index.js b/node_modules/denymount/lib/index.js -index 93b8e59..86d53dc 100644 ---- a/node_modules/denymount/lib/index.js -+++ b/node_modules/denymount/lib/index.js -@@ -24,7 +24,9 @@ var utils = require('./utils'); - * @module denymount - */ - --var EXECUTABLE_PATH = path.join(__dirname, '..', 'bin', 'denymount'); -+var EXECUTABLE_PATH = process.pkg -+ ? path.join(path.dirname(process.execPath), 'denymount') -+ : path.join(__dirname, '..', 'bin', 'denymount'); - - /** - * @summary Prevent automatic mounting of an OS X disk -diff --git a/node_modules/denymount/lib/index.js.rej b/node_modules/denymount/lib/index.js.rej -new file mode 100644 -index 0000000..a2c0516 ---- /dev/null -+++ b/node_modules/denymount/lib/index.js.rej -@@ -0,0 +1,17 @@ -+*************** -+*** 24,30 **** -+ * @module denymount -+ */ -+ -+- var EXECUTABLE_PATH = path.join(__dirname, '..', 'bin', 'denymount'); -+ -+ /** -+ * @summary Prevent automatic mounting of an OS X disk -+--- 24,30 ---- -+ * @module denymount -+ */ -+ -++ var EXECUTABLE_PATH = path.join(path.dirname(process.execPath), 'denymount', 'bin', 'denymount'); -+ -+ /** -+ * @summary Prevent automatic mounting of an OS X disk diff --git a/patches/qqjs++execa+0.10.0.patch b/patches/qqjs++execa+0.10.0.patch new file mode 100644 index 00000000..226ea0f1 --- /dev/null +++ b/patches/qqjs++execa+0.10.0.patch @@ -0,0 +1,53 @@ +diff --git a/node_modules/qqjs/node_modules/execa/index.js b/node_modules/qqjs/node_modules/execa/index.js +index 06f3969..7ab1b66 100644 +--- a/node_modules/qqjs/node_modules/execa/index.js ++++ b/node_modules/qqjs/node_modules/execa/index.js +@@ -14,6 +14,17 @@ const stdio = require('./lib/stdio'); + + const TEN_MEGABYTES = 1000 * 1000 * 10; + ++// OSTYPE is 'msys' for MSYS 1.0 and for MSYS2, or 'cygwin' for Cygwin ++// but note that OSTYPE is not "exported" by default, so run: export OSTYPE=$OSTYPE ++// MSYSTEM is 'MINGW32' for MSYS 1.0, 'MSYS' for MSYS2, and undefined for Cygwin ++const isCygwin = process.env.OSTYPE === 'cygwin'; ++const isMinGW = process.env.MSYSTEM && process.env.MSYSTEM.startsWith('MINGW'); ++const isMSYS2 = process.env.MSYSTEM && process.env.MSYSTEM.startsWith('MSYS'); ++ ++console.log(`[patched execa] detected "${ ++ isCygwin ? 'Cygwin' : isMinGW ? 'MinGW' : isMSYS2 ? 'MSYS2' : 'standard' ++}" environment (MSYSTEM="${process.env.MSYSTEM}")`); ++ + function handleArgs(cmd, args, opts) { + let parsed; + +@@ -104,13 +115,21 @@ function handleShell(fn, cmd, opts) { + + opts = Object.assign({}, opts); + +- if (process.platform === 'win32') { ++ if (isMSYS2 || isMinGW || isCygwin) { ++ file = process.env.MSYSSHELLPATH || ++ (isMSYS2 ? 'C:\\msys64\\usr\\bin\\bash.exe' : ++ (isMinGW ? 'C:\\MinGW\\msys\\1.0\\bin\\bash.exe' : ++ (isCygwin ? 'C:\\cygwin64\\bin\\bash.exe' : file))); ++ } ++ else if (process.platform === 'win32') { + opts.__winShell = true; + file = process.env.comspec || 'cmd.exe'; + args = ['/s', '/c', `"${cmd}"`]; + opts.windowsVerbatimArguments = true; + } + ++ console.log(`[patched execa] handleShell file="${file}" args="[${args}]"`); ++ + if (opts.shell) { + file = opts.shell; + delete opts.shell; +@@ -364,3 +383,7 @@ module.exports.sync = (cmd, args, opts) => { + module.exports.shellSync = (cmd, opts) => handleShell(module.exports.sync, cmd, opts); + + module.exports.spawn = util.deprecate(module.exports, 'execa.spawn() is deprecated. Use execa() instead.'); ++ ++module.exports.isCygwin = isCygwin; ++module.exports.isMinGW = isMinGW; ++module.exports.isMSYS2 = isMSYS2; diff --git a/patches/qqjs+0.3.10.patch b/patches/qqjs+0.3.10.patch new file mode 100644 index 00000000..b528dcab --- /dev/null +++ b/patches/qqjs+0.3.10.patch @@ -0,0 +1,16 @@ +diff --git a/node_modules/qqjs/lib/exec.js b/node_modules/qqjs/lib/exec.js +index 835f565..84bb5be 100644 +--- a/node_modules/qqjs/lib/exec.js ++++ b/node_modules/qqjs/lib/exec.js +@@ -5,6 +5,11 @@ const m = { + m: {}, + get execa() { return this.m.execa = this.m.execa || require('execa'); }, + }; ++const { isCygwin, isMinGW, isMSYS2 } = require('execa'); ++exports.isCygwin = isCygwin; ++exports.isMinGW = isMinGW; ++exports.isMSYS2 = isMSYS2; ++console.log(`qqjs exec.js MSYSTEM=${process.env.MSYSTEM} OSTYPE=${process.env.OSTYPE} isMSYS2=${isMSYS2} isMingGW=${isMinGW} isCygwin=${isCygwin}`); + /** + * easy access to process.env + */ diff --git a/tsconfig.json b/tsconfig.json index 606bb242..d9333e29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "commonjs", - "target": "es6", + "target": "es2017", "outDir": "build", "strict": true, "strictPropertyInitialization": false, @@ -11,20 +11,13 @@ "removeComments": true, "sourceMap": true, "skipLibCheck": true, - "lib": [ - // es5 defaults: - "dom", - "es5", - "scripthost", - // some specific es6 bits we're sure are safe: - "es2015.collection", - "es2015.iterable", - "es2016.array.include" + "typeRoots" : [ + "./node_modules/@types", + "./node_modules/etcher-sdk/typings", + "./typings" ] }, "include": [ - "./typings/*.d.ts", - "./node_modules/etcher-sdk/typings/**/*.d.ts", "./lib/**/*.ts" ] } diff --git a/typings/@resin-valid-email.d.ts b/typings/@resin-valid-email.d.ts deleted file mode 100644 index 7d9183ee..00000000 --- a/typings/@resin-valid-email.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@resin.io/valid-email'; diff --git a/typings/JSONStream.d.ts b/typings/JSONStream/index.d.ts similarity index 100% rename from typings/JSONStream.d.ts rename to typings/JSONStream/index.d.ts diff --git a/typings/balena-device-init.d.ts b/typings/balena-device-init/index.d.ts similarity index 99% rename from typings/balena-device-init.d.ts rename to typings/balena-device-init/index.d.ts index 7af56477..f3b27401 100644 --- a/typings/balena-device-init.d.ts +++ b/typings/balena-device-init/index.d.ts @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + declare module 'balena-device-init' { import { DeviceType } from 'balena-sdk'; import * as Promise from 'bluebird'; diff --git a/typings/balena-sync.d.ts b/typings/balena-sync.d.ts deleted file mode 100644 index 91bea19c..00000000 --- a/typings/balena-sync.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module 'balena-sync' { - import { CommandDefinition } from 'capitano'; - - export function capitano(tool: 'balena-cli'): CommandDefinition; -} diff --git a/typings/balena-sync/index.d.ts b/typings/balena-sync/index.d.ts new file mode 100644 index 00000000..6d4c9d3e --- /dev/null +++ b/typings/balena-sync/index.d.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'balena-sync' { + import { CommandDefinition } from 'capitano'; + + export function capitano(tool: 'balena-cli'): CommandDefinition; +} diff --git a/typings/bash/index.d.ts b/typings/bash/index.d.ts new file mode 100644 index 00000000..60e29952 --- /dev/null +++ b/typings/bash/index.d.ts @@ -0,0 +1 @@ +declare module 'bash'; diff --git a/typings/capitano.d.ts b/typings/capitano/index.d.ts similarity index 60% rename from typings/capitano.d.ts rename to typings/capitano/index.d.ts index acefe3ae..2ce5efef 100644 --- a/typings/capitano.d.ts +++ b/typings/capitano/index.d.ts @@ -1,3 +1,20 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + declare module 'capitano' { export function parse(argv: string[]): Cli; @@ -19,11 +36,11 @@ declare module 'capitano' { signature: string; description: string; help: string; - options?: OptionDefinition[]; + options?: Partial; permission?: 'user'; root?: boolean; primary?: boolean; - action(params: P, options: O, done: () => void): void; + action(params: P, options: Partial, done: () => void): void; } export interface Command { diff --git a/typings/color-hash.d.ts b/typings/color-hash/index.d.ts similarity index 99% rename from typings/color-hash.d.ts rename to typings/color-hash/index.d.ts index 1eb731e8..f9232309 100644 --- a/typings/color-hash.d.ts +++ b/typings/color-hash/index.d.ts @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + declare module 'color-hash' { interface Hasher { hex(text: string): string; diff --git a/typings/dockerfile-template.d.ts b/typings/dockerfile-template.d.ts deleted file mode 100644 index 15cc7623..00000000 --- a/typings/dockerfile-template.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare module 'dockerfile-template' { - /** - * Variables which define what will be replaced, and what they will be replaced with. - */ - export interface TemplateVariables { - [key: string]: string; - } - - export function process( - content: string, - variables: TemplateVariables, - ): string; -} diff --git a/typings/dockerfile-template/index.d.ts b/typings/dockerfile-template/index.d.ts new file mode 100644 index 00000000..4dcbbdfb --- /dev/null +++ b/typings/dockerfile-template/index.d.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'dockerfile-template' { + /** + * Variables which define what will be replaced, and what they will be replaced with. + */ + export interface TemplateVariables { + [key: string]: string; + } + + export function process( + content: string, + variables: TemplateVariables, + ): string; +} diff --git a/typings/ent.d.ts b/typings/ent.d.ts deleted file mode 100644 index 04e49ca3..00000000 --- a/typings/ent.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'ent'; diff --git a/typings/ent/index.d.ts b/typings/ent/index.d.ts new file mode 100644 index 00000000..582fb754 --- /dev/null +++ b/typings/ent/index.d.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'ent'; diff --git a/typings/filehound/index.d.ts b/typings/filehound/index.d.ts new file mode 100644 index 00000000..9c0bdbce --- /dev/null +++ b/typings/filehound/index.d.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'filehound' { + export function create(): FileHound; + + export interface FileHound { + paths(paths: string[]): FileHound; + paths(...paths: string[]): FileHound; + ext(extensions: string[]): FileHound; + ext(...extensions: string[]): FileHound; + find(): Promise; + } +} diff --git a/typings/global.d.ts b/typings/global.d.ts deleted file mode 100644 index 64dbc25f..00000000 --- a/typings/global.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -interface Dictionary { - [key: string]: T; -} diff --git a/typings/inquire-dynamic-list.d.ts b/typings/inquirer-dynamic-list/index.d.ts similarity index 99% rename from typings/inquire-dynamic-list.d.ts rename to typings/inquirer-dynamic-list/index.d.ts index eac1019c..125e406c 100644 --- a/typings/inquire-dynamic-list.d.ts +++ b/typings/inquirer-dynamic-list/index.d.ts @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + declare module 'inquirer-dynamic-list' { interface Choice { name: string; diff --git a/typings/nplugm.d.ts b/typings/nplugm/index.d.ts similarity index 99% rename from typings/nplugm.d.ts rename to typings/nplugm/index.d.ts index a9e983de..39f2e452 100644 --- a/typings/nplugm.d.ts +++ b/typings/nplugm/index.d.ts @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + declare module 'nplugm' { import Promise = require('bluebird'); export function list(regexp: RegExp): Promise; diff --git a/typings/package.json.d.ts b/typings/package.json.d.ts deleted file mode 100644 index 7f887f81..00000000 --- a/typings/package.json.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*/package.json' { - export const name: string; - export const version: string; -} diff --git a/typings/pkg/index.d.ts b/typings/pkg/index.d.ts new file mode 100644 index 00000000..25ff540f --- /dev/null +++ b/typings/pkg/index.d.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'pkg' { + export function exec(args: string[]): Promise; +} diff --git a/typings/president.d.ts b/typings/president.d.ts deleted file mode 100644 index c831ac3b..00000000 --- a/typings/president.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module 'president' { - export function execute( - command: string[], - callback: (err: Error) => void, - ): void; -} diff --git a/typings/president/index.d.ts b/typings/president/index.d.ts new file mode 100644 index 00000000..0de6567f --- /dev/null +++ b/typings/president/index.d.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'president' { + export function execute( + command: string[], + callback: (err: Error) => void, + ): void; +} diff --git a/typings/publish-release/index.d.ts b/typings/publish-release/index.d.ts new file mode 100644 index 00000000..48dad1d2 --- /dev/null +++ b/typings/publish-release/index.d.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'publish-release' { + interface PublishOptions { + token: string; + owner: string; + repo: string; + tag: string; + name: string; + reuseRelease?: boolean; + assets: string[]; + } + + interface Release { + html_url: string; + } + + let publishRelease: ( + args: PublishOptions, + callback: (e: Error, release: Release) => void, + ) => void; + + export = publishRelease; +} diff --git a/typings/resin-cli-form.d.ts b/typings/resin-cli-form.d.ts deleted file mode 100644 index 073592bd..00000000 --- a/typings/resin-cli-form.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'resin-cli-form'; diff --git a/typings/resin-cli-form/index.d.ts b/typings/resin-cli-form/index.d.ts new file mode 100644 index 00000000..4fcdef7e --- /dev/null +++ b/typings/resin-cli-form/index.d.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'resin-cli-form'; diff --git a/typings/resin-cli-visuals.d.ts b/typings/resin-cli-visuals.d.ts deleted file mode 100644 index f1b2e48f..00000000 --- a/typings/resin-cli-visuals.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'resin-cli-visuals'; diff --git a/typings/resin-cli-visuals/index.d.ts b/typings/resin-cli-visuals/index.d.ts new file mode 100644 index 00000000..f5f51a76 --- /dev/null +++ b/typings/resin-cli-visuals/index.d.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'resin-cli-visuals'; diff --git a/typings/resin-image-fs.d.ts b/typings/resin-image-fs.d.ts deleted file mode 100644 index ad1c0879..00000000 --- a/typings/resin-image-fs.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module 'resin-image-fs' { - import Promise = require('bluebird'); - - export function readFile(options: {}): Promise; -} diff --git a/typings/resin-image-fs/index.d.ts b/typings/resin-image-fs/index.d.ts new file mode 100644 index 00000000..ffebb4a3 --- /dev/null +++ b/typings/resin-image-fs/index.d.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'resin-image-fs' { + import Promise = require('bluebird'); + + export function readFile(options: {}): Promise; +} diff --git a/typings/resin.io/index.d.ts b/typings/resin.io/index.d.ts new file mode 100644 index 00000000..919313fd --- /dev/null +++ b/typings/resin.io/index.d.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2019 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module '@resin.io/valid-email'; diff --git a/typings/rindle.d.ts b/typings/rindle/index.d.ts similarity index 99% rename from typings/rindle.d.ts rename to typings/rindle/index.d.ts index 36e53cd7..8cd20fbc 100644 --- a/typings/rindle.d.ts +++ b/typings/rindle/index.d.ts @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + declare module 'rindle' { export function extract( stream: NodeJS.ReadableStream, diff --git a/typings/update-notifier.d.ts b/typings/update-notifier/index.d.ts similarity index 100% rename from typings/update-notifier.d.ts rename to typings/update-notifier/index.d.ts