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 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
latest v10 release, would remain compatible with the balenaCloud backend for one
year from the date when v11.0.0 is released.
following major version is released. For example, balena CLI v11.36.0, as the
latest v11 release, would remain compatible with the balenaCloud backend for one
year from the date when v12.0.0 was released.
At the end of this period, the older major version is considered deprecated and
some of the functionality that depends on balenaCloud services may stop working
at any time.
Users are encouraged to regularly update the balena CLI to the latest version.
Half way through to that period (6 months after the release of the next major
version), older major versions of the balena CLI will start printing a deprecation
warning message when it is used interactively (when `stderr` is attached to a TTY
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)

View File

@ -22,25 +22,26 @@ import * as archiver from 'archiver';
import * as Bluebird from 'bluebird';
import { execFile } from 'child_process';
import * as filehound from 'filehound';
import { Stats } from 'fs';
import * as fs from 'fs-extra';
import * as klaw from 'klaw';
import * as _ from 'lodash';
import * as path from 'path';
import * as rimraf from 'rimraf';
import * as semver from 'semver';
import * as util from 'util';
import * as klaw from 'klaw';
import { Stats } from 'fs';
import { promisify } from 'util';
import { stripIndent } from '../lib/utils/lazy';
import {
diffLines,
getSubprocessStdout,
loadPackageJson,
ROOT,
StdOutTap,
whichSpawn,
} from './utils';
const execFileAsync = promisify(execFile);
export const packageJSON = loadPackageJson();
export const version = 'v' + packageJSON.version;
const arch = process.arch;
@ -246,7 +247,17 @@ async function testPkg() {
console.log(`Testing standalone package "${pkgBalenaPath}"...`);
// Run `balena version -j`, parse its stdout as JSON, and check that the
// 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 pkgNodeMajorVersion = 0;
try {
@ -263,6 +274,10 @@ async function testPkg() {
`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)');
}
@ -411,8 +426,6 @@ async function renameInstallerFiles() {
async function signWindowsInstaller() {
if (process.env.CSC_LINK && process.env.CSC_KEY_PASSWORD) {
const exeName = renamedOclifInstallers[process.platform];
const execFileAsync = util.promisify<string, string[], void>(execFile);
console.log(`Signing installer "${exeName}"`);
await execFileAsync(MSYS2_BASH, [
'sign-exe.sh',

View File

@ -21,22 +21,6 @@ import * as path from 'path';
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 */
export class StdOutTap {
public stdoutBuf: string[] = [];
@ -104,60 +88,6 @@ export function loadPackageJson() {
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:
* "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 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
latest v10 release, would remain compatible with the balenaCloud backend for one
year from the date when v11.0.0 is released.
following major version is released. For example, balena CLI v11.36.0, as the
latest v11 release, would remain compatible with the balenaCloud backend for one
year from the date when v12.0.0 was released.
At the end of this period, the older major version is considered deprecated and
some of the functionality that depends on balenaCloud services may stop working
at any time.
Users are encouraged to regularly update the balena CLI to the latest version.
Half way through to that period (6 months after the release of the next major
version), older major versions of the balena CLI will start printing a deprecation
warning message when it is used interactively (when `stderr` is attached to a TTY
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

View File

@ -16,8 +16,14 @@
*/
import * as packageJSON from '../package.json';
import {
AppOptions,
checkDeletedCommand,
preparseArgs,
unsupportedFlag,
} from './preparser';
import { CliSettings } from './utils/bootstrap';
import { onceAsync, stripIndent } from './utils/lazy';
import { onceAsync } from './utils/lazy';
/**
* Sentry.io setup
@ -43,13 +49,8 @@ export const setupSentry = onceAsync(async () => {
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/
------------------------------------------------------------------------------
`);
const { getNodeEngineVersionWarn } = await import('./utils/messages');
console.warn(getNodeEngineVersionWarn(process.version, validNodeVersions));
}
}
@ -93,10 +94,20 @@ async function init() {
}
/** Execute the oclif parser and the CLI command. */
async function oclifRun(
command: string[],
options: import('./preparser').AppOptions,
) {
async function oclifRun(command: string[], options: AppOptions) {
let deprecationPromise: Promise<void>;
// 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 { CustomMain } = await import('./utils/oclif-utils');
let isEEXIT = false;
@ -130,14 +141,12 @@ async function oclifRun(
})(!options.noFlush);
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. */
export async function run(
cliArgs = process.argv,
options: import('./preparser').AppOptions = {},
) {
export async function run(cliArgs = process.argv, options: AppOptions = {}) {
try {
const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap');
normalizeEnvVars();
@ -150,8 +159,6 @@ export async function run(
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));

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 balenaUrl = settings.get('balenaUrl') as string;
const balenaUrl = settings.get<string>('balenaUrl');
const username = await (async () => {
const getStorage = await import('balena-settings-storage');
const dataDirectory = settings.get('dataDirectory') as string;
const dataDirectory = settings.get<string>('dataDirectory');
const storage = getStorage({ dataDirectory });
let token;
try {

View File

@ -46,7 +46,7 @@ export default class BalenaHelp extends Help {
const subject = getHelpSubject(argv);
if (!subject) {
const verbose = argv.includes('-v') || argv.includes('--verbose');
this.showCustomRootHelp(verbose);
console.log(this.getCustomRootHelp(verbose));
return;
}
@ -80,67 +80,106 @@ export default class BalenaHelp extends Help {
throw new ExpectedError(`command ${chalk.cyan.bold(subject)} not found`);
}
showCustomRootHelp(showAllCommands: boolean): void {
const chalk = getChalk();
const bold = chalk.bold;
const cmd = chalk.cyan.bold;
getCustomRootHelp(showAllCommands: boolean): string {
const { bold, cyan } = getChalk();
let commands = this.config.commands;
commands = commands.filter((c) => this.opts.all || !c.hidden);
// Get Primary Commands, sorted as in manual list
const primaryCommands = this.manuallySortedPrimaryCommands.map((pc) => {
return commands.find((c) => c.id === pc.replace(' ', ':'));
});
const primaryCommands = this.manuallySortedPrimaryCommands
.map((pc) => {
return commands.find((c) => c.id === pc.replace(' ', ':'));
})
.filter((c): c is typeof commands[0] => !!c);
// Get the rest as Additional Commands
const additionalCommands = commands.filter(
(c) =>
!this.manuallySortedPrimaryCommands.includes(c.id.replace(':', ' ')),
);
// Find longest usage, and pad usage of first command in each category
// This is to ensure that both categories align visually
const usageLength = commands
.map((c) => c.usage?.length || 0)
.reduce((longest, l) => {
return l > longest ? l : longest;
});
if (
typeof primaryCommands[0]?.usage === 'string' &&
typeof additionalCommands[0]?.usage === 'string'
) {
primaryCommands[0].usage = primaryCommands[0].usage.padEnd(usageLength);
additionalCommands[0].usage =
additionalCommands[0].usage.padEnd(usageLength);
let usageLength = 0;
for (const cmd of primaryCommands) {
usageLength = Math.max(usageLength, cmd.usage?.length || 0);
}
// Output help
console.log(bold('USAGE'));
console.log('$ balena [COMMAND] [OPTIONS]');
console.log(bold('\nPRIMARY COMMANDS'));
console.log(this.formatCommands(primaryCommands));
let additionalCmdSection: string[];
if (showAllCommands) {
console.log(bold('\nADDITIONAL COMMANDS'));
console.log(this.formatCommands(additionalCommands));
// Get the rest as Additional Commands
const additionalCommands = commands.filter(
(c) =>
!this.manuallySortedPrimaryCommands.includes(c.id.replace(':', ' ')),
);
// Find longest usage, and pad usage of first command in each category
// This is to ensure that both categories align visually
for (const cmd of additionalCommands) {
usageLength = Math.max(usageLength, cmd.usage?.length || 0);
}
if (
typeof primaryCommands[0].usage === 'string' &&
typeof additionalCommands[0].usage === 'string'
) {
primaryCommands[0].usage = primaryCommands[0].usage.padEnd(usageLength);
additionalCommands[0].usage =
additionalCommands[0].usage.padEnd(usageLength);
}
additionalCmdSection = [
bold('\nADDITIONAL COMMANDS'),
this.formatCommands(additionalCommands),
];
} else {
console.log(
`\n${bold('...MORE')} run ${cmd(
'balena help --verbose',
)} to list additional commands.`,
const cmd = cyan.bold('balena help --verbose');
additionalCmdSection = [
`\n${bold('...MORE')} run ${cmd} to list additional commands.`,
];
}
const globalOps = [
['--help, -h', 'display command help'],
['--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 { deprecationPolicyNote, reachingOut } =
require('./utils/messages') as typeof import('./utils/messages');
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)}`),
);
}
console.log(bold('\nGLOBAL OPTIONS'));
console.log(' --help, -h');
console.log(' --debug\n');
const { reachingOut } =
require('./utils/messages') as typeof import('./utils/messages');
console.log(reachingOut);
return outLines.join('\n');
}
protected formatCommands(commands: any[]): string {

View File

@ -14,8 +14,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { stripIndent } from './utils/lazy';
import { exitWithExpectedError } from './errors';
export let unsupportedFlag = false;
export interface AppOptions {
// 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
const debugIndex = cmdSlice.indexOf('--debug');
if (debugIndex > -1) {
if (extractBooleanFlag(cmdSlice, '--debug')) {
process.env.DEBUG = '1';
cmdSlice.splice(debugIndex, 1);
}
unsupportedFlag = extractBooleanFlag(cmdSlice, '--unsupported');
}
// 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;
}
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
* and removed and, if so, exit with an informative error message.
*/
export function checkDeletedCommand(argvSlice: string[]): void {
const { ExpectedError } = require('./errors') as typeof import('./errors');
if (argvSlice[0] === 'help') {
argvSlice = argvSlice.slice(1);
}
@ -101,17 +111,16 @@ export function checkDeletedCommand(argvSlice: string[]): void {
version: string,
verb = 'replaced',
) {
exitWithExpectedError(stripIndent`
Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}.
Please use "balena ${alternative}" instead.
`);
throw new ExpectedError(`\
Note: the command "balena ${oldCmd}" was ${verb} in CLI version ${version}.
Please use "balena ${alternative}" instead.`);
}
function removed(oldCmd: string, alternative: string, version: string) {
let msg = `Note: the command "balena ${oldCmd}" was removed in CLI version ${version}.`;
if (alternative) {
msg = [msg, alternative].join('\n');
}
exitWithExpectedError(msg);
throw new ExpectedError(msg);
}
const stopAlternative =
'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.
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:
* 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.
*/
export function warnify(msg: string) {
const lines = msg.split('\n').map((l) => `[Warn] ${l}`);
export function warnify(msg: string, prefix = '[Warn] ') {
const lines = msg.split('\n').map((l) => `${prefix}${l}`);
const maxLength = Math.max(...lines.map((l) => l.length));
const hr = '-'.repeat(maxLength);
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.
(The --v13 option will be silently ignored in CLI v13.)
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/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": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz",
@ -2534,6 +2545,11 @@
"integrity": "sha512-TikApqV8CkUsI1GGUgVydkJFrq9sYCBWv4fc/r3zvl6Oqe2YU1ASeWBrG5bw1D2XvS07YS3s05hCor/lEtIoYw==",
"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": {
"version": "1.17.7",
"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",
"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": {
"version": "3.0.2",
"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": {
"version": "2.5.28",
"resolved": "https://registry.npmjs.org/@types/rewire/-/rewire-2.5.28.tgz",
@ -4561,6 +4593,11 @@
"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": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
@ -8429,6 +8466,31 @@
"requires": {
"got": "^6.2.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": {
@ -8905,27 +8967,111 @@
}
},
"got": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz",
"integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=",
"version": "11.8.2",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz",
"integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==",
"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"
"@sindresorhus/is": "^4.0.0",
"@szmarczak/http-timer": "^4.0.5",
"@types/cacheable-request": "^6.0.1",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^5.0.3",
"cacheable-request": "^7.0.1",
"decompress-response": "^6.0.0",
"http2-wrapper": "^1.0.0-beta.5.2",
"lowercase-keys": "^2.0.0",
"p-cancelable": "^2.0.0",
"responselike": "^2.0.0"
},
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz",
"integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ="
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"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"
}
},
"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": {
"version": "5.0.0",
"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",
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -15851,6 +16011,11 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
@ -16347,13 +16512,13 @@
"dev": true
},
"sinon": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/sinon/-/sinon-11.1.1.tgz",
"integrity": "sha512-ZSSmlkSyhUWbkF01Z9tEbxZLF/5tRC9eojCdFh33gtQaP7ITQVaMWQHGuFM7Cuf/KEfihuh1tTl3/ABju3AQMg==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/sinon/-/sinon-11.1.2.tgz",
"integrity": "sha512-59237HChms4kg7/sXhiRcUzdSkKuydDeTiamT/jesUVHshBgL8XAmhgFo0GfK6RruMDM/iRSij1EybmMog9cJw==",
"dev": true,
"requires": {
"@sinonjs/commons": "^1.8.3",
"@sinonjs/fake-timers": "^7.1.0",
"@sinonjs/fake-timers": "^7.1.2",
"@sinonjs/samsam": "^6.0.2",
"diff": "^5.0.0",
"nise": "^5.1.0",

View File

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

View File

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

View File

@ -22,9 +22,9 @@ import { promises as fs } from 'fs';
import * as path from 'path';
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 { DockerMock, dockerResponsePath } from '../docker-mock';
import { DockerMock, dockerResponsePath } from '../nock/docker-mock';
import { cleanOutput, runCommand } from '../helpers';
import {
ExpectedTarStreamFiles,

View File

@ -21,9 +21,9 @@ import * as _ from 'lodash';
import * as path from 'path';
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 { DockerMock, dockerResponsePath } from '../docker-mock';
import { DockerMock, dockerResponsePath } from '../nock/docker-mock';
import { cleanOutput, runCommand, switchSentry } from '../helpers';
import {
ExpectedTarStreamFiles,

View File

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

View File

@ -18,7 +18,7 @@
import { expect } from 'chai';
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 { appToFleetOutputMsg, warnify } from '../../../build/utils/messages';

View File

@ -18,7 +18,7 @@
import { expect } from 'chai';
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 { appToFleetOutputMsg, warnify } from '../../../build/utils/messages';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,9 +17,9 @@
import { expect } from 'chai';
import { BalenaAPIMock } from '../balena-api-mock';
import { BalenaAPIMock } from '../nock/balena-api-mock';
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;

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@
import { expect } from 'chai';
import * as fs from 'fs';
import { BalenaAPIMock } from '../balena-api-mock';
import { BalenaAPIMock } from '../nock/balena-api-mock';
import { runCommand } from '../helpers';
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
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';
tmp.setGracefulCleanup();
// Use a temporary dir for tests data
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';
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 { stripIndent } from '../lib/utils/lazy';
import { BuilderMock } from './builder-mock';
import { DockerMock } from './docker-mock';
import { BuilderMock } from './nock/builder-mock';
import { DockerMock } from './nock/docker-mock';
import {
cleanOutput,
deepJsonParse,

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2019-2020 Balena Ltd.
* Copyright 2019-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.
@ -15,36 +15,59 @@
* limitations under the License.
*/
import { execFile } from 'child_process';
import intercept = require('intercept-stdout');
import * as _ from 'lodash';
import { promises as fs } from 'fs';
import * as nock from 'nock';
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 standalonePath = path.resolve(__dirname, '..', 'build-bin', balenaExe);
interface TestOutput {
export interface TestOutput {
err: string[]; // stderr
out: string[]; // stdout
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
* other lines that can be ignored for testing purposes.
* @param testOutput
*/
function filterCliOutputForTests(testOutput: TestOutput): TestOutput {
const { matchesNodeEngineVersionWarn } =
require('../automation/utils') as typeof import('../automation/utils');
export function filterCliOutputForTests({
err,
out,
}: {
err: string[];
out: string[];
}): { err: string[]; out: string[] } {
return {
exitCode: testOutput.exitCode,
err: testOutput.err.filter(
err: err.filter(
(line: string) =>
line &&
!line.match(/\[debug\]/i) &&
// TODO stop this warning message from appearing when running
// 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') &&
!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)
*/
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 err: string[] = [];
@ -85,12 +111,13 @@ async function runCommandInProcess(cmd: string): Promise<TestOutput> {
} finally {
unhookIntercept();
}
return filterCliOutputForTests({
err,
out,
const filtered = filterCliOutputForTests({ err, out });
return {
err: filtered.err,
out: filtered.out,
// this makes sense if `process.exit()` was stubbed with sinon
exitCode: process.exitCode,
});
};
}
/**
@ -129,6 +156,7 @@ async function runCommandInSubprocess(
// override default proxy exclusion to allow proxying of requests to 127.0.0.1
BALENARC_DO_PROXY: '127.0.0.1,localhost',
};
const { execFile } = await import('child_process');
await new Promise<void>((resolve) => {
const child = execFile(
standalonePath,
@ -141,11 +169,12 @@ async function runCommandInSubprocess(
// non-zero exit code. Usually this is harmless/expected, as
// the CLI child process is tested for error conditions.
if ($error && process.env.DEBUG) {
console.error(`
[debug] Error (possibly expected) executing child CLI process "${standalonePath}"
------------------------------------------------------------------
${$error}
------------------------------------------------------------------`);
const msg = `
Error (possibly expected) executing child CLI process "${standalonePath}"
${$error}`;
const { warnify } =
require('../build/utils/messages') as typeof import('../build/utils/messages');
console.error(warnify(msg, '[debug] '));
}
resolve();
},
@ -166,11 +195,16 @@ ${$error}
.filter((l) => l)
.map((l) => l + '\n');
return filterCliOutputForTests({
exitCode,
const filtered = filterCliOutputForTests({
err: splitLines(stderr),
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 {
const { promises: fs } = await import('fs');
await fs.access(standalonePath);
} catch {
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();
return runCommandInSubprocess(cmd, proxyPort);
} 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(
output: string[] | string,
collapseBlank = false,
@ -226,11 +245,17 @@ export function cleanOutput(
? (line: string) => monochrome(line.trim()).replace(/\s{2,}/g, ' ')
: (line: string) => monochrome(line.trim());
return _(_.castArray(output))
.map((log: string) => log.split('\n').map(cleanLine))
.flatten()
.compact()
.value();
const result: string[] = [];
output = typeof output === 'string' ? [output] : output;
for (const lines of output) {
for (let line of lines.split('\n')) {
line = cleanLine(line);
if (line) {
result.push(line);
}
}
}
return result;
}
/**
@ -320,6 +345,7 @@ export function deepJsonParse(data: any): any {
export async function switchSentry(
enabled: boolean | undefined,
): Promise<boolean | undefined> {
const balenaCLI = await import('../build/app');
const sentryOpts = (await balenaCLI.setupSentry()).getClient()?.getOptions();
if (sentryOpts) {
const sentryStatus = sentryOpts.enabled;

View File

@ -21,7 +21,7 @@ import * as path from 'path';
import { NockMock, ScopeOpts } from './nock-mock';
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' };

View File

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

View File

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