Refactor initialization code (delete app-oclif.ts and app-common.ts)

Change-type: patch
This commit is contained in:
Paulo Castro 2020-10-10 00:35:58 +01:00
parent cf376316bc
commit d0e4fa0e59
7 changed files with 213 additions and 227 deletions

View File

@ -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);
}
}

View File

@ -1,6 +1,6 @@
/** /**
* @license * @license
* Copyright 2019 Balena Ltd. * Copyright 2019-2020 Balena Ltd.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -15,73 +15,138 @@
* limitations under the License. * 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 * Sentry.io setup
* call this function. * @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<string>('apiUrl'),
dataDirectory: settings.get<string>('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( export async function run(
cliArgs = process.argv, cliArgs = process.argv,
options: import('./preparser').AppOptions = {}, 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 { try {
const mod: any = await import(replacedModPath); const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap');
if (funcName) { normalizeEnvVars();
await mod[funcName](...args);
// 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) { } catch (err) {
console.error(`Error executing pkgExec "${modFunc}" [${args.join()}]`); await (await import('./errors')).handleError(err);
console.error(err); } finally {
// Windows fix: reading from stdin prevents the process from exiting
process.stdin.pause();
} }
} }

View File

@ -1,6 +1,6 @@
/** /**
* @license * @license
* Copyright 2020 Balena Ltd. * Copyright 2019-2020 Balena Ltd.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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. * 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<T>(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<T>(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 { export function parseBoolEnvVar(varName: string): boolean {
return !['0', 'no', 'false', '', undefined].includes( return !['0', 'no', 'false', '', undefined].includes(
process.env[varName]?.toLowerCase(), process.env[varName]?.toLowerCase(),
@ -31,3 +56,48 @@ export function parseBoolEnvVar(varName: string): boolean {
export function normalizeEnvVar(varName: string) { export function normalizeEnvVar(varName: string) {
process.env[varName] = parseBoolEnvVar(varName) ? '1' : ''; 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);
}
}

View File

@ -15,6 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { Main } from '@oclif/command';
import type * as Config from '@oclif/config'; 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 <name> [value]' */ /** Convert e.g. 'env add NAME [VALUE]' to 'env add <name> [value]' */
export function capitanoizeOclifUsage( export function capitanoizeOclifUsage(
oclifUsage: string | string[] | undefined, oclifUsage: string | string[] | undefined,

View File

@ -15,71 +15,11 @@
* limitations under the License. * 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<T>(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<T>(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'; import type { Options as GlobalTunnelNgConfig } from 'global-tunnel-ng';
export type { GlobalTunnelNgConfig }; export type { GlobalTunnelNgConfig };
import { CliSettings } from './bootstrap';
type ProxyConfig = string | GlobalTunnelNgConfig; 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, * '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. * 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 // `global-tunnel-ng` accepts lowercase variables with higher precedence
// than uppercase variables, but `global-agent` does not accept lowercase. // than uppercase variables, but `global-agent` does not accept lowercase.
// Set uppercase versions for backwards compatibility. // Set uppercase versions for backwards compatibility.
@ -207,42 +147,3 @@ export function makeUrlFromTunnelNgConfig(cfg: GlobalTunnelNgConfig): string {
} }
return url; 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<string>('apiUrl'),
dataDirectory: settings.get<string>('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();
}

View File

@ -23,7 +23,6 @@ import * as nock from 'nock';
import * as path from 'path'; import * as path from 'path';
import * as balenaCLI from '../build/app'; import * as balenaCLI from '../build/app';
import { setupSentry } from '../build/app-common';
const balenaExe = process.platform === 'win32' ? 'balena.exe' : 'balena'; const balenaExe = process.platform === 'win32' ? 'balena.exe' : 'balena';
const standalonePath = path.resolve(__dirname, '..', 'build-bin', balenaExe); const standalonePath = path.resolve(__dirname, '..', 'build-bin', balenaExe);
@ -287,7 +286,7 @@ export function fillTemplateArray(
export async function switchSentry( export async function switchSentry(
enabled: boolean | undefined, enabled: boolean | undefined,
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
const sentryOpts = (await setupSentry()).getClient()?.getOptions(); const sentryOpts = (await balenaCLI.setupSentry()).getClient()?.getOptions();
if (sentryOpts) { if (sentryOpts) {
const sentryStatus = sentryOpts.enabled; const sentryStatus = sentryOpts.enabled;
sentryOpts.enabled = enabled; sentryOpts.enabled = enabled;

View File

@ -20,7 +20,7 @@ import { expect } from 'chai';
import { import {
GlobalTunnelNgConfig, GlobalTunnelNgConfig,
makeUrlFromTunnelNgConfig, makeUrlFromTunnelNgConfig,
} from '../build/app-common'; } from '../build/utils/proxy';
describe('makeUrlFromTunnelNgConfig() function', function () { describe('makeUrlFromTunnelNgConfig() function', function () {
it('should return a URL given a GlobalTunnelNgConfig object', () => { it('should return a URL given a GlobalTunnelNgConfig object', () => {