From 13e3e5e8eadf56d6a258637d7b6a83a9c7895b78 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Thu, 25 Apr 2019 18:39:16 +0100 Subject: [PATCH 01/22] Bump min Node.js version to 8.0, ts-node to 8.1 and typescript to 3.4. Refactor typings folder for use with the tsconfig typeRoots option. Change-type: major Signed-off-by: Paulo Castro --- .travis.yml | 2 +- appveyor.yml | 2 +- automation/custom-types.d.ts | 38 ------------------ automation/tsconfig.json | 16 ++++---- bin/balena-dev | 10 +++-- lib/actions/environment-variables.ts | 6 +-- lib/actions/tunnel.ts | 8 ++-- lib/global.d.ts | 25 ++++++++++++ lib/utils/device/deploy.ts | 3 +- lib/utils/remote-build.ts | 2 +- lib/utils/ssh.ts | 4 +- lib/utils/sudo.ts | 25 +++++++++++- package.json | 14 +++---- tsconfig.json | 17 +++----- typings/@resin-valid-email.d.ts | 1 - .../index.d.ts} | 0 .../index.d.ts} | 1 + typings/balena-sync.d.ts | 5 --- typings/balena-sync/index.d.ts | 22 +++++++++++ .../{capitano.d.ts => capitano/index.d.ts} | 17 ++++++++ .../index.d.ts} | 1 + typings/dockerfile-template.d.ts | 13 ------- typings/dockerfile-template/index.d.ts | 30 ++++++++++++++ typings/ent.d.ts | 1 - typings/ent/index.d.ts | 18 +++++++++ typings/filehound/index.d.ts | 28 +++++++++++++ typings/global.d.ts | 3 -- .../index.d.ts} | 1 + typings/{nplugm.d.ts => nplugm/index.d.ts} | 1 + typings/package.json.d.ts | 4 -- typings/pkg/index.d.ts | 20 ++++++++++ typings/president.d.ts | 6 --- typings/president/index.d.ts | 23 +++++++++++ typings/publish-release/index.d.ts | 39 +++++++++++++++++++ typings/resin-cli-form.d.ts | 1 - typings/resin-cli-form/index.d.ts | 18 +++++++++ typings/resin-cli-visuals.d.ts | 1 - typings/resin-cli-visuals/index.d.ts | 18 +++++++++ typings/resin-image-fs.d.ts | 5 --- typings/resin-image-fs/index.d.ts | 22 +++++++++++ typings/resin.io/index.d.ts | 18 +++++++++ typings/{rindle.d.ts => rindle/index.d.ts} | 1 + .../index.d.ts} | 0 43 files changed, 367 insertions(+), 123 deletions(-) delete mode 100644 automation/custom-types.d.ts create mode 100644 lib/global.d.ts delete mode 100644 typings/@resin-valid-email.d.ts rename typings/{JSONStream.d.ts => JSONStream/index.d.ts} (100%) rename typings/{balena-device-init.d.ts => balena-device-init/index.d.ts} (99%) delete mode 100644 typings/balena-sync.d.ts create mode 100644 typings/balena-sync/index.d.ts rename typings/{capitano.d.ts => capitano/index.d.ts} (65%) rename typings/{color-hash.d.ts => color-hash/index.d.ts} (99%) delete mode 100644 typings/dockerfile-template.d.ts create mode 100644 typings/dockerfile-template/index.d.ts delete mode 100644 typings/ent.d.ts create mode 100644 typings/ent/index.d.ts create mode 100644 typings/filehound/index.d.ts delete mode 100644 typings/global.d.ts rename typings/{inquire-dynamic-list.d.ts => inquirer-dynamic-list/index.d.ts} (99%) rename typings/{nplugm.d.ts => nplugm/index.d.ts} (99%) delete mode 100644 typings/package.json.d.ts create mode 100644 typings/pkg/index.d.ts delete mode 100644 typings/president.d.ts create mode 100644 typings/president/index.d.ts create mode 100644 typings/publish-release/index.d.ts delete mode 100644 typings/resin-cli-form.d.ts create mode 100644 typings/resin-cli-form/index.d.ts delete mode 100644 typings/resin-cli-visuals.d.ts create mode 100644 typings/resin-cli-visuals/index.d.ts delete mode 100644 typings/resin-image-fs.d.ts create mode 100644 typings/resin-image-fs/index.d.ts create mode 100644 typings/resin.io/index.d.ts rename typings/{rindle.d.ts => rindle/index.d.ts} (99%) rename typings/{update-notifier.d.ts => update-notifier/index.d.ts} (100%) diff --git a/.travis.yml b/.travis.yml index cc18cee2..bab23522 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ os: - linux - osx node_js: - - "6" + - "8" before_install: - npm -g install npm@4 script: npm run ci diff --git a/appveyor.yml b/appveyor.yml index ab9ebe80..92f6bf2f 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -14,7 +14,7 @@ matrix: # what combinations to test environment: matrix: - - nodejs_version: 6 + - nodejs_version: 8 install: - ps: Install-Product node $env:nodejs_version x64 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/tsconfig.json b/automation/tsconfig.json index 938fd3d7..99131ce4 100644 --- a/automation/tsconfig.json +++ b/automation/tsconfig.json @@ -1,16 +1,18 @@ { "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" - ] + "sourceMap": true, + "skipLibCheck": true, + "typeRoots" : [ + "../node_modules/@types", + "../typings" + ] + } } diff --git a/bin/balena-dev b/bin/balena-dev index c00fc85d..bcd1dd2d 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'); +// 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'); diff --git a/lib/actions/environment-variables.ts b/lib/actions/environment-variables.ts index 03b12df7..32ea05cf 100644 --- a/lib/actions/environment-variables.ts +++ b/lib/actions/environment-variables.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { ApplicationVariable, DeviceVariable } from 'balena-sdk'; +import * as Bluebird from 'bluebird'; import { CommandDefinition } from 'capitano'; import { stripIndent } from 'common-tags'; @@ -73,14 +74,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) { @@ -209,7 +209,6 @@ export const add: 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(); @@ -280,7 +279,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/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/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..c5059c88 100644 --- a/lib/utils/device/deploy.ts +++ b/lib/utils/device/deploy.ts @@ -228,7 +228,8 @@ 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(); 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/package.json b/package.json index 9e2fa0d4..44b8e200 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "build": "npm run build:src && npm run build:bin", "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:bin": "ts-node --type-check -P automation/tsconfig.json automation/build-bin.ts", + "release": "npm run build && ts-node --type-check -P automation/tsconfig.json automation/deploy-bin.ts", "pretest": "npm run build", "test": "gulp test", "test:fast": "npm run build:fast && gulp test", @@ -60,7 +60,7 @@ "author": "Juan Cruz Viotti ", "license": "Apache-2.0", "engines": { - "node": ">=6.0" + "node": ">=8.0" }, "devDependencies": { "@types/archiver": "2.1.2", @@ -76,7 +76,7 @@ "@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", @@ -100,8 +100,8 @@ "require-npm4-to-publish": "^1.0.0", "resin-lint": "^3.0.1", "rewire": "^3.0.2", - "ts-node": "^4.0.1", - "typescript": "2.8.1" + "ts-node": "^8.1.0", + "typescript": "3.4.3" }, "dependencies": { "@resin.io/valid-email": "^0.1.0", 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/capitano.d.ts b/typings/capitano/index.d.ts similarity index 65% rename from typings/capitano.d.ts rename to typings/capitano/index.d.ts index acefe3ae..a2d65b44 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; 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 From abf573fa479f08710983c25e60f94509ab4db08d Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Tue, 2 Apr 2019 12:26:21 +0100 Subject: [PATCH 02/22] Begin the transition to oclif with 'balena env add' (fix dropped leading zero in device UUID). This commit is fairly chunky because it adds the oclif dependency for the first time, and refactors the CLI help and docs generation code to accommodate both Capitano and oclif. Change-type: patch Signed-off-by: Paulo Castro --- automation/capitanodoc/capitanodoc.ts | 5 +- automation/capitanodoc/doc-types.d.ts | 25 +++- automation/capitanodoc/index.ts | 41 +++++-- automation/capitanodoc/markdown.ts | 145 ++++++++++++++++------- automation/capitanodoc/utils.ts | 13 +- bin/balena | 3 +- bin/balena-dev | 2 +- doc/cli.markdown | 46 +++++--- lib/actions-oclif/env/add.ts | 150 ++++++++++++++++++++++++ lib/actions/environment-variables.ts | 93 --------------- lib/actions/help.coffee | 11 +- lib/actions/help_ts.ts | 35 ++++++ lib/{app.coffee => app-capitano.coffee} | 74 +----------- lib/app-common.ts | 107 +++++++++++++++++ lib/app-oclif.ts | 37 ++++++ lib/app.ts | 86 ++++++++++++++ lib/errors.ts | 4 +- lib/utils/helpers.ts | 47 ++++++++ lib/utils/oclif-utils.ts | 53 +++++++++ package.json | 14 ++- 20 files changed, 737 insertions(+), 254 deletions(-) create mode 100644 lib/actions-oclif/env/add.ts create mode 100644 lib/actions/help_ts.ts rename lib/{app.coffee => app-capitano.coffee} (67%) create mode 100644 lib/app-common.ts create mode 100644 lib/app-oclif.ts create mode 100644 lib/app.ts create mode 100644 lib/utils/oclif-utils.ts diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index 423d5dbb..b599a9fc 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', 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..808e309d 100644 --- a/automation/capitanodoc/markdown.ts +++ b/automation/capitanodoc/markdown.ts @@ -14,81 +14,136 @@ * 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 - }`; + 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/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 bcd1dd2d..2b0dfdb4 100755 --- a/bin/balena-dev +++ b/bin/balena-dev @@ -20,4 +20,4 @@ require('coffeescript/register'); // 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'); +require('../lib/app').run(); diff --git a/doc/cli.markdown b/doc/cli.markdown index a2b4196e..62419945 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -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 @@ -633,38 +633,47 @@ 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 enviroment 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 enviroment + ### 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> @@ -2082,4 +2091,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..00e1d96f --- /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 enviroment or config variable to an application or device. + + Add an enviroment 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 enviroment", + }, + ]; + + // 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/environment-variables.ts b/lib/actions/environment-variables.ts index 32ea05cf..4bbcb810 100644 --- a/lib/actions/environment-variables.ts +++ b/lib/actions/environment-variables.ts @@ -13,7 +13,6 @@ 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'; @@ -22,18 +21,6 @@ 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< {}, { @@ -171,86 +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 _ = 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; diff --git a/lib/actions/help.coffee b/lib/actions/help.coffee index adb5e57d..c57fc765 100644 --- a/lib/actions/help.coffee +++ b/lib/actions/help.coffee @@ -17,11 +17,13 @@ limitations under the License. _ = require('lodash') capitano = require('capitano') columnify = require('columnify') + messages = require('../utils/messages') { 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,14 +35,15 @@ parse = (object) -> return [ signature item.description - ]).sort() + ] indent = (text) -> text = _.map text.split('\n'), (line) -> return ' ' + line return text.join('\n') -print = (data) -> +print = (usageDescriptionPairs...) -> + data = _.fromPairs([].concat(usageDescriptionPairs...).sort()) console.log indent columnify data, showHeaders: false minWidth: 35 @@ -64,7 +67,7 @@ general = (params, options, done) -> if options.verbose console.log('\nAdditional commands:\n') - print(parse(groupedCommands.secondary)) + print(parse(groupedCommands.secondary), getOclifHelpLinePairs()) else console.log('\nRun `balena help --verbose` to list additional commands') 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/app.coffee b/lib/app-capitano.coffee similarity index 67% rename from lib/app.coffee rename to lib/app-capitano.coffee index 99083be8..5697b9f6 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 @@ -147,7 +83,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) @@ -216,14 +151,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..78c7864b --- /dev/null +++ b/lib/app.ts @@ -0,0 +1,86 @@ +/** + * @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. + */ + +/** + * 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; + + 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'); + } +} + +/** + * 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/utils/helpers.ts b/lib/utils/helpers.ts index a387a488..7d0d96b7 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 consider the found element as being + * "smaller than" the not-found element (i.e. found elements appeare before + * not-found elements in sorted order). + * + * The equalityFunc() argument is a function used to compare the array items + * against 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/package.json b/package.json index 44b8e200..a2f3caaf 100644 --- a/package.json +++ b/package.json @@ -62,13 +62,22 @@ "engines": { "node": ">=8.0" }, + "oclif": { + "bin": "balena", + "commands": "./build/actions-oclif", + "macos": { + "identifier": "io.balena.cli" + } + }, "devDependencies": { + "@oclif/dev-cli": "^1.22.0", + "@oclif/config": "^1.12.12", + "@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/is-root": "1.0.0", "@types/lodash": "4.14.112", @@ -104,6 +113,8 @@ "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", @@ -156,6 +167,7 @@ "moment-duration-format": "~2.2.2", "mz": "^2.6.0", "node-cleanup": "^2.1.2", + "oclif": "^1.13.1", "opn": "^5.5.0", "prettyjson": "^1.1.3", "progress-stream": "^2.0.0", From c49a1d3fbff576680033cda031251268933b5e84 Mon Sep 17 00:00:00 2001 From: Alexis Svinartchouk Date: Mon, 29 Apr 2019 17:53:10 +0200 Subject: [PATCH 03/22] Remove --dockerPort's -p alias for `balena preload` It was conflicting with --pin-device-to-release -p alias Changelog-entry: Remove --dockerPort's -p alias for `balena preload` Change-type: major --- doc/cli.markdown | 2 +- lib/actions/preload.coffee | 96 ++++++++++++++++++++------------------ 2 files changed, 52 insertions(+), 46 deletions(-) diff --git a/doc/cli.markdown b/doc/cli.markdown index 62419945..213e9c16 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -1430,7 +1430,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) 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') From 8f8d6b5f08ce563090a0f6c18473b11ab2d0bb1a Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Mon, 29 Apr 2019 22:31:49 +0100 Subject: [PATCH 04/22] Sort 'balena help' primary commands in manually specified order Connects-to: #1140 Change-type: patch Signed-off-by: Paulo Castro --- lib/actions/help.coffee | 37 ++++++++++++++++++++++++++++++------- lib/utils/helpers.ts | 14 +++++++------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/lib/actions/help.coffee b/lib/actions/help.coffee index c57fc765..503021aa 100644 --- a/lib/actions/help.coffee +++ b/lib/actions/help.coffee @@ -19,6 +19,7 @@ capitano = require('capitano') columnify = require('columnify') messages = require('../utils/messages') +{ getManualSortCompareFunction } = require('../utils/helpers') { exitWithExpectedError } = require('../utils/patterns') { getOclifHelpLinePairs } = require('./help_ts') @@ -42,12 +43,30 @@ indent = (text) -> return ' ' + line return text.join('\n') -print = (usageDescriptionPairs...) -> - data = _.fromPairs([].concat(usageDescriptionPairs...).sort()) - 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) @@ -63,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), getOclifHelpLinePairs()) + 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() @@ -93,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/utils/helpers.ts b/lib/utils/helpers.ts index 7d0d96b7..beab4ba5 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -234,15 +234,15 @@ export function retry( * 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 consider the found element as being - * "smaller than" the not-found element (i.e. found elements appeare before + * 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() argument is a function used to compare the array items - * against 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. + * 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 From 64c2f00d2acd07bbfb997982e1c8f90bfff797d2 Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Wed, 24 Apr 2019 18:45:19 +0100 Subject: [PATCH 05/22] Update balena ssh command to support local devices and multicontainer Change-type: major Signed-off-by: Cameron Diver --- doc/cli.markdown | 56 ++--- lib/actions/ssh.coffee | 149 ------------ lib/actions/ssh.ts | 502 ++++++++++++++++++++++++++++++++++++++++ lib/app-capitano.coffee | 2 +- lib/utils/device/ssh.ts | 118 ++++++++++ lib/utils/validation.ts | 19 ++ typings/bash/index.d.ts | 1 + 7 files changed, 670 insertions(+), 177 deletions(-) delete mode 100644 lib/actions/ssh.coffee create mode 100644 lib/actions/ssh.ts create mode 100644 lib/utils/device/ssh.ts create mode 100644 typings/bash/index.d.ts diff --git a/doc/cli.markdown b/doc/cli.markdown index 213e9c16..25f8a22f 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -143,7 +143,7 @@ If you come across any problems or would like to get in touch: - SSH - - [ssh [uuid]](#ssh-uuid) + - [ssh <applicationOrDevice> [serviceName]](#ssh-applicationordevice-servicename) - [tunnel <uuid>](#tunnel-uuid) - Notes @@ -981,45 +981,47 @@ 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 the information here: + 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> @@ -1430,7 +1432,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 <dockerPort> +#### --dockerPort, -p <dockerPort> Docker daemon TCP port number (hint: 2375 for balena devices) 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..43dda4ef --- /dev/null +++ b/lib/actions/ssh.ts @@ -0,0 +1,502 @@ +/* +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', + 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`, + permission: 'user', + 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 { 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: + // 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; + paramChecks.deviceChecked = true; + } catch (e) { + if (e instanceof BalenaApplicationNotFound) { + exitWithExpectedError( + `Could not find an application named ${ + params.applicationOrDevice + }`, + ); + } + throw e; + } + case SSHTarget.DEVICE: + // 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/app-capitano.coffee b/lib/app-capitano.coffee index 5697b9f6..c5e9d253 100644 --- a/lib/app-capitano.coffee +++ b/lib/app-capitano.coffee @@ -121,7 +121,7 @@ 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) diff --git a/lib/utils/device/ssh.ts b/lib/utils/device/ssh.ts new file mode 100644 index 00000000..912f4e59 --- /dev/null +++ b/lib/utils/device/ssh.ts @@ -0,0 +1,118 @@ +/* +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'); + + 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. + `); + } + + 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/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/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'; From 94c9e1310675a6660c91e644610f82ff2c5811ba Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Fri, 10 May 2019 13:24:59 +0100 Subject: [PATCH 06/22] Fix windows straight-to-container SSH Closes: #1211 Change-type: patch Signed-off-by: Cameron Diver --- lib/utils/device/ssh.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/utils/device/ssh.ts b/lib/utils/device/ssh.ts index 912f4e59..2621949b 100644 --- a/lib/utils/device/ssh.ts +++ b/lib/utils/device/ssh.ts @@ -32,6 +32,7 @@ export async function performLocalDeviceSSH( const { getSubShellCommand } = await import('../helpers'); const { exitWithExpectedError } = await import('../patterns'); const { stripIndent } = await import('common-tags'); + const os = await import('os'); let command = ''; @@ -98,10 +99,19 @@ export async function performLocalDeviceSSH( `); } - 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}`; + // 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 \ From ea89a6f22117d21ac61517e57fe1f366b8660101 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Mon, 13 May 2019 15:03:07 +0100 Subject: [PATCH 07/22] Update documentation markdown following v11-meta branch rebase Change-type: patch Signed-off-by: Paulo Castro --- doc/cli.markdown | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/cli.markdown b/doc/cli.markdown index 25f8a22f..d5ba6d73 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -1005,7 +1005,7 @@ Examples: 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 information here: +support) please check: https://github.com/balena-io/balena-cli/blob/master/INSTALL.md#additional-dependencies ### Options @@ -1415,7 +1415,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> @@ -1432,7 +1432,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) From 6e7f51758e9fcf0bb7d17d13401ed0fd91db6d51 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Sat, 25 May 2019 22:11:26 +0100 Subject: [PATCH 08/22] Add CONTRIBUTING.md and some guidance on commit messages and doc files. Change-type: patch Signed-off-by: Paulo Castro --- CONTRIBUTING.md | 65 ++++++++++++++++++++++++++++ README.md | 42 +++++------------- doc/cli.markdown | 21 ++++----- lib/actions-oclif/env/add.ts | 6 +-- lib/actions/environment-variables.ts | 2 +- 5 files changed, 92 insertions(+), 44 deletions(-) create mode 100644 CONTRIBUTING.md 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/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/doc/cli.markdown b/doc/cli.markdown index d5ba6d73..cc616a6e 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). @@ -635,7 +636,7 @@ device ## env add NAME [VALUE] -Add an enviroment or config variable to an application or device, as selected +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 @@ -659,7 +660,7 @@ environment or config variable name #### VALUE -variable value; if omitted, use value from CLI's enviroment +variable value; if omitted, use value from CLI's environment ### Options @@ -678,7 +679,7 @@ 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. diff --git a/lib/actions-oclif/env/add.ts b/lib/actions-oclif/env/add.ts index 00e1d96f..0a9a6a1d 100644 --- a/lib/actions-oclif/env/add.ts +++ b/lib/actions-oclif/env/add.ts @@ -34,9 +34,9 @@ interface ArgsDef { export default class EnvAddCmd extends Command { public static description = stripIndent` - Add an enviroment or config variable to an application or device. + Add an environment or config variable to an application or device. - Add an enviroment or config variable to an application or device, as selected + 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 @@ -62,7 +62,7 @@ export default class EnvAddCmd extends Command { name: 'value', required: false, description: - "variable value; if omitted, use value from CLI's enviroment", + "variable value; if omitted, use value from CLI's environment", }, ]; diff --git a/lib/actions/environment-variables.ts b/lib/actions/environment-variables.ts index 4bbcb810..5254e2ed 100644 --- a/lib/actions/environment-variables.ts +++ b/lib/actions/environment-variables.ts @@ -171,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. From c204dbd6cd509ee2c939d208b8d999d58740ea45 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Thu, 23 May 2019 01:29:54 +0100 Subject: [PATCH 09/22] Bump denymount version and delete redundant patch (chore task) Change-type: patch Signed-off-by: Paulo Castro --- lib/actions/local/configure.coffee | 9 ++++++- package.json | 3 +-- patches/denymount+2.2.0.patch | 38 ------------------------------ 3 files changed, 9 insertions(+), 41 deletions(-) delete mode 100644 patches/denymount+2.2.0.patch 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/package.json b/package.json index a2f3caaf..c7699040 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,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", @@ -201,7 +201,6 @@ }, "optionalDependencies": { "net-keepalive": "^1.2.1", - "removedrive": "^1.0.0", "windosu": "^0.2.0" } } 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 From dafbdd5f34b459bc91d27a8e8274dbd3cfba4fb9 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Thu, 23 May 2019 01:44:08 +0100 Subject: [PATCH 10/22] Add native installers for Windows and macOS Change-type: minor Signed-off-by: Paulo Castro --- .gitignore | 1 + .travis.yml | 16 ++- appveyor.yml | 12 ++- automation/build-bin.ts | 128 ++++++++++++++++------- automation/deploy-bin.ts | 151 ++++++++++++++++++++-------- automation/run.ts | 112 +++++++++++++++++++++ automation/tsconfig.json | 1 + bin/{balena => run} | 0 package.json | 29 +++--- patches/@oclif+dev-cli+1.22.0.patch | 146 +++++++++++++++++++++++++++ patches/qqjs++execa+0.10.0.patch | 49 +++++++++ 11 files changed, 544 insertions(+), 101 deletions(-) mode change 100755 => 100644 automation/build-bin.ts create mode 100644 automation/run.ts rename bin/{balena => run} (100%) create mode 100644 patches/@oclif+dev-cli+1.22.0.patch create mode 100644 patches/qqjs++execa+0.10.0.patch 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 bab23522..dc306149 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,15 +3,21 @@ os: - linux - osx node_js: - - "8" -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/appveyor.yml b/appveyor.yml index 92f6bf2f..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: 8 + - 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/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 99131ce4..83d916dc 100644 --- a/automation/tsconfig.json +++ b/automation/tsconfig.json @@ -8,6 +8,7 @@ "noUnusedParameters": true, "preserveConstEnums": true, "removeComments": true, + "resolveJsonModule": true, "sourceMap": true, "skipLibCheck": true, "typeRoots" : [ diff --git a/bin/balena b/bin/run similarity index 100% rename from bin/balena rename to bin/run diff --git a/package.json b/package.json index c7699040..6b13325b 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,13 @@ }, "preferGlobal": true, "files": [ - "bin/balena", + "bin/run", "build/", "doc/", "lib/" ], "bin": { - "balena": "./bin/balena" + "balena": "./bin/run" }, "pkg": { "scripts": [ @@ -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 --type-check -P automation/tsconfig.json automation/capitanodoc/index.ts > doc/cli.markdown", - "build:bin": "ts-node --type-check -P automation/tsconfig.json automation/build-bin.ts", - "release": "npm run build && ts-node --type-check -P automation/tsconfig.json automation/deploy-bin.ts", + "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": [ @@ -70,15 +71,15 @@ } }, "devDependencies": { - "@oclif/dev-cli": "^1.22.0", "@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/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", @@ -89,13 +90,15 @@ "@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", @@ -105,10 +108,10 @@ "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", + "shell-escape": "^0.2.0", "ts-node": "^8.1.0", "typescript": "3.4.3" }, @@ -165,7 +168,7 @@ "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", 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..d4572bb0 --- /dev/null +++ b/patches/@oclif+dev-cli+1.22.0.patch @@ -0,0 +1,146 @@ +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..4ed799c 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,21 @@ 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}"`); ++ await qq.cp(path.join(c.root, source), ws); ++ } ++ await qq.rm('bin/run.cmd'); ++ } + const updatePJSON = async () => { + qq.cd(c.workspace()); + const pjson = await qq.readJSON('package.json'); +@@ -124,7 +142,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..7df1815 100644 +--- a/node_modules/@oclif/dev-cli/lib/tarballs/node.js ++++ b/node_modules/@oclif/dev-cli/lib/tarballs/node.js +@@ -1,9 +1,11 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + const errors_1 = require("@oclif/errors"); ++const { isMSYS2 } = require('execa'); + const path = require("path"); + const qq = require("qqjs"); + const log_1 = require("../log"); ++const { fixPath } = require("../util"); + async function checkFor7Zip() { + try { + await qq.x('7z', { stdio: [0, null, 2] }); +@@ -40,7 +42,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 +53,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(`7z 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..7766d88 100644 +--- a/node_modules/@oclif/dev-cli/lib/util.js ++++ b/node_modules/@oclif/dev-cli/lib/util.js +@@ -1,5 +1,6 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); ++const { isCygwin, isMinGW, isMSYS2 } = require('execa'); + const _ = require("lodash"); + function castArray(input) { + if (input === undefined) +@@ -40,3 +41,16 @@ function sortBy(arr, fn) { + } + exports.sortBy = sortBy; + exports.template = (context) => (t) => _.template(t || '')(context); ++ ++function fixPath(badPath) { ++ // '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/qqjs++execa+0.10.0.patch b/patches/qqjs++execa+0.10.0.patch new file mode 100644 index 00000000..1024c3ae --- /dev/null +++ b/patches/qqjs++execa+0.10.0.patch @@ -0,0 +1,49 @@ +diff --git a/node_modules/qqjs/node_modules/execa/index.js b/node_modules/qqjs/node_modules/execa/index.js +index 06f3969..6251e17 100644 +--- a/node_modules/qqjs/node_modules/execa/index.js ++++ b/node_modules/qqjs/node_modules/execa/index.js +@@ -14,6 +14,21 @@ 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') ++ ++exports.isCygwin = isCygwin ++exports.isMinGW = isMinGW ++exports.isMSYS2 = isMSYS2 ++ ++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 +119,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; From 717c43f10b1438b014a6090cc2e98f471c90675e Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Thu, 23 May 2019 01:10:12 +0100 Subject: [PATCH 11/22] Update the CLI's installation instructions for executable installers Change-type: patch Signed-off-by: Paulo Castro --- INSTALL.md | 70 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 15 deletions(-) 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. From 1a1861bfcb68bce75ee9659b4c75a8a6ac36e8ff Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Thu, 16 May 2019 08:53:24 +0100 Subject: [PATCH 12/22] Remove or move most local namespaced commands Change-type: major Signed-off-by: Cameron Diver --- automation/capitanodoc/capitanodoc.ts | 4 - doc/cli.markdown | 328 +------------------------- lib/actions/{local => }/flash.ts | 10 +- lib/actions/index.coffee | 3 +- lib/actions/local/index.coffee | 6 - lib/actions/{local => }/scan.coffee | 14 +- lib/actions/ssh.ts | 1 + lib/actions/sync.ts | 44 ---- lib/app-capitano.coffee | 11 +- 9 files changed, 19 insertions(+), 402 deletions(-) rename lib/actions/{local => }/flash.ts (90%) rename lib/actions/{local => }/scan.coffee (90%) delete mode 100644 lib/actions/sync.ts diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index b599a9fc..c98b66d7 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -73,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/doc/cli.markdown b/doc/cli.markdown index cc616a6e..2254e86f 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -138,10 +138,6 @@ If you come across any problems or would like to get in touch: - [logs <uuidOrDevice>](#logs-uuidordevice) -- Sync - - - [sync [uuid]](#sync-uuid) - - SSH - [ssh <applicationOrDevice> [serviceName]](#ssh-applicationordevice-servicename) @@ -182,12 +178,6 @@ If you come across any problems or would like to get in touch: - Local - [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 @@ -893,93 +883,6 @@ Only show logs for a single service. This can be used in combination with --syst 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 <applicationOrDevice> [serviceName] @@ -1402,9 +1305,8 @@ id of the application to preload #### --commit, -c <hash> -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 . +the commit hash for a specific application release to preload, use "latest" to specify the latest release +(ignored if no appId is given) #### --splash-image, -s <splashImage.png> @@ -1580,232 +1482,6 @@ Examples: $ balena local configure /dev/sdc $ balena local configure path/to/image.img -## local flash <image> - -Use this command to flash a balenaOS image to a drive. - -Examples: - - $ balena local flash path/to/balenaos.img[.zip|.gz|.bz2|.xz] - $ balena local flash path/to/balenaos.img --drive /dev/disk2 - $ balena local flash path/to/balenaos.img --drive /dev/disk2 --yes - -### Options - -#### --yes, -y - -confirm non-interactively - -#### --drive, -d <drive> - -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] diff --git a/lib/actions/local/flash.ts b/lib/actions/flash.ts similarity index 90% rename from lib/actions/local/flash.ts rename to lib/actions/flash.ts index b11c406b..2bbced85 100644 --- a/lib/actions/local/flash.ts +++ b/lib/actions/flash.ts @@ -35,7 +35,7 @@ async function getDrive(options: { } drive = d; } else { - const { DriveList } = await import('../../utils/visuals/drive-list'); + const { DriveList } = await import('../utils/visuals/drive-list'); const driveList = new DriveList(scanner); drive = await driveList.run(); } @@ -47,16 +47,16 @@ export const flash: CommandDefinition< { image: string }, { drive: string; yes: boolean } > = { - signature: 'local flash ', + signature: 'flash ', description: 'Flash an image to a drive', help: stripIndent` Use this command to flash a balenaOS image to a drive. Examples: - $ balena local flash path/to/balenaos.img[.zip|.gz|.bz2|.xz] - $ balena local flash path/to/balenaos.img --drive /dev/disk2 - $ balena local flash path/to/balenaos.img --drive /dev/disk2 --yes + $ balena flash path/to/balenaos.img[.zip|.gz|.bz2|.xz] + $ balena flash path/to/balenaos.img --drive /dev/disk2 + $ balena flash path/to/balenaos.img --drive /dev/disk2 --yes `, options: [ { diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index 7ccad6ca..f16e27eb 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -25,12 +25,13 @@ module.exports = keys: require('./keys') logs: require('./logs') local: require('./local') + scan: require('./scan') + flash: require('./flash').flash 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/index.coffee b/lib/actions/local/index.coffee index 9557997e..eb6484b2 100644 --- a/lib/actions/local/index.coffee +++ b/lib/actions/local/index.coffee @@ -15,9 +15,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/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.ts b/lib/actions/ssh.ts index 43dda4ef..709428cf 100644 --- a/lib/actions/ssh.ts +++ b/lib/actions/ssh.ts @@ -249,6 +249,7 @@ export const ssh: CommandDefinition< > = { 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. 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/app-capitano.coffee b/lib/app-capitano.coffee index c5e9d253..d9848934 100644 --- a/lib/app-capitano.coffee +++ b/lib/app-capitano.coffee @@ -111,9 +111,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) @@ -125,12 +122,8 @@ 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.flash) +capitano.command(actions.scan) # ---------- Public utils ---------- capitano.command(actions.util.availableDrives) From 0ee73f5164473e47960eb4571a0434a4694abc7c Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Tue, 28 May 2019 10:39:30 +0100 Subject: [PATCH 13/22] Don't require a login for commands operating on local devices Change-type: patch Closes: #1195 Signed-off-by: Cameron Diver --- doc/cli.markdown | 5 +++-- lib/actions/logs.ts | 6 ++++-- lib/actions/ssh.ts | 4 +++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/doc/cli.markdown b/doc/cli.markdown index 2254e86f..949476ce 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -1305,8 +1305,9 @@ id of the application to preload #### --commit, -c <hash> -the commit hash for a specific application release to preload, use "latest" to specify the latest release -(ignored if no appId is given) +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 . #### --splash-image, -s <splashImage.png> diff --git a/lib/actions/logs.ts b/lib/actions/logs.ts index f553a189..28d21a3a 100644 --- a/lib/actions/logs.ts +++ b/lib/actions/logs.ts @@ -91,7 +91,6 @@ 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); @@ -101,7 +100,9 @@ export const logs: CommandDefinition< '../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(); @@ -153,6 +154,7 @@ export const logs: CommandDefinition< options.service, ); } else { + exitIfNotLoggedIn(); if (options.tail) { return balena.logs .subscribe(params.uuidOrDevice, { count: 100 }) diff --git a/lib/actions/ssh.ts b/lib/actions/ssh.ts index 709428cf..eb18a3c8 100644 --- a/lib/actions/ssh.ts +++ b/lib/actions/ssh.ts @@ -275,7 +275,6 @@ export const ssh: CommandDefinition< 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`, - permission: 'user', options: [ { signature: 'port', @@ -304,6 +303,7 @@ export const ssh: CommandDefinition< 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' @@ -366,6 +366,7 @@ export const ssh: CommandDefinition< 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 { @@ -395,6 +396,7 @@ export const ssh: CommandDefinition< 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 From b391c96e641ae5fe79d71336fa0201e6f91a73a8 Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Tue, 28 May 2019 16:56:39 +0100 Subject: [PATCH 14/22] Allow multiple services to be tailed with balena logs and push Also correctly type the input. Change-type: patch Signed-off-by: Cameron Diver --- doc/cli.markdown | 7 +++++-- lib/actions/logs.ts | 26 ++++++++++++++++++-------- lib/actions/push.ts | 36 ++++++++++++++++++++++-------------- lib/utils/device/deploy.ts | 11 ++++++++--- lib/utils/device/logs.ts | 16 ++++++++-------- 5 files changed, 61 insertions(+), 35 deletions(-) diff --git a/doc/cli.markdown b/doc/cli.markdown index 949476ce..eb81e222 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -865,6 +865,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,7 +878,8 @@ 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 @@ -1444,7 +1446,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 diff --git a/lib/actions/logs.ts b/lib/actions/logs.ts index 28d21a3a..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', }, @@ -95,6 +97,7 @@ export const logs: CommandDefinition< 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' @@ -107,6 +110,13 @@ export const logs: CommandDefinition< 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); @@ -117,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, ); } }; @@ -151,7 +161,7 @@ export const logs: CommandDefinition< logStream, logger, options.system || false, - options.service, + servicesToDisplay, ); } else { exitIfNotLoggedIn(); diff --git a/lib/actions/push.ts b/lib/actions/push.ts index 593148be..fc32a076 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; + live?: boolean; + detached?: boolean; + service?: string | string[]; + system?: boolean; + env?: string | string[]; } > = { signature: 'push ', @@ -215,7 +215,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 +244,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( @@ -312,8 +314,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 +334,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({ @@ -342,7 +350,7 @@ export const push: CommandDefinition< nocache: options.nocache || false, live: options.live || false, detached: options.detached || false, - service: options.service, + services: servicesToDisplay, system: options.system || false, env: typeof options.env === 'string' diff --git a/lib/utils/device/deploy.ts b/lib/utils/device/deploy.ts index c5059c88..f4c586d5 100644 --- a/lib/utils/device/deploy.ts +++ b/lib/utils/device/deploy.ts @@ -48,7 +48,7 @@ export interface DeviceDeployOptions { nocache: boolean; live: boolean; detached: boolean; - service?: string; + services?: string[]; system: boolean; env: string[]; } @@ -236,7 +236,7 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise { 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( @@ -255,7 +255,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; From 5da307f02e30558e484f9f5d7c774386b9c6ce87 Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Tue, 28 May 2019 17:31:18 +0100 Subject: [PATCH 15/22] Make the CommandDefinition option parameter a Partial This ensures that no code accidentally relies on them being present, and the types are then correct. Change-type: patch Signed-off-by: Cameron Diver --- automation/capitanodoc/markdown.ts | 3 +++ lib/actions/environment-variables.ts | 2 +- typings/capitano/index.d.ts | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/automation/capitanodoc/markdown.ts b/automation/capitanodoc/markdown.ts index 808e309d..974d7b09 100644 --- a/automation/capitanodoc/markdown.ts +++ b/automation/capitanodoc/markdown.ts @@ -29,6 +29,9 @@ function renderCapitanoCommand(command: CapitanoCommand): string[] { result.push('### Options'); for (const option of command.options!) { + if (option == null) { + throw new Error(`Undefined option in markdown generation!`); + } result.push( `#### ${utils.parseCapitanoOption(option)}`, option.description, diff --git a/lib/actions/environment-variables.ts b/lib/actions/environment-variables.ts index 5254e2ed..7ae16cd1 100644 --- a/lib/actions/environment-variables.ts +++ b/lib/actions/environment-variables.ts @@ -138,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() { diff --git a/typings/capitano/index.d.ts b/typings/capitano/index.d.ts index a2d65b44..2ce5efef 100644 --- a/typings/capitano/index.d.ts +++ b/typings/capitano/index.d.ts @@ -36,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 { From 6696b1b5f7f59240c092f648d06b9572c61e48e2 Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Wed, 29 May 2019 15:09:05 +0100 Subject: [PATCH 16/22] Make livepush the default when pushing to a local device Change-type: major Signed-off-by: Cameron Diver --- doc/cli.markdown | 23 +++++++++++------------ lib/actions/push.ts | 32 +++++++++++++++----------------- lib/utils/device/deploy.ts | 13 +++++++------ 3 files changed, 33 insertions(+), 35 deletions(-) diff --git a/doc/cli.markdown b/doc/cli.markdown index eb81e222..ab7f1b80 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -1375,9 +1375,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. @@ -1403,6 +1406,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 @@ -1429,16 +1433,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 diff --git a/lib/actions/push.ts b/lib/actions/push.ts index fc32a076..3157d82b 100644 --- a/lib/actions/push.ts +++ b/lib/actions/push.ts @@ -110,7 +110,7 @@ export const push: CommandDefinition< dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile) nocache?: boolean; 'registry-secrets'?: string; - live?: boolean; + nolive?: boolean; detached?: boolean; service?: string | string[]; system?: boolean; @@ -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', @@ -279,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) { @@ -348,7 +346,7 @@ export const push: CommandDefinition< dockerfilePath, registrySecrets, nocache: options.nocache || false, - live: options.live || false, + nolive: options.nolive || false, detached: options.detached || false, services: servicesToDisplay, system: options.system || false, diff --git a/lib/utils/device/deploy.ts b/lib/utils/device/deploy.ts index f4c586d5..d3f83daf 100644 --- a/lib/utils/device/deploy.ts +++ b/lib/utils/device/deploy.ts @@ -46,7 +46,7 @@ export interface DeviceDeployOptions { dockerfilePath?: string; registrySecrets: RegistrySecrets; nocache: boolean; - live: boolean; + nolive: boolean; detached: boolean; services?: string[]; system: boolean; @@ -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, From 01b454351b5b300a530e2cf50abdbc3930c78cec Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Thu, 30 May 2019 15:44:08 +0100 Subject: [PATCH 17/22] Fix SSH'ing into a device from application Change-type: patch Signed-off-by: Cameron Diver --- lib/actions/ssh.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/actions/ssh.ts b/lib/actions/ssh.ts index eb18a3c8..7540a5f9 100644 --- a/lib/actions/ssh.ts +++ b/lib/actions/ssh.ts @@ -384,7 +384,6 @@ export const ssh: CommandDefinition< // A little bit hacky, but it means we can fall // through to the next handling mechanism params.applicationOrDevice = choice.uuid; - paramChecks.deviceChecked = true; } catch (e) { if (e instanceof BalenaApplicationNotFound) { exitWithExpectedError( From 1e2e48b149dd876d2abe2c9cecd6de34e0b8fa27 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Thu, 30 May 2019 15:18:58 +0100 Subject: [PATCH 18/22] Revert 'balena flash' to 'balena local flash' Change-type: major Signed-off-by: Paulo Castro --- doc/cli.markdown | 21 +++++++++++++++++++++ lib/actions/index.coffee | 1 - lib/actions/{ => local}/flash.ts | 10 +++++----- lib/actions/local/index.coffee | 1 + lib/app-capitano.coffee | 2 +- 5 files changed, 28 insertions(+), 7 deletions(-) rename lib/actions/{ => local}/flash.ts (90%) diff --git a/doc/cli.markdown b/doc/cli.markdown index ab7f1b80..29de8a16 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -178,6 +178,7 @@ If you come across any problems or would like to get in touch: - Local - [local configure <target>](#local-configure-target) + - [local flash <image>](#local-flash-image) - Deploy @@ -1485,6 +1486,26 @@ Examples: $ balena local configure /dev/sdc $ balena local configure path/to/image.img +## local flash <image> + +Use this command to flash a balenaOS image to a drive. + +Examples: + + $ balena local flash path/to/balenaos.img[.zip|.gz|.bz2|.xz] + $ balena local flash path/to/balenaos.img --drive /dev/disk2 + $ balena local flash path/to/balenaos.img --drive /dev/disk2 --yes + +### Options + +#### --yes, -y + +confirm non-interactively + +#### --drive, -d <drive> + +drive + # Deploy ## build [source] diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index f16e27eb..b3b3ffcf 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -26,7 +26,6 @@ module.exports = logs: require('./logs') local: require('./local') scan: require('./scan') - flash: require('./flash').flash notes: require('./notes') help: require('./help') os: require('./os') diff --git a/lib/actions/flash.ts b/lib/actions/local/flash.ts similarity index 90% rename from lib/actions/flash.ts rename to lib/actions/local/flash.ts index 2bbced85..b11c406b 100644 --- a/lib/actions/flash.ts +++ b/lib/actions/local/flash.ts @@ -35,7 +35,7 @@ async function getDrive(options: { } drive = d; } else { - const { DriveList } = await import('../utils/visuals/drive-list'); + const { DriveList } = await import('../../utils/visuals/drive-list'); const driveList = new DriveList(scanner); drive = await driveList.run(); } @@ -47,16 +47,16 @@ export const flash: CommandDefinition< { image: string }, { drive: string; yes: boolean } > = { - signature: 'flash ', + signature: 'local flash ', description: 'Flash an image to a drive', help: stripIndent` Use this command to flash a balenaOS image to a drive. Examples: - $ balena flash path/to/balenaos.img[.zip|.gz|.bz2|.xz] - $ balena flash path/to/balenaos.img --drive /dev/disk2 - $ balena flash path/to/balenaos.img --drive /dev/disk2 --yes + $ balena local flash path/to/balenaos.img[.zip|.gz|.bz2|.xz] + $ balena local flash path/to/balenaos.img --drive /dev/disk2 + $ balena local flash path/to/balenaos.img --drive /dev/disk2 --yes `, options: [ { diff --git a/lib/actions/local/index.coffee b/lib/actions/local/index.coffee index eb6484b2..e089ffaa 100644 --- a/lib/actions/local/index.coffee +++ b/lib/actions/local/index.coffee @@ -15,3 +15,4 @@ limitations under the License. ### exports.configure = require('./configure') +exports.flash = require('./flash').flash diff --git a/lib/app-capitano.coffee b/lib/app-capitano.coffee index d9848934..4d552948 100644 --- a/lib/app-capitano.coffee +++ b/lib/app-capitano.coffee @@ -122,7 +122,7 @@ capitano.command(actions.ssh.ssh) # ---------- Local balenaOS Module ---------- capitano.command(actions.local.configure) -capitano.command(actions.flash) +capitano.command(actions.local.flash) capitano.command(actions.scan) # ---------- Public utils ---------- From 751749325fed0106ebe84f02ef87aae3f40e74fa Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Thu, 30 May 2019 15:19:47 +0100 Subject: [PATCH 19/22] Add warning notices for replaced 'local' commands in v11 Change-type: patch Signed-off-by: Paulo Castro --- lib/app.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/lib/app.ts b/lib/app.ts index 78c7864b..6353aca8 100644 --- a/lib/app.ts +++ b/lib/app.ts @@ -14,6 +14,9 @@ * 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. @@ -30,12 +33,16 @@ function routeCliFramework(argv: string[]): void { 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) { @@ -58,6 +65,53 @@ function routeCliFramework(argv: string[]): void { } } +/** + * + * @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) From b5c4348de1bd72bb7b01f532573807a8e7dfdd95 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Mon, 3 Jun 2019 16:02:32 +0100 Subject: [PATCH 20/22] balena CI integration: Patch @oclif/dev-cli to install 7zip on demand Change-type: patch Signed-off-by: Paulo Castro --- patches/@oclif+dev-cli+1.22.0.patch | 110 +++++++++++++++++++++++----- patches/qqjs++execa+0.10.0.patch | 26 ++++--- patches/qqjs+0.3.10.patch | 16 ++++ 3 files changed, 124 insertions(+), 28 deletions(-) create mode 100644 patches/qqjs+0.3.10.patch diff --git a/patches/@oclif+dev-cli+1.22.0.patch b/patches/@oclif+dev-cli+1.22.0.patch index d4572bb0..89ae3bb6 100644 --- a/patches/@oclif+dev-cli+1.22.0.patch +++ b/patches/@oclif+dev-cli+1.22.0.patch @@ -36,7 +36,7 @@ index a9d4276..75c2f8b 100644 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..4ed799c 100644 +index 3e613e0..393516c 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) => { @@ -49,7 +49,7 @@ index 3e613e0..4ed799c 100644 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,21 @@ async function build(c, options = {}) { +@@ -34,6 +37,28 @@ async function build(c, options = {}) { await qq.mv(f, '.'); await qq.rm('package', tarball, 'bin/run.cmd'); }; @@ -64,14 +64,34 @@ index 3e613e0..4ed799c 100644 + ]; + for (const source of sources) { + console.log(`cp "${source}" -> "${ws}"`); -+ await qq.cp(path.join(c.root, 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; ++ } ++ } + } + await qq.rm('bin/run.cmd'); + } const updatePJSON = async () => { qq.cd(c.workspace()); const pjson = await qq.readJSON('package.json'); -@@ -124,7 +142,8 @@ async function build(c, options = {}) { +@@ -55,7 +80,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 +153,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()}`); @@ -82,22 +102,76 @@ index 3e613e0..4ed799c 100644 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..7df1815 100644 +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,9 +1,11 @@ +@@ -1,19 +1,45 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const errors_1 = require("@oclif/errors"); -+const { isMSYS2 } = require('execa'); ++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"); - async function checkFor7Zip() { - try { - await qq.x('7z', { stdio: [0, null, 2] }); -@@ -40,7 +42,8 @@ async function fetchNodeBinary({ nodeVersion, output, platform, arch, tmp }) { ++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); @@ -107,32 +181,34 @@ index 343eb00..7df1815 100644 }; const extract = async () => { log_1.log(`extracting ${nodeBase}`); -@@ -50,7 +53,7 @@ async function fetchNodeBinary({ nodeVersion, output, platform, arch, tmp }) { +@@ -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(`7z x -bd -y ${fixPath(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..7766d88 100644 +index 17368b4..9d3fcf9 100644 --- a/node_modules/@oclif/dev-cli/lib/util.js +++ b/node_modules/@oclif/dev-cli/lib/util.js -@@ -1,5 +1,6 @@ +@@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -+const { isCygwin, isMinGW, isMSYS2 } = require('execa'); const _ = require("lodash"); ++const { isCygwin, isMinGW, isMSYS2 } = require('qqjs'); function castArray(input) { if (input === undefined) -@@ -40,3 +41,16 @@ function sortBy(arr, fn) { + 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) { diff --git a/patches/qqjs++execa+0.10.0.patch b/patches/qqjs++execa+0.10.0.patch index 1024c3ae..226ea0f1 100644 --- a/patches/qqjs++execa+0.10.0.patch +++ b/patches/qqjs++execa+0.10.0.patch @@ -1,30 +1,26 @@ diff --git a/node_modules/qqjs/node_modules/execa/index.js b/node_modules/qqjs/node_modules/execa/index.js -index 06f3969..6251e17 100644 +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,21 @@ const stdio = require('./lib/stdio'); +@@ -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') -+ -+exports.isCygwin = isCygwin -+exports.isMinGW = isMinGW -+exports.isMSYS2 = isMSYS2 ++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}")`) ++}" environment (MSYSTEM="${process.env.MSYSTEM}")`); + function handleArgs(cmd, args, opts) { let parsed; -@@ -104,13 +119,21 @@ function handleShell(fn, cmd, opts) { +@@ -104,13 +115,21 @@ function handleShell(fn, cmd, opts) { opts = Object.assign({}, opts); @@ -47,3 +43,11 @@ index 06f3969..6251e17 100644 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 + */ From 04223dbc58ec1f6e83e130ac0e1ac2641d6e05e8 Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Mon, 3 Jun 2019 16:01:36 +0100 Subject: [PATCH 21/22] Revert bin/balena (previously renamed bin/run for oclif compatibility) Change-type: major Signed-off-by: Paulo Castro --- bin/{run => balena} | 0 package.json | 2 +- patches/@oclif+dev-cli+1.22.0.patch | 10 ++++++---- 3 files changed, 7 insertions(+), 5 deletions(-) rename bin/{run => balena} (100%) diff --git a/bin/run b/bin/balena similarity index 100% rename from bin/run rename to bin/balena diff --git a/package.json b/package.json index 6b13325b..679ce057 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "lib/" ], "bin": { - "balena": "./bin/run" + "balena": "./bin/balena" }, "pkg": { "scripts": [ diff --git a/patches/@oclif+dev-cli+1.22.0.patch b/patches/@oclif+dev-cli+1.22.0.patch index 89ae3bb6..dc71f7ae 100644 --- a/patches/@oclif+dev-cli+1.22.0.patch +++ b/patches/@oclif+dev-cli+1.22.0.patch @@ -36,7 +36,7 @@ index a9d4276..75c2f8b 100644 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..393516c 100644 +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) => { @@ -49,7 +49,7 @@ index 3e613e0..393516c 100644 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,28 @@ async function build(c, options = {}) { +@@ -34,6 +37,30 @@ async function build(c, options = {}) { await qq.mv(f, '.'); await qq.rm('package', tarball, 'bin/run.cmd'); }; @@ -73,12 +73,14 @@ index 3e613e0..393516c 100644 + } + } + } ++ // 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 +80,11 @@ async function build(c, options = {}) { +@@ -55,7 +82,11 @@ async function build(c, options = {}) { if (!await qq.exists(lockpath)) { lockpath = qq.join(c.root, 'npm-shrinkwrap.json'); } @@ -91,7 +93,7 @@ index 3e613e0..393516c 100644 await qq.x('npm install --production'); } }; -@@ -124,7 +153,8 @@ async function build(c, options = {}) { +@@ -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()}`); From 0bbe376e414985ab8e87ebe8a48124871527245a Mon Sep 17 00:00:00 2001 From: Paulo Castro Date: Tue, 4 Jun 2019 16:31:32 +0100 Subject: [PATCH 22/22] Remove 'signup' command Change-type: major Signed-off-by: Paulo Castro --- balena-completion.bash | 4 ++-- doc/cli.markdown | 16 -------------- lib/actions/auth.coffee | 46 +++-------------------------------------- lib/app-capitano.coffee | 1 - 4 files changed, 5 insertions(+), 62 deletions(-) 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/doc/cli.markdown b/doc/cli.markdown index 29de8a16..6503fc02 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -85,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 @@ -330,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. 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/app-capitano.coffee b/lib/app-capitano.coffee index 4d552948..e582a0a4 100644 --- a/lib/app-capitano.coffee +++ b/lib/app-capitano.coffee @@ -52,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 ----------