diff --git a/lib/app-oclif.ts b/lib/app-oclif.ts deleted file mode 100644 index f824b83a..00000000 --- a/lib/app-oclif.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @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 { Main } from '@oclif/command'; - -import { trackPromise } from './hooks/prerun/track'; - -class CustomMain extends Main { - protected _helpOverride(): boolean { - // Disable oclif's default handler for the 'version' command - if (['-v', '--version', 'version'].includes(this.argv[0])) { - return false; - } else { - return super._helpOverride(); - } - } -} - -import type { AppOptions } from './preparser'; - -/** - * oclif CLI entrypoint - */ -export async function run(command: string[], options: AppOptions) { - const runPromise = CustomMain.run(command).then( - () => { - if (!options.noFlush) { - return require('@oclif/command/flush'); - } - }, - (error) => { - // oclif sometimes exits with ExitError code 0 (not an error) - // (Avoid `error instanceof ExitError` here for the reasons explained - // in the CONTRIBUTING.md file regarding the `instanceof` operator.) - if (error.oclif?.exit === 0) { - return; - } else { - throw error; - } - }, - ); - try { - await Promise.all([trackPromise, runPromise]); - } catch (err) { - await (await import('./errors')).handleError(err); - } -} diff --git a/lib/app.ts b/lib/app.ts index 22f33578..a5897bb2 100644 --- a/lib/app.ts +++ b/lib/app.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Balena Ltd. + * Copyright 2019-2020 Balena Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,73 +15,138 @@ * limitations under the License. */ +import * as packageJSON from '../package.json'; +import { CliSettings } from './utils/bootstrap'; +import { onceAsync, stripIndent } from './utils/lazy'; + /** - * CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which - * call this function. + * Sentry.io setup + * @see https://docs.sentry.io/error-reporting/quickstart/?platform=node */ +export const setupSentry = onceAsync(async () => { + const config = await import('./config'); + const Sentry = await import('@sentry/node'); + Sentry.init({ + dsn: config.sentryDsn, + release: packageJSON.version, + }); + Sentry.configureScope((scope) => { + scope.setExtras({ + is_pkg: !!(process as any).pkg, + node_version: process.version, + platform: process.platform, + }); + }); + return Sentry.getCurrentHub(); +}); + +async function checkNodeVersion() { + const validNodeVersions = packageJSON.engines.node; + if (!(await import('semver')).satisfies(process.version, validNodeVersions)) { + console.warn(stripIndent` + ------------------------------------------------------------------------------ + Warning: Node version "${process.version}" does not match required versions "${validNodeVersions}". + This may cause unexpected behavior. To upgrade Node, visit: + https://nodejs.org/en/download/ + ------------------------------------------------------------------------------ + `); + } +} + +/** Setup balena-sdk options that are shared with imported packages */ +function setupBalenaSdkSharedOptions(settings: CliSettings) { + const BalenaSdk = require('balena-sdk') as typeof import('balena-sdk'); + BalenaSdk.setSharedOptions({ + apiUrl: settings.get('apiUrl'), + dataDirectory: settings.get('dataDirectory'), + }); +} + +/** + * Addresses the console warning: + * (node:49500) MaxListenersExceededWarning: Possible EventEmitter memory + * leak detected. 11 error listeners added. Use emitter.setMaxListeners() to + * increase limit + */ +export function setMaxListeners(maxListeners: number) { + require('events').EventEmitter.defaultMaxListeners = maxListeners; +} + +/** Selected CLI initialization steps */ +async function init() { + if (process.env.BALENARC_NO_SENTRY) { + console.error(`WARN: disabling Sentry.io error reporting`); + } else { + await setupSentry(); + } + checkNodeVersion(); + + const settings = new CliSettings(); + + // Proxy setup should be done early on, before loading balena-sdk + await (await import('./utils/proxy')).setupGlobalHttpProxy(settings); + + setupBalenaSdkSharedOptions(settings); + + // check for CLI updates once a day + (await import('./utils/update')).notify(); +} + +/** Execute the oclif parser and the CLI command. */ +async function oclifRun( + command: string[], + options: import('./preparser').AppOptions, +) { + const { CustomMain } = await import('./utils/oclif-utils'); + const runPromise = CustomMain.run(command).then( + () => { + if (!options.noFlush) { + return require('@oclif/command/flush'); + } + }, + (error) => { + // oclif sometimes exits with ExitError code 0 (not an error) + // (Avoid `error instanceof ExitError` here for the reasons explained + // in the CONTRIBUTING.md file regarding the `instanceof` operator.) + if (error.oclif?.exit === 0) { + return; + } else { + throw error; + } + }, + ); + const { trackPromise } = await import('./hooks/prerun/track'); + await Promise.all([trackPromise, runPromise]); +} + +/** CLI entrypoint. Called by the `bin/balena` and `bin/balena-dev` scripts. */ export async function run( cliArgs = process.argv, options: import('./preparser').AppOptions = {}, ) { - (await import('./utils/bootstrap')).normalizeEnvVar('DEBUG'); - - // The 'pkgExec' special/internal command provides a Node.js interpreter - // for use of the standalone zip package. See pkgExec function. - if (cliArgs.length > 3 && cliArgs[2] === 'pkgExec') { - return pkgExec(cliArgs[3], cliArgs.slice(4)); - } - - const { globalInit } = await import('./app-common'); - const { preparseArgs, checkDeletedCommand } = await import('./preparser'); - - // 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. - await globalInit(); - - // Look for commands that have been removed and if so, exit with a notice - checkDeletedCommand(cliArgs.slice(2)); - - const args = await preparseArgs(cliArgs); - await (await import('./app-oclif')).run(args, options); - - // Windows fix: reading from stdin prevents the process from exiting - process.stdin.pause(); -} - -/** - * Implements the 'pkgExec' command, used as a way to provide a Node.js - * interpreter for child_process.spawn()-like operations when the CLI is - * executing as a standalone zip package (built-in Node interpreter) and - * the system may not have a separate Node.js installation. A present use - * case is a patched version of the 'windosu' package that requires a - * Node.js interpreter to spawn a privileged child process. - * - * @param modFunc Path to a JS module that will be executed via require(). - * The modFunc argument may optionally contain a function name separated - * by '::', for example '::main' in: - * 'C:\\snapshot\\balena-cli\\node_modules\\windosu\\lib\\pipe.js::main' - * in which case that function is executed in the require'd module. - * @param args Optional arguments to passed through process.argv and as - * arguments to the function specified via modFunc. - */ -async function pkgExec(modFunc: string, args: string[]) { - const [modPath, funcName] = modFunc.split('::'); - let replacedModPath = modPath; - const match = modPath - .replace(/\\/g, '/') - .match(/\/snapshot\/balena-cli\/(.+)/); - if (match) { - replacedModPath = `../${match[1]}`; - } - process.argv = [process.argv[0], process.argv[1], ...args]; try { - const mod: any = await import(replacedModPath); - if (funcName) { - await mod[funcName](...args); + const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap'); + normalizeEnvVars(); + + // The 'pkgExec' special/internal command provides a Node.js interpreter + // for use of the standalone zip package. See pkgExec function. + if (cliArgs.length > 3 && cliArgs[2] === 'pkgExec') { + return pkgExec(cliArgs[3], cliArgs.slice(4)); } + + await init(); + + const { preparseArgs, checkDeletedCommand } = await import('./preparser'); + + // Look for commands that have been removed and if so, exit with a notice + checkDeletedCommand(cliArgs.slice(2)); + + const args = await preparseArgs(cliArgs); + await oclifRun(args, options); } catch (err) { - console.error(`Error executing pkgExec "${modFunc}" [${args.join()}]`); - console.error(err); + await (await import('./errors')).handleError(err); + } finally { + // Windows fix: reading from stdin prevents the process from exiting + process.stdin.pause(); } } diff --git a/lib/utils/bootstrap.ts b/lib/utils/bootstrap.ts index baaa3c4a..f05106fe 100644 --- a/lib/utils/bootstrap.ts +++ b/lib/utils/bootstrap.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2020 Balena Ltd. + * Copyright 2019-2020 Balena Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,31 @@ * like Sentry error reporting, preparser, oclif parser and the like. */ +export class CliSettings { + public readonly settings: any; + constructor() { + this.settings = require('balena-settings-client') as typeof import('balena-settings-client'); + } + + public get(name: string): T { + return this.settings.get(name); + } + + /** + * Like settings.get(), but return `undefined` instead of throwing an + * error if the setting is not found / not defined. + */ + public getCatch(name: string): T | undefined { + try { + return this.settings.get(name); + } catch (err) { + if (!/Setting not found/i.test(err.message)) { + throw err; + } + } + } +} + export function parseBoolEnvVar(varName: string): boolean { return !['0', 'no', 'false', '', undefined].includes( process.env[varName]?.toLowerCase(), @@ -31,3 +56,48 @@ export function parseBoolEnvVar(varName: string): boolean { export function normalizeEnvVar(varName: string) { process.env[varName] = parseBoolEnvVar(varName) ? '1' : ''; } + +const bootstrapVars = ['DEBUG', 'BALENARC_NO_SENTRY']; + +export function normalizeEnvVars(varNames: string[] = bootstrapVars) { + for (const varName of varNames) { + normalizeEnvVar(varName); + } +} + +/** + * Implements the 'pkgExec' command, used as a way to provide a Node.js + * interpreter for child_process.spawn()-like operations when the CLI is + * executing as a standalone zip package (built-in Node interpreter) and + * the system may not have a separate Node.js installation. A present use + * case is a patched version of the 'windosu' package that requires a + * Node.js interpreter to spawn a privileged child process. + * + * @param modFunc Path to a JS module that will be executed via require(). + * The modFunc argument may optionally contain a function name separated + * by '::', for example '::main' in: + * 'C:\\snapshot\\balena-cli\\node_modules\\windosu\\lib\\pipe.js::main' + * in which case that function is executed in the require'd module. + * @param args Optional arguments to passed through process.argv and as + * arguments to the function specified via modFunc. + */ +export async function pkgExec(modFunc: string, args: string[]) { + const [modPath, funcName] = modFunc.split('::'); + let replacedModPath = modPath; + const match = modPath + .replace(/\\/g, '/') + .match(/\/snapshot\/balena-cli\/(.+)/); + if (match) { + replacedModPath = `../${match[1]}`; + } + process.argv = [process.argv[0], process.argv[1], ...args]; + try { + const mod: any = await import(replacedModPath); + if (funcName) { + await mod[funcName](...args); + } + } catch (err) { + console.error(`Error executing pkgExec "${modFunc}" [${args.join()}]`); + console.error(err); + } +} diff --git a/lib/utils/oclif-utils.ts b/lib/utils/oclif-utils.ts index aeee4650..de1f9717 100644 --- a/lib/utils/oclif-utils.ts +++ b/lib/utils/oclif-utils.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { Main } from '@oclif/command'; import type * as Config from '@oclif/config'; /** @@ -48,6 +49,17 @@ export class CommandHelp { } } +export class CustomMain extends Main { + protected _helpOverride(): boolean { + // Disable oclif's default handler for the 'version' command + if (['-v', '--version', 'version'].includes(this.argv[0])) { + return false; + } else { + return super._helpOverride(); + } + } +} + /** Convert e.g. 'env add NAME [VALUE]' to 'env add [value]' */ export function capitanoizeOclifUsage( oclifUsage: string | string[] | undefined, diff --git a/lib/app-common.ts b/lib/utils/proxy.ts similarity index 63% rename from lib/app-common.ts rename to lib/utils/proxy.ts index 054498ea..ebc7aa72 100644 --- a/lib/app-common.ts +++ b/lib/utils/proxy.ts @@ -15,71 +15,11 @@ * limitations under the License. */ -import * as packageJSON from '../package.json'; -import { onceAsync, stripIndent } from './utils/lazy'; - -class CliSettings { - public readonly settings: any; - constructor() { - this.settings = require('balena-settings-client') as typeof import('balena-settings-client'); - } - - public get(name: string): T { - return this.settings.get(name); - } - - /** - * Like settings.get(), but return `undefined` instead of throwing an - * error if the setting is not found / not defined. - */ - public getCatch(name: string): T | undefined { - try { - return this.settings.get(name); - } catch (err) { - if (!/Setting not found/i.test(err.message)) { - throw err; - } - } - } -} - -/** - * Sentry.io setup - * @see https://docs.sentry.io/error-reporting/quickstart/?platform=node - */ -export const setupSentry = onceAsync(async () => { - const config = await import('./config'); - const Sentry = await import('@sentry/node'); - Sentry.init({ - dsn: config.sentryDsn, - release: packageJSON.version, - }); - Sentry.configureScope((scope) => { - scope.setExtras({ - is_pkg: !!(process as any).pkg, - node_version: process.version, - platform: process.platform, - }); - }); - return Sentry.getCurrentHub(); -}); - -async function checkNodeVersion() { - const validNodeVersions = packageJSON.engines.node; - if (!(await import('semver')).satisfies(process.version, validNodeVersions)) { - console.warn(stripIndent` - ------------------------------------------------------------------------------ - Warning: Node version "${process.version}" does not match required versions "${validNodeVersions}". - This may cause unexpected behavior. To upgrade Node, visit: - https://nodejs.org/en/download/ - ------------------------------------------------------------------------------ - `); - } -} - import type { Options as GlobalTunnelNgConfig } from 'global-tunnel-ng'; export type { GlobalTunnelNgConfig }; +import { CliSettings } from './bootstrap'; + type ProxyConfig = string | GlobalTunnelNgConfig; /** @@ -104,7 +44,7 @@ type ProxyConfig = string | GlobalTunnelNgConfig; * 'localhost' and '127.0.0.1' are always excluded. If NO_PROXY is not defined, * default exclusion patterns are added for all private IPv4 address ranges. */ -async function setupGlobalHttpProxy(settings: CliSettings) { +export async function setupGlobalHttpProxy(settings: CliSettings) { // `global-tunnel-ng` accepts lowercase variables with higher precedence // than uppercase variables, but `global-agent` does not accept lowercase. // Set uppercase versions for backwards compatibility. @@ -207,42 +147,3 @@ export function makeUrlFromTunnelNgConfig(cfg: GlobalTunnelNgConfig): string { } return url; } - -function setupBalenaSdkSharedOptions(settings: CliSettings) { - // 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') as typeof import('balena-sdk'); - BalenaSdk.setSharedOptions({ - apiUrl: settings.get('apiUrl'), - dataDirectory: settings.get('dataDirectory'), - }); -} - -/** - * Addresses the console warning: - * (node:49500) MaxListenersExceededWarning: Possible EventEmitter memory - * leak detected. 11 error listeners added. Use emitter.setMaxListeners() to - * increase limit - */ -export function setMaxListeners(maxListeners: number) { - require('events').EventEmitter.defaultMaxListeners = maxListeners; -} - -export async function globalInit() { - (await import('./utils/bootstrap')).normalizeEnvVar('BALENARC_NO_SENTRY'); - if (process.env.BALENARC_NO_SENTRY) { - console.error(`WARN: disabling Sentry.io error reporting`); - } else { - await setupSentry(); - } - checkNodeVersion(); - - const settings = new CliSettings(); - - // Proxy setup should be done early on, before loading balena-sdk - await setupGlobalHttpProxy(settings); - setupBalenaSdkSharedOptions(settings); - - // check for CLI updates once a day - (await import('./utils/update')).notify(); -} diff --git a/tests/helpers.ts b/tests/helpers.ts index 4fbad73f..26830a39 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -23,7 +23,6 @@ import * as nock from 'nock'; import * as path from 'path'; import * as balenaCLI from '../build/app'; -import { setupSentry } from '../build/app-common'; const balenaExe = process.platform === 'win32' ? 'balena.exe' : 'balena'; const standalonePath = path.resolve(__dirname, '..', 'build-bin', balenaExe); @@ -287,7 +286,7 @@ export function fillTemplateArray( export async function switchSentry( enabled: boolean | undefined, ): Promise { - const sentryOpts = (await setupSentry()).getClient()?.getOptions(); + const sentryOpts = (await balenaCLI.setupSentry()).getClient()?.getOptions(); if (sentryOpts) { const sentryStatus = sentryOpts.enabled; sentryOpts.enabled = enabled; diff --git a/tests/app-common.spec.ts b/tests/proxy.spec.ts similarity index 97% rename from tests/app-common.spec.ts rename to tests/proxy.spec.ts index 25b73590..aa1d41d6 100644 --- a/tests/app-common.spec.ts +++ b/tests/proxy.spec.ts @@ -20,7 +20,7 @@ import { expect } from 'chai'; import { GlobalTunnelNgConfig, makeUrlFromTunnelNgConfig, -} from '../build/app-common'; +} from '../build/utils/proxy'; describe('makeUrlFromTunnelNgConfig() function', function () { it('should return a URL given a GlobalTunnelNgConfig object', () => {