Merge pull request #2309 from balena-io/warn-deprecation-policy

Add deprecation policy checker and --unsupported global flag
This commit is contained in:
bulldozer-balena[bot] 2021-08-19 23:13:25 +00:00 committed by GitHub
commit cb9b6be24b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1105 additions and 269 deletions

View File

@ -156,14 +156,18 @@ of major, minor and patch version releases.
The latest release of a major version of the balena CLI will remain compatible with The latest release of a major version of the balena CLI will remain compatible with
the balenaCloud backend services for at least one year from the date when the the balenaCloud backend services for at least one year from the date when the
following major version is released. For example, balena CLI v10.17.5, as the following major version is released. For example, balena CLI v11.36.0, as the
latest v10 release, would remain compatible with the balenaCloud backend for one latest v11 release, would remain compatible with the balenaCloud backend for one
year from the date when v11.0.0 is released. year from the date when v12.0.0 was released.
At the end of this period, the older major version is considered deprecated and Half way through to that period (6 months after the release of the next major
some of the functionality that depends on balenaCloud services may stop working version), older major versions of the balena CLI will start printing a deprecation
at any time. warning message when it is used interactively (when `stderr` is attached to a TTY
Users are encouraged to regularly update the balena CLI to the latest version. device file). At the end of that period, older major versions will exit with an
error message unless the `--unsupported` flag is used. This behavior was
introduced in CLI version 12.47.0 and is also documented by `balena help`.
To take advantage of the latest backend features and ensure compatibility, users
are encouraged to regularly update the balena CLI to the latest version.
## Contributing (including editing documentation files) ## Contributing (including editing documentation files)

View File

@ -22,25 +22,26 @@ import * as archiver from 'archiver';
import * as Bluebird from 'bluebird'; import * as Bluebird from 'bluebird';
import { execFile } from 'child_process'; import { execFile } from 'child_process';
import * as filehound from 'filehound'; import * as filehound from 'filehound';
import { Stats } from 'fs';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import * as klaw from 'klaw';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as path from 'path'; import * as path from 'path';
import * as rimraf from 'rimraf'; import * as rimraf from 'rimraf';
import * as semver from 'semver'; import * as semver from 'semver';
import * as util from 'util'; import { promisify } from 'util';
import * as klaw from 'klaw';
import { Stats } from 'fs';
import { stripIndent } from '../lib/utils/lazy'; import { stripIndent } from '../lib/utils/lazy';
import { import {
diffLines, diffLines,
getSubprocessStdout,
loadPackageJson, loadPackageJson,
ROOT, ROOT,
StdOutTap, StdOutTap,
whichSpawn, whichSpawn,
} from './utils'; } from './utils';
const execFileAsync = promisify(execFile);
export const packageJSON = loadPackageJson(); export const packageJSON = loadPackageJson();
export const version = 'v' + packageJSON.version; export const version = 'v' + packageJSON.version;
const arch = process.arch; const arch = process.arch;
@ -246,7 +247,17 @@ async function testPkg() {
console.log(`Testing standalone package "${pkgBalenaPath}"...`); console.log(`Testing standalone package "${pkgBalenaPath}"...`);
// Run `balena version -j`, parse its stdout as JSON, and check that the // Run `balena version -j`, parse its stdout as JSON, and check that the
// reported Node.js major version matches semver.major(process.version) // reported Node.js major version matches semver.major(process.version)
const stdout = await getSubprocessStdout(pkgBalenaPath, ['version', '-j']); let { stdout, stderr } = await execFileAsync(pkgBalenaPath, [
'version',
'-j',
]);
const { filterCliOutputForTests } = await import('../tests/helpers');
const filtered = filterCliOutputForTests({
err: stderr.split(/\r?\n/),
out: stdout.split(/\r?\n/),
});
stdout = filtered.out.join('\n');
stderr = filtered.err.join('\n');
let pkgNodeVersion = ''; let pkgNodeVersion = '';
let pkgNodeMajorVersion = 0; let pkgNodeMajorVersion = 0;
try { try {
@ -263,6 +274,10 @@ async function testPkg() {
`Mismatched major version: built-in pkg Node version="${pkgNodeVersion}" vs process.version="${process.version}"`, `Mismatched major version: built-in pkg Node version="${pkgNodeVersion}" vs process.version="${process.version}"`,
); );
} }
if (filtered.err.length > 0) {
const err = filtered.err.join('\n');
throw new Error(`"${pkgBalenaPath}": non-empty stderr "${err}"`);
}
console.log('Success! (standalone package test successful)'); console.log('Success! (standalone package test successful)');
} }
@ -411,8 +426,6 @@ async function renameInstallerFiles() {
async function signWindowsInstaller() { async function signWindowsInstaller() {
if (process.env.CSC_LINK && process.env.CSC_KEY_PASSWORD) { if (process.env.CSC_LINK && process.env.CSC_KEY_PASSWORD) {
const exeName = renamedOclifInstallers[process.platform]; const exeName = renamedOclifInstallers[process.platform];
const execFileAsync = util.promisify<string, string[], void>(execFile);
console.log(`Signing installer "${exeName}"`); console.log(`Signing installer "${exeName}"`);
await execFileAsync(MSYS2_BASH, [ await execFileAsync(MSYS2_BASH, [
'sign-exe.sh', 'sign-exe.sh',

View File

@ -21,22 +21,6 @@ import * as path from 'path';
export const ROOT = path.join(__dirname, '..'); export const ROOT = path.join(__dirname, '..');
const nodeEngineWarn = `\
------------------------------------------------------------------------------
Warning: Node version "v14.x.x" does not match required versions ">=10.20.0 <13.0.0".
This may cause unexpected behavior. To upgrade Node, visit:
https://nodejs.org/en/download/
------------------------------------------------------------------------------
`;
const nodeEngineWarnArray = nodeEngineWarn.split('\n').filter((l) => l);
export function matchesNodeEngineVersionWarn(line: string) {
line = line.replace(/"v14\.\d{1,3}\.\d{1,3}"/, '"v14.x.x"');
return (
line === nodeEngineWarn || nodeEngineWarnArray.includes(line.trimEnd())
);
}
/** Tap and buffer this process' stdout and stderr */ /** Tap and buffer this process' stdout and stderr */
export class StdOutTap { export class StdOutTap {
public stdoutBuf: string[] = []; public stdoutBuf: string[] = [];
@ -104,60 +88,6 @@ export function loadPackageJson() {
return require(path.join(ROOT, 'package.json')); return require(path.join(ROOT, 'package.json'));
} }
/**
* Run the executable at execPath as a child process, and resolve a promise
* to the executable's stdout output as a string. Reject the promise if
* anything is printed to stderr, or if the child process exits with a
* non-zero exit code.
* @param execPath Executable path
* @param args Command-line argument for the executable
*/
export async function getSubprocessStdout(
execPath: string,
args: string[],
): Promise<string> {
const child = spawn(execPath, args);
return new Promise((resolve, reject) => {
let stdout = '';
child.stdout.on('error', reject);
child.stderr.on('error', reject);
child.stdout.on('data', (data: Buffer) => {
try {
stdout = data.toString();
} catch (err) {
reject(err);
}
});
child.stderr.on('data', (data: Buffer) => {
try {
const stderr = data.toString();
// ignore any debug lines, but ensure that we parse
// every line provided to the stderr stream
const lines = _.filter(
stderr.trim().split(/\r?\n/),
(line) =>
!line.startsWith('[debug]') && !matchesNodeEngineVersionWarn(line),
);
if (lines.length > 0) {
reject(
new Error(`"${execPath}": non-empty stderr "${lines.join('\n')}"`),
);
}
} catch (err) {
reject(err);
}
});
child.on('exit', (code: number) => {
if (code) {
reject(new Error(`"${execPath}": non-zero exit code "${code}"`));
} else {
resolve(stdout);
}
});
});
}
/** /**
* Error handling wrapper around the npm `which` package: * Error handling wrapper around the npm `which` package:
* "Like the unix which utility. Finds the first instance of a specified * "Like the unix which utility. Finds the first instance of a specified

View File

@ -144,14 +144,18 @@ of major, minor and patch version releases.
The latest release of a major version of the balena CLI will remain compatible with The latest release of a major version of the balena CLI will remain compatible with
the balenaCloud backend services for at least one year from the date when the the balenaCloud backend services for at least one year from the date when the
following major version is released. For example, balena CLI v10.17.5, as the following major version is released. For example, balena CLI v11.36.0, as the
latest v10 release, would remain compatible with the balenaCloud backend for one latest v11 release, would remain compatible with the balenaCloud backend for one
year from the date when v11.0.0 is released. year from the date when v12.0.0 was released.
At the end of this period, the older major version is considered deprecated and Half way through to that period (6 months after the release of the next major
some of the functionality that depends on balenaCloud services may stop working version), older major versions of the balena CLI will start printing a deprecation
at any time. warning message when it is used interactively (when `stderr` is attached to a TTY
Users are encouraged to regularly update the balena CLI to the latest version. device file). At the end of that period, older major versions will exit with an
error message unless the `--unsupported` flag is used. This behavior was
introduced in CLI version 12.47.0 and is also documented by `balena help`.
To take advantage of the latest backend features and ensure compatibility, users
are encouraged to regularly update the balena CLI to the latest version.
# CLI Command Reference # CLI Command Reference

View File

@ -16,8 +16,14 @@
*/ */
import * as packageJSON from '../package.json'; import * as packageJSON from '../package.json';
import {
AppOptions,
checkDeletedCommand,
preparseArgs,
unsupportedFlag,
} from './preparser';
import { CliSettings } from './utils/bootstrap'; import { CliSettings } from './utils/bootstrap';
import { onceAsync, stripIndent } from './utils/lazy'; import { onceAsync } from './utils/lazy';
/** /**
* Sentry.io setup * Sentry.io setup
@ -43,13 +49,8 @@ export const setupSentry = onceAsync(async () => {
async function checkNodeVersion() { async function checkNodeVersion() {
const validNodeVersions = packageJSON.engines.node; const validNodeVersions = packageJSON.engines.node;
if (!(await import('semver')).satisfies(process.version, validNodeVersions)) { if (!(await import('semver')).satisfies(process.version, validNodeVersions)) {
console.warn(stripIndent` const { getNodeEngineVersionWarn } = await import('./utils/messages');
------------------------------------------------------------------------------ console.warn(getNodeEngineVersionWarn(process.version, validNodeVersions));
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/
------------------------------------------------------------------------------
`);
} }
} }
@ -93,10 +94,20 @@ async function init() {
} }
/** Execute the oclif parser and the CLI command. */ /** Execute the oclif parser and the CLI command. */
async function oclifRun( async function oclifRun(command: string[], options: AppOptions) {
command: string[], let deprecationPromise: Promise<void>;
options: import('./preparser').AppOptions, // check and enforce the CLI's deprecation policy
) { if (unsupportedFlag) {
deprecationPromise = Promise.resolve();
} else {
const { DeprecationChecker } = await import('./deprecation');
const deprecationChecker = new DeprecationChecker(packageJSON.version);
// warnAndAbortIfDeprecated uses previously cached data only
await deprecationChecker.warnAndAbortIfDeprecated();
// checkForNewReleasesIfNeeded may query the npm registry
deprecationPromise = deprecationChecker.checkForNewReleasesIfNeeded();
}
const runPromise = (async function (shouldFlush: boolean) { const runPromise = (async function (shouldFlush: boolean) {
const { CustomMain } = await import('./utils/oclif-utils'); const { CustomMain } = await import('./utils/oclif-utils');
let isEEXIT = false; let isEEXIT = false;
@ -130,14 +141,12 @@ async function oclifRun(
})(!options.noFlush); })(!options.noFlush);
const { trackPromise } = await import('./hooks/prerun/track'); const { trackPromise } = await import('./hooks/prerun/track');
await Promise.all([trackPromise, runPromise]);
await Promise.all([trackPromise, deprecationPromise, runPromise]);
} }
/** CLI entrypoint. Called by the `bin/balena` and `bin/balena-dev` scripts. */ /** CLI entrypoint. Called by the `bin/balena` and `bin/balena-dev` scripts. */
export async function run( export async function run(cliArgs = process.argv, options: AppOptions = {}) {
cliArgs = process.argv,
options: import('./preparser').AppOptions = {},
) {
try { try {
const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap'); const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap');
normalizeEnvVars(); normalizeEnvVars();
@ -150,8 +159,6 @@ export async function run(
await init(); await init();
const { preparseArgs, checkDeletedCommand } = await import('./preparser');
// Look for commands that have been removed and if so, exit with a notice // Look for commands that have been removed and if so, exit with a notice
checkDeletedCommand(cliArgs.slice(2)); checkDeletedCommand(cliArgs.slice(2));

241
lib/deprecation.ts Normal file
View File

@ -0,0 +1,241 @@
/**
* @license
* Copyright 2021 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.
*/
export interface ReleaseTimestampsByVersion {
[version: string]: string; // e.g. { '12.0.0': '2021-06-16T12:54:52.000Z' }
lastFetched: string; // ISO 8601 timestamp, e.g. '2021-06-27T16:46:10.000Z'
}
/**
* Warn about and enforce the CLI deprecation policy stated in the README
* file. In particular:
* The latest release of a major version will remain compatible with
* the backend services for at least one year from the date when the
* following major version is released. [...]
* Half way through to that period (6 months), old major versions of the
* balena CLI will start printing a deprecation warning message.
* At the end of that period, older major versions will abort with an error
* message unless the `--unsupported` flag is used.
*
* - Check for new balena-cli releases by querying the npm registry.
* - Cache results for a number of days to improve performance.
*
* For this feature's specification and planning, see (restricted access):
* https://jel.ly.fish/ed8d2395-9323-418c-bb67-d11d32a17d00
*/
export class DeprecationChecker {
protected static disabled = false; // for the benefit of testing
readonly majorVersionFetchIntervalDays = 7;
readonly expiryDays = 365;
readonly deprecationDays = Math.ceil(this.expiryDays / 2);
readonly msInDay = 24 * 60 * 60 * 1000; // milliseconds in a day
readonly debugPrefix = 'Deprecation check';
readonly cacheFile = 'cachedReleaseTimestamps';
readonly now = new Date().getTime();
private initialized = false;
storage: ReturnType<typeof import('balena-settings-storage')>;
cachedTimestamps: ReleaseTimestampsByVersion;
nextMajorVersion: string; // semver without the 'v' prefix
constructor(protected currentVersion: string) {
const semver = require('semver') as typeof import('semver');
const major = semver.major(this.currentVersion, { loose: true });
this.nextMajorVersion = `${major + 1}.0.0`;
}
public async init() {
if (this.initialized) {
return;
}
this.initialized = true;
const settings = await import('balena-settings-client');
const getStorage = await import('balena-settings-storage');
const dataDirectory = settings.get<string>('dataDirectory');
this.storage = getStorage({ dataDirectory });
let stored: ReleaseTimestampsByVersion | undefined;
try {
stored = (await this.storage.get(
this.cacheFile,
)) as ReleaseTimestampsByVersion;
} catch {
// ignore
}
this.cachedTimestamps = {
...stored,
// '1970-01-01T00:00:00.000Z' is new Date(0).toISOString()
lastFetched: stored?.lastFetched || '1970-01-01T00:00:00.000Z',
};
}
/**
* Get NPM registry URL to retrieve the package.json file for a given version.
* @param version Semver without 'v' prefix, e.g. '12.0.0.'
*/
protected getNpmUrl(version: string) {
return `http://registry.npmjs.org/balena-cli/${version}`;
}
/**
* Query the npm registry (HTTP request) for a given balena-cli version.
*
* @param version semver version without the 'v' prefix, e.g. '13.0.0'
* @returns `undefined` if the request status code is 404 (version not
* published), otherwise a publishedAt date in ISO 8601 format, e.g.
* '2021-06-27T16:46:10.000Z'.
*/
protected async fetchPublishedTimestampForVersion(
version: string,
): Promise<string | undefined> {
const { default: got } = await import('got');
const url = this.getNpmUrl(version);
let response: import('got').Response<Dictionary<any>> | undefined;
try {
response = await got(url, { responseType: 'json', retry: 0 });
} catch (e) {
// 404 is expected if `version` hasn't been published yet
if (e.response?.statusCode !== 404) {
throw new Error(`Failed to query "${url}":\n${e}`);
}
}
// response.body looks like a package.json file, plus possibly a
// `versionist.publishedAt` field added by `github.com/product-os/versionist`
const publishedAt: string | undefined =
response?.body?.versionist?.publishedAt;
if (!publishedAt && process.env.DEBUG) {
console.error(`\
[debug] ${this.debugPrefix}: balena CLI next major version "${this.nextMajorVersion}" not released, \
or release date not available`);
}
return publishedAt; // ISO 8601, e.g. '2021-06-27T16:46:10.000Z'
}
/**
* Check if we already know (cached value) when the next major version
* was released. If we don't know, check how long ago the npm registry
* was last fetched, and fetch again if it has been longer than
* `majorVersionFetchIntervalDays`.
*/
public async checkForNewReleasesIfNeeded() {
if (DeprecationChecker.disabled) {
return; // for the benefit of code testing
}
await this.init();
if (this.cachedTimestamps[this.nextMajorVersion]) {
// A cached value exists: no need to check the npm registry
return;
}
const lastFetched = new Date(this.cachedTimestamps.lastFetched).getTime();
const daysSinceLastFetch = (this.now - lastFetched) / this.msInDay;
if (daysSinceLastFetch < this.majorVersionFetchIntervalDays) {
if (process.env.DEBUG) {
// toFixed(5) results in a precision of ~1 second
const days = daysSinceLastFetch.toFixed(5);
console.error(`\
[debug] ${this.debugPrefix}: ${days} days since last npm registry query for next major version release date.
[debug] Will not query the registry again until at least ${this.majorVersionFetchIntervalDays} days have passed.`);
}
return;
}
if (process.env.DEBUG) {
console.error(`\
[debug] ${
this.debugPrefix
}: Cache miss for the balena CLI next major version release date.
[debug] Will query ${this.getNpmUrl(this.nextMajorVersion)}`);
}
try {
const publishedAt = await this.fetchPublishedTimestampForVersion(
this.nextMajorVersion,
);
if (publishedAt) {
this.cachedTimestamps[this.nextMajorVersion] = publishedAt;
}
} catch (e) {
if (process.env.DEBUG) {
console.error(`[debug] ${this.debugPrefix}: ${e}`);
}
}
// Refresh `lastFetched` regardless of whether or not the request to the npm
// registry was successful. Will try again after `majorVersionFetchIntervalDays`.
this.cachedTimestamps.lastFetched = new Date(this.now).toISOString();
await this.storage.set(this.cacheFile, this.cachedTimestamps);
}
/**
* Use previously cached data (local cache only, fast execution) to check
* whether this version of the CLI is deprecated as per deprecation policy,
* in which case warn about it and conditionally throw an error.
*/
public async warnAndAbortIfDeprecated() {
if (DeprecationChecker.disabled) {
return; // for the benefit of code testing
}
await this.init();
const nextMajorDateStr = this.cachedTimestamps[this.nextMajorVersion];
if (!nextMajorDateStr) {
return;
}
const nextMajorDate = new Date(nextMajorDateStr).getTime();
const daysElapsed = Math.trunc((this.now - nextMajorDate) / this.msInDay);
if (daysElapsed > this.expiryDays) {
const { ExpectedError } = await import('./errors');
throw new ExpectedError(this.getExpiryMsg(daysElapsed));
} else if (daysElapsed > this.deprecationDays && process.stderr.isTTY) {
console.error(this.getDeprecationMsg(daysElapsed));
}
}
/** Separate function for the benefit of code testing */
getDeprecationMsg(daysElapsed: number) {
const { warnify } =
require('./utils/messages') as typeof import('./utils/messages');
return warnify(`\
CLI version ${this.nextMajorVersion} was released ${daysElapsed} days ago: please upgrade.
This version of the balena CLI (${this.currentVersion}) will exit with an error
message after ${this.expiryDays} days from the release of version ${this.nextMajorVersion},
as per deprecation policy: https://git.io/JRHUW#deprecation-policy
The --unsupported flag may be used to bypass this deprecation check and
allow the CLI to keep working beyond the deprecation period. However,
note that the balenaCloud or openBalena backends may be updated in a way
that is no longer compatible with this version.`);
}
/** Separate function the benefit of code testing */
getExpiryMsg(daysElapsed: number) {
return `
This version of the balena CLI (${this.currentVersion}) has expired: please upgrade.
${daysElapsed} days have passed since the release of CLI version ${this.nextMajorVersion}.
See deprecation policy at: https://git.io/JRHUW#deprecation-policy
The --unsupported flag may be used to bypass this deprecation check and
continue using this version of the CLI. However, note that the balenaCloud
or openBalena backends may be updated in a way that is no longer compatible
with this CLI version.`;
}
/** Disable deprecation checks (for the benefit of code testing). */
public static disable() {
DeprecationChecker.disabled = true;
}
/** Re-enable deprecation checks (for the benefit of code testing). */
public static enable() {
DeprecationChecker.disabled = false;
}
}

View File

@ -60,11 +60,11 @@ export async function trackCommand(commandSignature: string) {
}); });
} }
const settings = await import('balena-settings-client'); const settings = await import('balena-settings-client');
const balenaUrl = settings.get('balenaUrl') as string; const balenaUrl = settings.get<string>('balenaUrl');
const username = await (async () => { const username = await (async () => {
const getStorage = await import('balena-settings-storage'); const getStorage = await import('balena-settings-storage');
const dataDirectory = settings.get('dataDirectory') as string; const dataDirectory = settings.get<string>('dataDirectory');
const storage = getStorage({ dataDirectory }); const storage = getStorage({ dataDirectory });
let token; let token;
try { try {

View File

@ -46,7 +46,7 @@ export default class BalenaHelp extends Help {
const subject = getHelpSubject(argv); const subject = getHelpSubject(argv);
if (!subject) { if (!subject) {
const verbose = argv.includes('-v') || argv.includes('--verbose'); const verbose = argv.includes('-v') || argv.includes('--verbose');
this.showCustomRootHelp(verbose); console.log(this.getCustomRootHelp(verbose));
return; return;
} }
@ -80,19 +80,26 @@ export default class BalenaHelp extends Help {
throw new ExpectedError(`command ${chalk.cyan.bold(subject)} not found`); throw new ExpectedError(`command ${chalk.cyan.bold(subject)} not found`);
} }
showCustomRootHelp(showAllCommands: boolean): void { getCustomRootHelp(showAllCommands: boolean): string {
const chalk = getChalk(); const { bold, cyan } = getChalk();
const bold = chalk.bold;
const cmd = chalk.cyan.bold;
let commands = this.config.commands; let commands = this.config.commands;
commands = commands.filter((c) => this.opts.all || !c.hidden); commands = commands.filter((c) => this.opts.all || !c.hidden);
// Get Primary Commands, sorted as in manual list // Get Primary Commands, sorted as in manual list
const primaryCommands = this.manuallySortedPrimaryCommands.map((pc) => { const primaryCommands = this.manuallySortedPrimaryCommands
.map((pc) => {
return commands.find((c) => c.id === pc.replace(' ', ':')); return commands.find((c) => c.id === pc.replace(' ', ':'));
}); })
.filter((c): c is typeof commands[0] => !!c);
let usageLength = 0;
for (const cmd of primaryCommands) {
usageLength = Math.max(usageLength, cmd.usage?.length || 0);
}
let additionalCmdSection: string[];
if (showAllCommands) {
// Get the rest as Additional Commands // Get the rest as Additional Commands
const additionalCommands = commands.filter( const additionalCommands = commands.filter(
(c) => (c) =>
@ -101,46 +108,78 @@ export default class BalenaHelp extends Help {
// Find longest usage, and pad usage of first command in each category // Find longest usage, and pad usage of first command in each category
// This is to ensure that both categories align visually // This is to ensure that both categories align visually
const usageLength = commands for (const cmd of additionalCommands) {
.map((c) => c.usage?.length || 0) usageLength = Math.max(usageLength, cmd.usage?.length || 0);
.reduce((longest, l) => { }
return l > longest ? l : longest;
});
if ( if (
typeof primaryCommands[0]?.usage === 'string' && typeof primaryCommands[0].usage === 'string' &&
typeof additionalCommands[0]?.usage === 'string' typeof additionalCommands[0].usage === 'string'
) { ) {
primaryCommands[0].usage = primaryCommands[0].usage.padEnd(usageLength); primaryCommands[0].usage = primaryCommands[0].usage.padEnd(usageLength);
additionalCommands[0].usage = additionalCommands[0].usage =
additionalCommands[0].usage.padEnd(usageLength); additionalCommands[0].usage.padEnd(usageLength);
} }
// Output help additionalCmdSection = [
console.log(bold('USAGE')); bold('\nADDITIONAL COMMANDS'),
console.log('$ balena [COMMAND] [OPTIONS]'); this.formatCommands(additionalCommands),
];
console.log(bold('\nPRIMARY COMMANDS'));
console.log(this.formatCommands(primaryCommands));
if (showAllCommands) {
console.log(bold('\nADDITIONAL COMMANDS'));
console.log(this.formatCommands(additionalCommands));
} else { } else {
console.log( const cmd = cyan.bold('balena help --verbose');
`\n${bold('...MORE')} run ${cmd( additionalCmdSection = [
'balena help --verbose', `\n${bold('...MORE')} run ${cmd} to list additional commands.`,
)} to list additional commands.`, ];
);
} }
console.log(bold('\nGLOBAL OPTIONS')); const globalOps = [
console.log(' --help, -h'); ['--help, -h', 'display command help'],
console.log(' --debug\n'); ['--debug', 'enable debug output'],
[
'--unsupported',
`\
prevent exit with an error as per Deprecation Policy
See: https://git.io/JRHUW#deprecation-policy`,
],
];
globalOps[0][0] = globalOps[0][0].padEnd(usageLength);
const { reachingOut } = const { deprecationPolicyNote, reachingOut } =
require('./utils/messages') as typeof import('./utils/messages'); require('./utils/messages') as typeof import('./utils/messages');
console.log(reachingOut);
return [
bold('USAGE'),
'$ balena [COMMAND] [OPTIONS]',
bold('\nPRIMARY COMMANDS'),
this.formatCommands(primaryCommands),
...additionalCmdSection,
bold('\nGLOBAL OPTIONS'),
this.formatGlobalOpts(globalOps),
bold('\nDeprecation Policy Reminder'),
deprecationPolicyNote,
reachingOut,
].join('\n');
}
protected formatGlobalOpts(opts: string[][]) {
const { dim } = getChalk();
const outLines: string[] = [];
let flagWidth = 0;
for (const opt of opts) {
flagWidth = Math.max(flagWidth, opt[0].length);
}
for (const opt of opts) {
const descriptionLines = opt[1].split('\n');
outLines.push(
` ${opt[0].padEnd(flagWidth + 2)}${dim(descriptionLines[0])}`,
);
outLines.push(
...descriptionLines
.slice(1)
.map((line) => ` ${' '.repeat(flagWidth + 2)}${dim(line)}`),
);
}
return outLines.join('\n');
} }
protected formatCommands(commands: any[]): string { protected formatCommands(commands: any[]): string {

View File

@ -14,8 +14,8 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import { stripIndent } from './utils/lazy';
import { exitWithExpectedError } from './errors'; export let unsupportedFlag = false;
export interface AppOptions { export interface AppOptions {
// Prevent the default behavior of flushing stdout after running a command // Prevent the default behavior of flushing stdout after running a command
@ -50,11 +50,10 @@ export async function preparseArgs(argv: string[]): Promise<string[]> {
} }
// support global --debug flag // support global --debug flag
const debugIndex = cmdSlice.indexOf('--debug'); if (extractBooleanFlag(cmdSlice, '--debug')) {
if (debugIndex > -1) {
process.env.DEBUG = '1'; process.env.DEBUG = '1';
cmdSlice.splice(debugIndex, 1);
} }
unsupportedFlag = extractBooleanFlag(cmdSlice, '--unsupported');
} }
// Enable bluebird long stack traces when in debug mode, must be set // Enable bluebird long stack traces when in debug mode, must be set
@ -87,11 +86,22 @@ export async function preparseArgs(argv: string[]): Promise<string[]> {
return args; return args;
} }
function extractBooleanFlag(argv: string[], flag: string): boolean {
const index = argv.indexOf(flag);
if (index >= 0) {
argv.splice(index, 1);
return true;
}
return false;
}
/** /**
* Check whether the command line refers to a command that has been deprecated * Check whether the command line refers to a command that has been deprecated
* and removed and, if so, exit with an informative error message. * and removed and, if so, exit with an informative error message.
*/ */
export function checkDeletedCommand(argvSlice: string[]): void { export function checkDeletedCommand(argvSlice: string[]): void {
const { ExpectedError } = require('./errors') as typeof import('./errors');
if (argvSlice[0] === 'help') { if (argvSlice[0] === 'help') {
argvSlice = argvSlice.slice(1); argvSlice = argvSlice.slice(1);
} }
@ -101,17 +111,16 @@ export function checkDeletedCommand(argvSlice: string[]): void {
version: string, version: string,
verb = 'replaced', verb = 'replaced',
) { ) {
exitWithExpectedError(stripIndent` throw new ExpectedError(`\
Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}. Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}.
Please use "balena ${alternative}" instead. Please use "balena ${alternative}" instead.`);
`);
} }
function removed(oldCmd: string, alternative: string, version: string) { function removed(oldCmd: string, alternative: string, version: string) {
let msg = `Note: the command "balena ${oldCmd}" was removed in CLI version ${version}.`; let msg = `Note: the command "balena ${oldCmd}" was removed in CLI version ${version}.`;
if (alternative) { if (alternative) {
msg = [msg, alternative].join('\n'); msg = [msg, alternative].join('\n');
} }
exitWithExpectedError(msg); throw new ExpectedError(msg);
} }
const stopAlternative = const stopAlternative =
'Please use "balena ssh -s" to access the host OS, then use `balena-engine stop`.'; 'Please use "balena ssh -s" to access the host OS, then use `balena-engine stop`.';

View File

@ -30,6 +30,12 @@ export const help = reachingOut;
// is parsed, so its evaluation cannot happen at module loading time. // is parsed, so its evaluation cannot happen at module loading time.
export const getHelp = () => (process.env.DEBUG ? '' : debugHint) + help; export const getHelp = () => (process.env.DEBUG ? '' : debugHint) + help;
export const deprecationPolicyNote = `\
The balena CLI enforces its deprecation policy by exiting with an error a year
after the release of the next major version, unless the --unsupported option is
used. Find out more at: https://git.io/JRHUW#deprecation-policy
`;
/** /**
* Take a multiline string like: * Take a multiline string like:
* Line One * Line One
@ -41,8 +47,8 @@ export const getHelp = () => (process.env.DEBUG ? '' : debugHint) + help;
* --------------- * ---------------
* where the length of the dash rows matches the length of the longest line. * where the length of the dash rows matches the length of the longest line.
*/ */
export function warnify(msg: string) { export function warnify(msg: string, prefix = '[Warn] ') {
const lines = msg.split('\n').map((l) => `[Warn] ${l}`); const lines = msg.split('\n').map((l) => `${prefix}${l}`);
const maxLength = Math.max(...lines.map((l) => l.length)); const maxLength = Math.max(...lines.map((l) => l.length));
const hr = '-'.repeat(maxLength); const hr = '-'.repeat(maxLength);
return [hr, ...lines, hr].join('\n'); return [hr, ...lines, hr].join('\n');
@ -184,3 +190,13 @@ the next major version of the CLI (v13). The --v13 option may be used
to enable the new names already now, and suppress a warning message. to enable the new names already now, and suppress a warning message.
(The --v13 option will be silently ignored in CLI v13.) (The --v13 option will be silently ignored in CLI v13.)
Find out more at: https://git.io/JRuZr`; Find out more at: https://git.io/JRuZr`;
export function getNodeEngineVersionWarn(
version: string,
validVersions: string,
) {
version = version.startsWith('v') ? version.substring(1) : version;
return warnify(`\
Node.js version "${version}" does not satisfy requirement "${validVersions}"
This may cause unexpected behavior.`);
}

207
npm-shrinkwrap.json generated
View File

@ -2385,6 +2385,17 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/cacheable-request": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz",
"integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==",
"requires": {
"@types/http-cache-semantics": "*",
"@types/keyv": "*",
"@types/node": "*",
"@types/responselike": "*"
}
},
"@types/caseless": { "@types/caseless": {
"version": "0.12.2", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz",
@ -2534,6 +2545,11 @@
"integrity": "sha512-TikApqV8CkUsI1GGUgVydkJFrq9sYCBWv4fc/r3zvl6Oqe2YU1ASeWBrG5bw1D2XvS07YS3s05hCor/lEtIoYw==", "integrity": "sha512-TikApqV8CkUsI1GGUgVydkJFrq9sYCBWv4fc/r3zvl6Oqe2YU1ASeWBrG5bw1D2XvS07YS3s05hCor/lEtIoYw==",
"dev": true "dev": true
}, },
"@types/http-cache-semantics": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
"integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
},
"@types/http-proxy": { "@types/http-proxy": {
"version": "1.17.7", "version": "1.17.7",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.7.tgz", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.7.tgz",
@ -2600,6 +2616,14 @@
"resolved": "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz",
"integrity": "sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A==" "integrity": "sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A=="
}, },
"@types/keyv": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.2.tgz",
"integrity": "sha512-/FvAK2p4jQOaJ6CGDHJTqZcUtbZe820qIeTg7o0Shg7drB4JHeL+V/dhSaly7NXx6u8eSee+r7coT+yuJEvDLg==",
"requires": {
"@types/node": "*"
}
},
"@types/klaw": { "@types/klaw": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/klaw/-/klaw-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/klaw/-/klaw-3.0.2.tgz",
@ -2791,6 +2815,14 @@
} }
} }
}, },
"@types/responselike": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz",
"integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==",
"requires": {
"@types/node": "*"
}
},
"@types/rewire": { "@types/rewire": {
"version": "2.5.28", "version": "2.5.28",
"resolved": "https://registry.npmjs.org/@types/rewire/-/rewire-2.5.28.tgz", "resolved": "https://registry.npmjs.org/@types/rewire/-/rewire-2.5.28.tgz",
@ -4561,6 +4593,11 @@
"unset-value": "^1.0.0" "unset-value": "^1.0.0"
} }
}, },
"cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="
},
"cacheable-request": { "cacheable-request": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
@ -8429,6 +8466,31 @@
"requires": { "requires": {
"got": "^6.2.0", "got": "^6.2.0",
"is-plain-obj": "^1.1.0" "is-plain-obj": "^1.1.0"
},
"dependencies": {
"get-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz",
"integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ="
},
"got": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz",
"integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=",
"requires": {
"create-error-class": "^3.0.0",
"duplexer3": "^0.1.4",
"get-stream": "^3.0.0",
"is-redirect": "^1.0.0",
"is-retry-allowed": "^1.0.0",
"is-stream": "^1.0.0",
"lowercase-keys": "^1.0.0",
"safe-buffer": "^5.0.1",
"timed-out": "^4.0.0",
"unzip-response": "^2.0.1",
"url-parse-lax": "^1.0.0"
}
}
} }
}, },
"ghauth": { "ghauth": {
@ -8905,27 +8967,111 @@
} }
}, },
"got": { "got": {
"version": "6.7.1", "version": "11.8.2",
"resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz", "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz",
"integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=", "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==",
"requires": { "requires": {
"create-error-class": "^3.0.0", "@sindresorhus/is": "^4.0.0",
"duplexer3": "^0.1.4", "@szmarczak/http-timer": "^4.0.5",
"get-stream": "^3.0.0", "@types/cacheable-request": "^6.0.1",
"is-redirect": "^1.0.0", "@types/responselike": "^1.0.0",
"is-retry-allowed": "^1.0.0", "cacheable-lookup": "^5.0.3",
"is-stream": "^1.0.0", "cacheable-request": "^7.0.1",
"lowercase-keys": "^1.0.0", "decompress-response": "^6.0.0",
"safe-buffer": "^5.0.1", "http2-wrapper": "^1.0.0-beta.5.2",
"timed-out": "^4.0.0", "lowercase-keys": "^2.0.0",
"unzip-response": "^2.0.1", "p-cancelable": "^2.0.0",
"url-parse-lax": "^1.0.0" "responselike": "^2.0.0"
}, },
"dependencies": { "dependencies": {
"@sindresorhus/is": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz",
"integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g=="
},
"@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
"requires": {
"defer-to-connect": "^2.0.0"
}
},
"cacheable-request": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz",
"integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==",
"requires": {
"clone-response": "^1.0.2",
"get-stream": "^5.1.0",
"http-cache-semantics": "^4.0.0",
"keyv": "^4.0.0",
"lowercase-keys": "^2.0.0",
"normalize-url": "^6.0.1",
"responselike": "^2.0.0"
}
},
"decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"requires": {
"mimic-response": "^3.1.0"
}
},
"defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="
},
"get-stream": { "get-stream": {
"version": "3.0.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"requires": {
"pump": "^3.0.0"
}
},
"json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
},
"keyv": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz",
"integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==",
"requires": {
"json-buffer": "3.0.1"
}
},
"lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="
},
"mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="
},
"normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="
},
"p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="
},
"responselike": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz",
"integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==",
"requires": {
"lowercase-keys": "^2.0.0"
}
} }
} }
}, },
@ -9388,6 +9534,15 @@
"sshpk": "^1.7.0" "sshpk": "^1.7.0"
} }
}, },
"http2-wrapper": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
"requires": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.0.0"
}
},
"https-proxy-agent": { "https-proxy-agent": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
@ -14676,6 +14831,11 @@
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
}, },
"quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="
},
"randombytes": { "randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -15851,6 +16011,11 @@
"path-parse": "^1.0.6" "path-parse": "^1.0.6"
} }
}, },
"resolve-alpn": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.0.tgz",
"integrity": "sha512-e4FNQs+9cINYMO5NMFc6kOUCdohjqFPSgMuwuZAOUWqrfWsen+Yjy5qZFkV5K7VO7tFSLKcUL97olkED7sCBHA=="
},
"resolve-dir": { "resolve-dir": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
@ -16347,13 +16512,13 @@
"dev": true "dev": true
}, },
"sinon": { "sinon": {
"version": "11.1.1", "version": "11.1.2",
"resolved": "https://registry.npmjs.org/sinon/-/sinon-11.1.1.tgz", "resolved": "https://registry.npmjs.org/sinon/-/sinon-11.1.2.tgz",
"integrity": "sha512-ZSSmlkSyhUWbkF01Z9tEbxZLF/5tRC9eojCdFh33gtQaP7ITQVaMWQHGuFM7Cuf/KEfihuh1tTl3/ABju3AQMg==", "integrity": "sha512-59237HChms4kg7/sXhiRcUzdSkKuydDeTiamT/jesUVHshBgL8XAmhgFo0GfK6RruMDM/iRSij1EybmMog9cJw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@sinonjs/commons": "^1.8.3", "@sinonjs/commons": "^1.8.3",
"@sinonjs/fake-timers": "^7.1.0", "@sinonjs/fake-timers": "^7.1.2",
"@sinonjs/samsam": "^6.0.2", "@sinonjs/samsam": "^6.0.2",
"diff": "^5.0.0", "diff": "^5.0.0",
"nise": "^5.1.0", "nise": "^5.1.0",

View File

@ -49,6 +49,7 @@
"postinstall": "node patches/apply-patches.js", "postinstall": "node patches/apply-patches.js",
"prebuild": "rimraf build/ build-bin/", "prebuild": "rimraf build/ build-bin/",
"build": "npm run build:src && npm run catch-uncommitted", "build": "npm run build:src && npm run catch-uncommitted",
"build:t": "npm run lint && npm run build:fast && npm run build:test",
"build:src": "npm run lint && npm run build:fast && npm run build:test && npm run build:doc && npm run build:completion", "build:src": "npm run lint && npm run build:fast && npm run build:test && npm run build:doc && npm run build:completion",
"build:fast": "gulp pages && tsc && npx oclif-dev manifest", "build:fast": "gulp pages && tsc && npx oclif-dev manifest",
"build:test": "tsc -P ./tsconfig.dev.json --noEmit && tsc -P ./tsconfig.js.json --noEmit", "build:test": "tsc -P ./tsconfig.dev.json --noEmit && tsc -P ./tsconfig.js.json --noEmit",
@ -185,7 +186,7 @@
"publish-release": "^1.6.1", "publish-release": "^1.6.1",
"rewire": "^5.0.0", "rewire": "^5.0.0",
"simple-git": "^2.40.0", "simple-git": "^2.40.0",
"sinon": "^11.1.1", "sinon": "^11.1.2",
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"typescript": "^4.3.5" "typescript": "^4.3.5"
}, },
@ -236,6 +237,7 @@
"glob": "^7.1.7", "glob": "^7.1.7",
"global-agent": "^2.1.12", "global-agent": "^2.1.12",
"global-tunnel-ng": "^2.1.1", "global-tunnel-ng": "^2.1.1",
"got": "^11.8.2",
"humanize": "0.0.9", "humanize": "0.0.9",
"ignore": "^5.1.8", "ignore": "^5.1.8",
"inquirer": "^7.3.3", "inquirer": "^7.3.3",

View File

@ -16,7 +16,7 @@
*/ */
import { expect } from 'chai'; import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock'; import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers'; import { cleanOutput, runCommand } from '../../helpers';
const HELP_MESSAGE = ''; const HELP_MESSAGE = '';

View File

@ -22,9 +22,9 @@ import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { stripIndent } from '../../lib/utils/lazy'; import { stripIndent } from '../../lib/utils/lazy';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../nock/balena-api-mock';
import { expectStreamNoCRLF, testDockerBuildStream } from '../docker-build'; import { expectStreamNoCRLF, testDockerBuildStream } from '../docker-build';
import { DockerMock, dockerResponsePath } from '../docker-mock'; import { DockerMock, dockerResponsePath } from '../nock/docker-mock';
import { cleanOutput, runCommand } from '../helpers'; import { cleanOutput, runCommand } from '../helpers';
import { import {
ExpectedTarStreamFiles, ExpectedTarStreamFiles,

View File

@ -21,9 +21,9 @@ import * as _ from 'lodash';
import * as path from 'path'; import * as path from 'path';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../nock/balena-api-mock';
import { expectStreamNoCRLF, testDockerBuildStream } from '../docker-build'; import { expectStreamNoCRLF, testDockerBuildStream } from '../docker-build';
import { DockerMock, dockerResponsePath } from '../docker-mock'; import { DockerMock, dockerResponsePath } from '../nock/docker-mock';
import { cleanOutput, runCommand, switchSentry } from '../helpers'; import { cleanOutput, runCommand, switchSentry } from '../helpers';
import { import {
ExpectedTarStreamFiles, ExpectedTarStreamFiles,

View File

@ -16,7 +16,7 @@
*/ */
import { expect } from 'chai'; import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock'; import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers'; import { cleanOutput, runCommand } from '../../helpers';
describe('balena device move', function () { describe('balena device move', function () {

View File

@ -18,7 +18,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import * as path from 'path'; import * as path from 'path';
import { apiResponsePath, BalenaAPIMock } from '../../balena-api-mock'; import { apiResponsePath, BalenaAPIMock } from '../../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers'; import { cleanOutput, runCommand } from '../../helpers';
import { appToFleetOutputMsg, warnify } from '../../../build/utils/messages'; import { appToFleetOutputMsg, warnify } from '../../../build/utils/messages';

View File

@ -18,7 +18,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import * as path from 'path'; import * as path from 'path';
import { apiResponsePath, BalenaAPIMock } from '../../balena-api-mock'; import { apiResponsePath, BalenaAPIMock } from '../../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers'; import { cleanOutput, runCommand } from '../../helpers';
import { appToFleetOutputMsg, warnify } from '../../../build/utils/messages'; import { appToFleetOutputMsg, warnify } from '../../../build/utils/messages';

View File

@ -17,7 +17,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock'; import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers'; import { cleanOutput, runCommand } from '../../helpers';
import { isV13 } from '../../../lib/utils/version'; import { isV13 } from '../../../lib/utils/version';

View File

@ -17,7 +17,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock'; import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { runCommand } from '../../helpers'; import { runCommand } from '../../helpers';
describe('balena env add', function () { describe('balena env add', function () {

View File

@ -18,7 +18,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { stripIndent } from '../../../lib/utils/lazy'; import { stripIndent } from '../../../lib/utils/lazy';
import { BalenaAPIMock } from '../../balena-api-mock'; import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { runCommand } from '../../helpers'; import { runCommand } from '../../helpers';
import { import {

View File

@ -17,7 +17,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock'; import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { runCommand } from '../../helpers'; import { runCommand } from '../../helpers';
describe('balena env rename', function () { describe('balena env rename', function () {

View File

@ -17,7 +17,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock'; import { BalenaAPIMock } from '../../nock/balena-api-mock';
import { runCommand } from '../../helpers'; import { runCommand } from '../../helpers';
describe('balena env rm', function () { describe('balena env rm', function () {

View File

@ -17,7 +17,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../helpers'; import { cleanOutput, runCommand } from '../helpers';
import * as messages from '../../build/utils/messages'; import * as messages from '../../build/utils/messages';

View File

@ -17,9 +17,9 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../helpers'; import { cleanOutput, runCommand } from '../helpers';
import { SupervisorMock } from '../supervisor-mock'; import { SupervisorMock } from '../nock/supervisor-mock';
const itS = process.env.BALENA_CLI_TEST_TYPE === 'standalone' ? it : it.skip; const itS = process.env.BALENA_CLI_TEST_TYPE === 'standalone' ? it : it.skip;

View File

@ -25,7 +25,7 @@ import * as tmp from 'tmp';
tmp.setGracefulCleanup(); tmp.setGracefulCleanup();
const tmpNameAsync = promisify(tmp.tmpName); const tmpNameAsync = promisify(tmp.tmpName);
import { BalenaAPIMock } from '../../balena-api-mock'; import { BalenaAPIMock } from '../../nock/balena-api-mock';
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
describe('balena os configure', function () { describe('balena os configure', function () {

View File

@ -19,8 +19,8 @@ import { expect } from 'chai';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../nock/balena-api-mock';
import { BuilderMock, builderResponsePath } from '../builder-mock'; import { BuilderMock, builderResponsePath } from '../nock/builder-mock';
import { expectStreamNoCRLF, testPushBuildStream } from '../docker-build'; import { expectStreamNoCRLF, testPushBuildStream } from '../docker-build';
import { cleanOutput, runCommand } from '../helpers'; import { cleanOutput, runCommand } from '../helpers';
import { import {

View File

@ -19,7 +19,7 @@ import { expect } from 'chai';
import mock = require('mock-require'); import mock = require('mock-require');
import { createServer, Server } from 'net'; import { createServer, Server } from 'net';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../nock/balena-api-mock';
import { cleanOutput, runCommand } from '../helpers'; import { cleanOutput, runCommand } from '../helpers';
// "itSS" means "it() Skip Standalone" // "itSS" means "it() Skip Standalone"

View File

@ -17,7 +17,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import * as fs from 'fs'; import * as fs from 'fs';
import { BalenaAPIMock } from '../balena-api-mock'; import { BalenaAPIMock } from '../nock/balena-api-mock';
import { runCommand } from '../helpers'; import { runCommand } from '../helpers';
const packageJSON = JSON.parse(fs.readFileSync('./package.json', 'utf8')); const packageJSON = JSON.parse(fs.readFileSync('./package.json', 'utf8'));

View File

@ -19,10 +19,20 @@ import { set as setEsVersion } from '@balena/es-version';
// Set the desired es version for downstream modules that support it // Set the desired es version for downstream modules that support it
setEsVersion('es2018'); setEsVersion('es2018');
// Disable Sentry.io error reporting while running test code
process.env.BALENARC_NO_SENTRY = '1';
// Disable deprecation checks while running test code
import { DeprecationChecker } from '../build/deprecation';
DeprecationChecker.disable();
import * as tmp from 'tmp'; import * as tmp from 'tmp';
tmp.setGracefulCleanup(); tmp.setGracefulCleanup();
// Use a temporary dir for tests data // Use a temporary dir for tests data
process.env.BALENARC_DATA_DIRECTORY = tmp.dirSync().name; process.env.BALENARC_DATA_DIRECTORY = tmp.dirSync().name;
console.error(
`[debug] tests/config-tests.ts: BALENARC_DATA_DIRECTORY="${process.env.BALENARC_DATA_DIRECTORY}"`,
);
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
EventEmitter.defaultMaxListeners = 35; // it appears that 'nock' adds a bunch of listeners - bug? EventEmitter.defaultMaxListeners = 35; // it appears that 'nock' adds a bunch of listeners - bug?

320
tests/deprecation.spec.ts Normal file
View File

@ -0,0 +1,320 @@
/**
* @license
* Copyright 2021 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 settings from 'balena-settings-client';
import * as getStorage from 'balena-settings-storage';
import { expect } from 'chai';
import mock = require('mock-require');
import * as semver from 'semver';
import * as sinon from 'sinon';
import * as packageJSON from '../package.json';
import {
DeprecationChecker,
ReleaseTimestampsByVersion,
} from '../build/deprecation';
import { BalenaAPIMock } from './nock/balena-api-mock';
import { NpmMock } from './nock/npm-mock';
import { runCommand, TestOutput } from './helpers';
// "itSS" means "it() Skip Standalone"
const itSS = process.env.BALENA_CLI_TEST_TYPE === 'standalone' ? it.skip : it;
describe('DeprecationChecker', function () {
const sandbox = sinon.createSandbox();
const now = new Date().getTime();
const anHourAgo = now - 3600000;
const currentMajor = semver.major(packageJSON.version, { loose: true });
const nextMajorVersion = `${currentMajor + 1}.0.0`;
const dataDirectory = settings.get<string>('dataDirectory');
const storageModPath = 'balena-settings-storage';
const mockStorage = getStorage({ dataDirectory });
let api: BalenaAPIMock;
let npm: NpmMock;
let checker: DeprecationChecker;
let getStub: sinon.SinonStub<
Parameters<typeof mockStorage.get>,
ReturnType<typeof mockStorage.get>
>;
let setStub: sinon.SinonStub<
Parameters<typeof mockStorage.set>,
ReturnType<typeof mockStorage.set>
>;
// see also DeprecationChecker.disable() in tests/config-tests.ts
this.beforeAll(() => DeprecationChecker.enable());
this.afterAll(() => DeprecationChecker.disable());
this.beforeEach(() => {
npm = new NpmMock();
api = new BalenaAPIMock();
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetMixpanel({ optional: true });
checker = new DeprecationChecker(packageJSON.version);
getStub = sandbox.stub(mockStorage, 'get').withArgs(checker.cacheFile);
setStub = sandbox
.stub(mockStorage, 'set')
.withArgs(checker.cacheFile, sinon.match.any);
mock(storageModPath, () => mockStorage);
});
this.afterEach(() => {
// Check all expected api calls have been made and clean up.
(mockStorage.get as sinon.SinonStub).restore();
(mockStorage.set as sinon.SinonStub).restore();
// originalStorage.set.restore();
api.done();
npm.done();
mock.stop(storageModPath);
});
itSS(
'should warn if this version of the CLI is deprecated (isTTY is true)',
async () => {
const mockCache: ReleaseTimestampsByVersion = {
lastFetched: '1970-01-01T00:00:00.000Z',
};
// pretend the next major was released just over half a year ago
mockCache[nextMajorVersion] = new Date(
checker.now - (checker.deprecationDays + 1) * checker.msInDay,
).toISOString();
getStub.resolves(mockCache);
// Force isTTY to be true. It happens to be false (undefined) when
// the tests run on balenaCI on Windows.
const originalIsTTY = process.stderr.isTTY;
process.stderr.isTTY = true;
let result: TestOutput;
try {
result = await runCommand('version');
} finally {
process.stderr.isTTY = originalIsTTY;
}
const { out, err } = result;
expect(setStub.callCount).to.equal(0);
expect(getStub.callCount).to.equal(1);
expect(getStub.firstCall.args).to.deep.equal([checker.cacheFile]);
expect(out.join('')).to.equal(packageJSON.version + '\n');
expect(err.join('')).to.equal(
checker.getDeprecationMsg(checker.deprecationDays + 1) + '\n',
);
},
);
itSS(
'should NOT warn if this version of the CLI is deprecated (isTTY is false)',
async () => {
const mockCache: ReleaseTimestampsByVersion = {
lastFetched: '1970-01-01T00:00:00.000Z',
};
// pretend the next major was released just over half a year ago
mockCache[nextMajorVersion] = new Date(
checker.now - (checker.deprecationDays + 1) * checker.msInDay,
).toISOString();
getStub.resolves(mockCache);
// Force isTTY to be false (undefined). It happens to be true when
// the tests run on balenaCI on macOS and Linux.
const originalIsTTY = process.stderr.isTTY;
process.stderr.isTTY = undefined;
let result: TestOutput;
try {
result = await runCommand('version');
} finally {
process.stderr.isTTY = originalIsTTY;
}
const { out, err } = result;
expect(setStub.callCount).to.equal(0);
expect(getStub.callCount).to.equal(1);
expect(getStub.firstCall.args).to.deep.equal([checker.cacheFile]);
expect(out.join('')).to.equal(packageJSON.version + '\n');
expect(err.join('')).to.equal('');
},
);
itSS(
'should NOT warn with --unsupported (deprecated but not expired)',
async () => {
const mockCache: ReleaseTimestampsByVersion = {
lastFetched: '1970-01-01T00:00:00.000Z',
};
// pretend the next major was released just over half a year ago
mockCache[nextMajorVersion] = new Date(
checker.now - (checker.deprecationDays + 1) * checker.msInDay,
).toISOString();
getStub.resolves(mockCache);
const { out, err } = await runCommand('version --unsupported');
expect(setStub.callCount).to.equal(0);
expect(getStub.callCount).to.equal(0);
expect(out.join('')).to.equal(packageJSON.version + '\n');
expect(err.join('')).to.be.empty;
},
);
itSS('should exit if this version of the CLI has expired', async () => {
const mockCache: ReleaseTimestampsByVersion = {
lastFetched: '1970-01-01T00:00:00.000Z',
};
// pretend the next major was released just over a year ago
mockCache[nextMajorVersion] = new Date(
checker.now - (checker.expiryDays + 1) * checker.msInDay,
).toISOString();
getStub.resolves(mockCache);
const { out, err } = await runCommand('version');
expect(setStub.callCount).to.equal(0);
expect(getStub.callCount).to.equal(1);
expect(getStub.firstCall.args).to.deep.equal([checker.cacheFile]);
expect(out.join('')).to.equal('');
expect(err.join('')).to.include(
checker.getExpiryMsg(checker.expiryDays + 1) + '\n\n',
);
});
itSS('should NOT exit with --unsupported (expired version)', async () => {
const mockCache: ReleaseTimestampsByVersion = {
lastFetched: '1970-01-01T00:00:00.000Z',
};
// pretend the next major was released just over a year ago
mockCache[nextMajorVersion] = new Date(
checker.now - (checker.expiryDays + 1) * checker.msInDay,
).toISOString();
getStub.resolves(mockCache);
const { out, err } = await runCommand('--unsupported version');
expect(setStub.callCount).to.equal(0);
expect(getStub.callCount).to.equal(0);
expect(out.join('')).to.equal(packageJSON.version + '\n');
expect(err.join('')).to.be.empty;
});
it('should query the npm registry (empty cache file)', async () => {
npm.expectGetBalenaCli({
version: nextMajorVersion,
publishedAt: new Date().toISOString(),
});
getStub.resolves(undefined);
const { out, err } = await runCommand('version');
expect(setStub.callCount).to.equal(1);
expect(setStub.firstCall.args.length).to.equal(2);
const [name, obj] = setStub.firstCall.args;
expect(name).to.equal(checker.cacheFile);
expect(obj).to.have.property(nextMajorVersion);
const lastFetched = new Date(obj[nextMajorVersion]).getTime();
expect(lastFetched).to.be.greaterThan(anHourAgo);
expect(getStub.callCount).to.equal(1);
expect(getStub.firstCall.args).to.deep.equal([checker.cacheFile]);
expect(out.join('')).to.equal(packageJSON.version + '\n');
expect(err.join('')).to.equal('');
});
it('should query the npm registry (not recently fetched)', async () => {
npm.expectGetBalenaCli({
version: nextMajorVersion,
publishedAt: new Date().toISOString(),
});
const mockCache: ReleaseTimestampsByVersion = {
lastFetched: new Date(
checker.now -
(checker.majorVersionFetchIntervalDays + 1) * checker.msInDay,
).toISOString(),
};
getStub.resolves(mockCache);
const { out, err } = await runCommand('version');
expect(setStub.callCount).to.equal(1);
expect(setStub.firstCall.args.length).to.equal(2);
const [name, obj] = setStub.firstCall.args;
expect(name).to.equal(checker.cacheFile);
expect(obj).to.have.property(nextMajorVersion);
const lastFetched = new Date(obj[nextMajorVersion]).getTime();
expect(lastFetched).to.be.greaterThan(anHourAgo);
expect(getStub.callCount).to.equal(1);
expect(getStub.firstCall.args).to.deep.equal([checker.cacheFile]);
expect(out.join('')).to.equal(packageJSON.version + '\n');
expect(err.join('')).to.equal('');
});
itSS('should NOT query the npm registry (recently fetched)', async () => {
const mockCache: ReleaseTimestampsByVersion = {
lastFetched: new Date(
checker.now -
(checker.majorVersionFetchIntervalDays - 1) * checker.msInDay,
).toISOString(),
};
getStub.resolves(mockCache);
const { out, err } = await runCommand('version');
expect(setStub.callCount).to.equal(0);
expect(getStub.callCount).to.equal(1);
expect(getStub.firstCall.args).to.deep.equal([checker.cacheFile]);
expect(out.join('')).to.equal(packageJSON.version + '\n');
expect(err.join('')).to.equal('');
});
itSS('should NOT query the npm registry (cached value)', async () => {
const mockCache: ReleaseTimestampsByVersion = {
lastFetched: '1970-01-01T00:00:00.000Z',
};
// pretend the next major was released just under half a year ago
mockCache[nextMajorVersion] = new Date(
checker.now - (checker.deprecationDays - 1) * checker.msInDay,
).toISOString();
getStub.resolves(mockCache);
const { out, err } = await runCommand('version');
expect(setStub.callCount).to.equal(0);
expect(getStub.callCount).to.equal(1);
expect(getStub.firstCall.args).to.deep.equal([checker.cacheFile]);
expect(out.join('')).to.equal(packageJSON.version + '\n');
expect(err.join('')).to.equal('');
});
});

View File

@ -28,8 +28,8 @@ import { streamToBuffer } from 'tar-utils';
import { URL } from 'url'; import { URL } from 'url';
import { stripIndent } from '../lib/utils/lazy'; import { stripIndent } from '../lib/utils/lazy';
import { BuilderMock } from './builder-mock'; import { BuilderMock } from './nock/builder-mock';
import { DockerMock } from './docker-mock'; import { DockerMock } from './nock/docker-mock';
import { import {
cleanOutput, cleanOutput,
deepJsonParse, deepJsonParse,

View File

@ -1,6 +1,6 @@
/** /**
* @license * @license
* Copyright 2019-2020 Balena Ltd. * Copyright 2019-2021 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,36 +15,59 @@
* limitations under the License. * limitations under the License.
*/ */
import { execFile } from 'child_process';
import intercept = require('intercept-stdout');
import * as _ from 'lodash'; import * as _ from 'lodash';
import { promises as fs } from 'fs';
import * as nock from 'nock';
import * as path from 'path'; import * as path from 'path';
import * as balenaCLI from '../build/app'; import * as packageJSON from '../package.json';
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);
interface TestOutput { export interface TestOutput {
err: string[]; // stderr err: string[]; // stderr
out: string[]; // stdout out: string[]; // stdout
exitCode?: number; // process.exitCode exitCode?: number; // process.exitCode
} }
function matchesNodeEngineVersionWarn(msg: string) {
if (/^-----+\r?\n?$/.test(msg)) {
return true;
}
const cleanup = (line: string): string[] =>
line
.replace(/-----+/g, '')
.replace(/"\d+\.\d+\.\d+"/, '"x.y.z"')
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l);
const { getNodeEngineVersionWarn } = require('../build/utils/messages');
let nodeEngineWarn: string = getNodeEngineVersionWarn(
'x.y.z',
packageJSON.engines.node,
);
const nodeEngineWarnArray = cleanup(nodeEngineWarn);
nodeEngineWarn = nodeEngineWarnArray.join('\n');
msg = cleanup(msg).join('\n');
return msg === nodeEngineWarn || nodeEngineWarnArray.includes(msg);
}
/** /**
* Filter stdout / stderr lines to remove lines that start with `[debug]` and * Filter stdout / stderr lines to remove lines that start with `[debug]` and
* other lines that can be ignored for testing purposes. * other lines that can be ignored for testing purposes.
* @param testOutput * @param testOutput
*/ */
function filterCliOutputForTests(testOutput: TestOutput): TestOutput { export function filterCliOutputForTests({
const { matchesNodeEngineVersionWarn } = err,
require('../automation/utils') as typeof import('../automation/utils'); out,
}: {
err: string[];
out: string[];
}): { err: string[]; out: string[] } {
return { return {
exitCode: testOutput.exitCode, err: err.filter(
err: testOutput.err.filter(
(line: string) => (line: string) =>
line &&
!line.match(/\[debug\]/i) && !line.match(/\[debug\]/i) &&
// TODO stop this warning message from appearing when running // TODO stop this warning message from appearing when running
// sdk.setSharedOptions multiple times in the same process // sdk.setSharedOptions multiple times in the same process
@ -52,7 +75,7 @@ function filterCliOutputForTests(testOutput: TestOutput): TestOutput {
!line.startsWith('WARN: disabling Sentry.io error reporting') && !line.startsWith('WARN: disabling Sentry.io error reporting') &&
!matchesNodeEngineVersionWarn(line), !matchesNodeEngineVersionWarn(line),
), ),
out: testOutput.out.filter((line: string) => !line.match(/\[debug\]/i)), out: out.filter((line: string) => line && !line.match(/\[debug\]/i)),
}; };
} }
@ -61,6 +84,9 @@ function filterCliOutputForTests(testOutput: TestOutput): TestOutput {
* @param cmd Command to execute, e.g. `push myApp` (without 'balena' prefix) * @param cmd Command to execute, e.g. `push myApp` (without 'balena' prefix)
*/ */
async function runCommandInProcess(cmd: string): Promise<TestOutput> { async function runCommandInProcess(cmd: string): Promise<TestOutput> {
const balenaCLI = await import('../build/app');
const intercept = await import('intercept-stdout');
const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')]; const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')];
const err: string[] = []; const err: string[] = [];
@ -85,12 +111,13 @@ async function runCommandInProcess(cmd: string): Promise<TestOutput> {
} finally { } finally {
unhookIntercept(); unhookIntercept();
} }
return filterCliOutputForTests({ const filtered = filterCliOutputForTests({ err, out });
err, return {
out, err: filtered.err,
out: filtered.out,
// this makes sense if `process.exit()` was stubbed with sinon // this makes sense if `process.exit()` was stubbed with sinon
exitCode: process.exitCode, exitCode: process.exitCode,
}); };
} }
/** /**
@ -129,6 +156,7 @@ async function runCommandInSubprocess(
// override default proxy exclusion to allow proxying of requests to 127.0.0.1 // override default proxy exclusion to allow proxying of requests to 127.0.0.1
BALENARC_DO_PROXY: '127.0.0.1,localhost', BALENARC_DO_PROXY: '127.0.0.1,localhost',
}; };
const { execFile } = await import('child_process');
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const child = execFile( const child = execFile(
standalonePath, standalonePath,
@ -141,11 +169,12 @@ async function runCommandInSubprocess(
// non-zero exit code. Usually this is harmless/expected, as // non-zero exit code. Usually this is harmless/expected, as
// the CLI child process is tested for error conditions. // the CLI child process is tested for error conditions.
if ($error && process.env.DEBUG) { if ($error && process.env.DEBUG) {
console.error(` const msg = `
[debug] Error (possibly expected) executing child CLI process "${standalonePath}" Error (possibly expected) executing child CLI process "${standalonePath}"
------------------------------------------------------------------ ${$error}`;
${$error} const { warnify } =
------------------------------------------------------------------`); require('../build/utils/messages') as typeof import('../build/utils/messages');
console.error(warnify(msg, '[debug] '));
} }
resolve(); resolve();
}, },
@ -166,11 +195,16 @@ ${$error}
.filter((l) => l) .filter((l) => l)
.map((l) => l + '\n'); .map((l) => l + '\n');
return filterCliOutputForTests({ const filtered = filterCliOutputForTests({
exitCode,
err: splitLines(stderr), err: splitLines(stderr),
out: splitLines(stdout), out: splitLines(stdout),
}); });
return {
err: filtered.err,
out: filtered.out,
// this makes sense if `process.exit()` was stubbed with sinon
exitCode,
};
} }
/** /**
@ -190,11 +224,12 @@ export async function runCommand(cmd: string): Promise<TestOutput> {
); );
} }
try { try {
const { promises: fs } = await import('fs');
await fs.access(standalonePath); await fs.access(standalonePath);
} catch { } catch {
throw new Error(`Standalone executable not found: "${standalonePath}"`); throw new Error(`Standalone executable not found: "${standalonePath}"`);
} }
const proxy = await import('./proxy-server'); const proxy = await import('./nock/proxy-server');
const [proxyPort] = await proxy.createProxyServerOnce(); const [proxyPort] = await proxy.createProxyServerOnce();
return runCommandInSubprocess(cmd, proxyPort); return runCommandInSubprocess(cmd, proxyPort);
} else { } else {
@ -202,22 +237,6 @@ export async function runCommand(cmd: string): Promise<TestOutput> {
} }
} }
export const balenaAPIMock = () => {
if (!nock.isActive()) {
nock.activate();
}
return nock(/./).get('/config/vars').reply(200, {
reservedNames: [],
reservedNamespaces: [],
invalidRegex: '/^d|W/',
whiteListedNames: [],
whiteListedNamespaces: [],
blackListedNames: [],
configVarSchema: [],
});
};
export function cleanOutput( export function cleanOutput(
output: string[] | string, output: string[] | string,
collapseBlank = false, collapseBlank = false,
@ -226,11 +245,17 @@ export function cleanOutput(
? (line: string) => monochrome(line.trim()).replace(/\s{2,}/g, ' ') ? (line: string) => monochrome(line.trim()).replace(/\s{2,}/g, ' ')
: (line: string) => monochrome(line.trim()); : (line: string) => monochrome(line.trim());
return _(_.castArray(output)) const result: string[] = [];
.map((log: string) => log.split('\n').map(cleanLine)) output = typeof output === 'string' ? [output] : output;
.flatten() for (const lines of output) {
.compact() for (let line of lines.split('\n')) {
.value(); line = cleanLine(line);
if (line) {
result.push(line);
}
}
}
return result;
} }
/** /**
@ -320,6 +345,7 @@ export function deepJsonParse(data: any): any {
export async function switchSentry( export async function switchSentry(
enabled: boolean | undefined, enabled: boolean | undefined,
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
const balenaCLI = await import('../build/app');
const sentryOpts = (await balenaCLI.setupSentry()).getClient()?.getOptions(); const sentryOpts = (await balenaCLI.setupSentry()).getClient()?.getOptions();
if (sentryOpts) { if (sentryOpts) {
const sentryStatus = sentryOpts.enabled; const sentryStatus = sentryOpts.enabled;

View File

@ -21,7 +21,7 @@ import * as path from 'path';
import { NockMock, ScopeOpts } from './nock-mock'; import { NockMock, ScopeOpts } from './nock-mock';
export const apiResponsePath = path.normalize( export const apiResponsePath = path.normalize(
path.join(__dirname, 'test-data', 'api-response'), path.join(__dirname, '..', 'test-data', 'api-response'),
); );
const jHeader = { 'Content-Type': 'application/json' }; const jHeader = { 'Content-Type': 'application/json' };

View File

@ -22,7 +22,7 @@ import * as zlib from 'zlib';
import { NockMock } from './nock-mock'; import { NockMock } from './nock-mock';
export const builderResponsePath = path.normalize( export const builderResponsePath = path.normalize(
path.join(__dirname, 'test-data', 'builder-response'), path.join(__dirname, '..', 'test-data', 'builder-response'),
); );
export class BuilderMock extends NockMock { export class BuilderMock extends NockMock {

View File

@ -21,7 +21,7 @@ import * as path from 'path';
import { NockMock, ScopeOpts } from './nock-mock'; import { NockMock, ScopeOpts } from './nock-mock';
export const dockerResponsePath = path.normalize( export const dockerResponsePath = path.normalize(
path.join(__dirname, 'test-data', 'docker-response'), path.join(__dirname, '..', 'test-data', 'docker-response'),
); );
export class DockerMock extends NockMock { export class DockerMock extends NockMock {

50
tests/nock/npm-mock.ts Normal file
View File

@ -0,0 +1,50 @@
/**
* @license
* Copyright 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.
* 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 { NockMock } from './nock-mock';
const jHeader = { 'Content-Type': 'application/json' };
export class NpmMock extends NockMock {
constructor() {
super(/registry\.npmjs\.org/);
}
public expectGetBalenaCli({
version,
publishedAt,
notFound = false,
optional = false,
persist = false,
}: {
version: string;
publishedAt: string;
notFound?: boolean;
optional?: boolean;
persist?: boolean;
}) {
const interceptor = this.optGet(`/balena-cli/${version}`, {
optional,
persist,
});
if (notFound) {
interceptor.reply(404, `version not found: ${version}`, jHeader);
} else {
interceptor.reply(200, { versionist: { publishedAt } }, jHeader);
}
}
}

View File

@ -22,7 +22,7 @@ import { Readable } from 'stream';
import { NockMock, ScopeOpts } from './nock-mock'; import { NockMock, ScopeOpts } from './nock-mock';
export const dockerResponsePath = path.normalize( export const dockerResponsePath = path.normalize(
path.join(__dirname, 'test-data', 'docker-response'), path.join(__dirname, '..', 'test-data', 'docker-response'),
); );
export class SupervisorMock extends NockMock { export class SupervisorMock extends NockMock {