@salesforce/plugin-release-management
Version:
A plugin for preparing and publishing npm packages
430 lines • 16.2 kB
JavaScript
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs/promises';
import shelljs from 'shelljs';
import { Flags, SfCommand, Ux } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import { ensure } from '@salesforce/ts-types';
import got from 'got';
import chalk from 'chalk';
import { Channel, CLI } from '../../../types.js';
import { AmazonS3, download } from '../../../amazonS3.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-release-management', 'cli.install.test');
const ux = new Ux();
var Method;
(function (Method) {
let Type;
(function (Type) {
Type["INSTALLER"] = "installer";
Type["NPM"] = "npm";
Type["TARBALL"] = "tarball";
})(Type = Method.Type || (Method.Type = {}));
class Base {
options;
static TEST_TARGETS = {
[CLI.SF]: [CLI.SF],
[CLI.SFDX]: [CLI.SFDX, CLI.SF],
};
constructor(options) {
this.options = options;
}
async execute() {
const { service, available } = await this.ping();
if (!available) {
ux.warn(`${service} is not currently available.`);
const results = {
[this.options.method]: {},
};
for (const cli of this.getTargets()) {
results[this.options.method][cli] = false;
}
return results;
}
switch (process.platform) {
case 'darwin': {
return this.darwin();
}
case 'win32': {
return this.win32();
}
case 'linux': {
return this.linux();
}
default:
break;
}
return {};
}
// eslint-disable-next-line class-methods-use-this
async ping() {
return Promise.resolve({ available: true, service: 'Service' });
}
// eslint-disable-next-line class-methods-use-this
logResult(cli, success) {
const msg = success ? chalk.green('true') : chalk.red('false');
ux.log(`${chalk.bold(`${cli} Success`)}: ${msg}`);
}
getTargets() {
return Base.TEST_TARGETS[this.options.cli];
}
}
Method.Base = Base;
})(Method || (Method = {}));
class Tarball extends Method.Base {
options;
s3;
paths = {
darwin: ['x64.tar.gz', 'x64.tar.xz'],
win32: [
'x64.tar.gz',
'x86.tar.gz',
// .xz is not supported by powershell's tar command
// 'x64.tar.xz',
// 'x86.tar.xz'
],
linux: ['x64.tar.gz', 'x64.tar.xz'],
'linux-arm': ['arm.tar.gz', 'arm.tar.xz'],
};
constructor(options) {
super(options);
this.options = options;
this.s3 = new AmazonS3({ cli: options.cli, channel: options.channel });
}
async darwin() {
return this.installAndTest('darwin');
}
async win32() {
return this.installAndTest('win32');
}
async linux() {
return this.installAndTest('linux');
}
async ping() {
return this.s3.ping();
}
async installAndTest(platform) {
const tarballs = this.getTarballs(platform);
const results = {};
for (const [tarball, location] of Object.entries(tarballs)) {
try {
// eslint-disable-next-line no-await-in-loop
await download(tarball, location);
// eslint-disable-next-line no-await-in-loop
const extracted = await this.extract(location);
const testResults = this.test(extracted);
for (const [cli, success] of Object.entries(testResults)) {
this.logResult(cli, success);
}
results[tarball] = testResults;
}
catch {
results[tarball] = {};
for (const cli of this.getTargets()) {
results[tarball][cli] = false;
}
}
ux.log();
}
return results;
}
getTarballs(platform) {
const paths = platform === 'linux' && os.arch().includes('arm') ? this.paths['linux-arm'] : this.paths[platform];
const s3Tarballs = paths.map((p) => `${this.s3.directory}/channels/${this.options.channel}/${this.options.cli}-${platform}-${p}`);
const tarballs = {};
for (const tarball of s3Tarballs) {
const name = path.basename(tarball);
const location = path.join(this.options.directory, name);
tarballs[tarball] = location;
}
return tarballs;
}
async extract(file) {
const dir = path.join(this.options.directory, path.basename(file).replace(/\./g, '-'));
await fs.mkdir(dir, { recursive: true });
return new Promise((resolve, reject) => {
ux.spinner.start(`Unpacking ${chalk.cyan(path.basename(file))} to ${dir}`);
const cmd = process.platform === 'win32'
? `tar -xf ${file} -C ${dir} --strip-components 1 --exclude node_modules/.bin`
: `tar -xf ${file} -C ${dir} --strip-components 1`;
const opts = process.platform === 'win32' ? { shell: 'powershell.exe' } : {};
shelljs.exec(cmd, { ...opts, silent: true }, (code, stdout, stderr) => {
if (code === 0) {
ux.spinner.stop();
ux.log(stdout);
resolve(dir);
}
else {
ux.log('stdout:', stdout);
ux.log('stderr:', stderr);
reject();
}
});
});
}
test(directory) {
const results = {};
for (const cli of this.getTargets()) {
const executable = path.join(directory, 'bin', cli);
ux.log(`Testing ${chalk.cyan(executable)}`);
const result = process.platform === 'win32'
? shelljs.exec(`cmd /c "${executable}.cmd" --version`)
: shelljs.exec(`${executable} --version`);
results[cli] = result.code === 0;
}
return results;
}
}
class Npm extends Method.Base {
options;
static STATUS_URL = 'https://status.npmjs.org/api/v2/status.json';
package;
constructor(options) {
super(options);
this.options = options;
const name = options.cli === CLI.SF ? '@salesforce/cli' : 'sfdx-cli';
const tag = options.channel === Channel.STABLE ? 'latest' : 'latest-rc';
this.package = `${name}@${tag}`;
}
async darwin() {
return this.installAndTest();
}
async win32() {
return this.installAndTest();
}
async linux() {
return this.installAndTest();
}
// eslint-disable-next-line class-methods-use-this
async ping() {
// I'm not confident that this is the best way to preempt any issues related to Npm's availability. Mainly
// because I couldn't find any documentation related to what status indicators might be used and when.
const response = await got.get(Npm.STATUS_URL).json();
return { service: 'Npm', available: ['none', 'minor'].includes(response.status.indicator) };
}
async installAndTest() {
try {
await this.install();
}
catch {
const results = {};
for (const cli of this.getTargets()) {
results[cli] = false;
}
return { [this.package]: results };
}
const testResults = this.test();
for (const [cli, success] of Object.entries(testResults)) {
this.logResult(cli, success);
}
ux.log();
return { [this.package]: testResults };
}
async install() {
ux.spinner.start(`Installing: ${chalk.cyan(this.package)}`);
return new Promise((resolve, reject) => {
shelljs.exec(`npm install ${this.package}`, { silent: true, cwd: this.options.directory }, (code, stdout, stderr) => {
if (code === 0) {
ux.spinner.stop();
ux.log(stdout);
resolve();
}
else {
ux.spinner.stop('Failed');
ux.log(stdout);
ux.log(stderr);
reject();
}
});
});
}
test() {
const results = {};
const executable = path.join(this.options.directory, 'node_modules', '.bin', this.options.cli);
ux.log(`Testing ${chalk.cyan(executable)}`);
const result = process.platform === 'win32'
? shelljs.exec(process.platform === 'win32' ? `cmd /c "${executable}" --version` : `${executable} --version`)
: shelljs.exec(`${executable} --version`);
results[this.options.cli] = result.code === 0;
return results;
}
}
class Installer extends Method.Base {
options;
s3;
constructor(options) {
super(options);
this.options = options;
this.s3 = new AmazonS3({ cli: options.cli, channel: options.channel });
}
async darwin() {
const pkg = `${this.options.cli}.pkg`;
const url = `${this.s3.directory}/channels/${this.options.channel}/${pkg}`;
const location = path.join(this.options.directory, pkg);
await download(url, location);
const result = shelljs.exec(`sudo installer -pkg ${location} -target /`);
const results = {};
if (result.code === 0) {
const testResults = this.nixTest();
for (const [cli, success] of Object.entries(testResults)) {
this.logResult(cli, success);
}
results[url] = testResults;
}
else {
results[url] = {};
for (const cli of this.getTargets()) {
this.logResult(this.options.cli, false);
results[url][cli] = false;
}
}
ux.log();
return results;
}
async win32() {
const executables = [`${this.options.cli}-x64.exe`, `${this.options.cli}-x86.exe`];
const results = {};
for (const exe of executables) {
const url = `${this.s3.directory}/channels/${this.options.channel}/${exe}`;
const location = path.join(this.options.directory, exe);
// eslint-disable-next-line no-await-in-loop
await download(url, location);
const installLocation = `C:\\install-test\\${this.options.cli}\\${exe.includes('x86') ? 'x86' : 'x64'}`;
const cmd = `Start-Process -Wait -FilePath "${location}" -ArgumentList "/S", "/D=${installLocation}" -PassThru`;
ux.log(`Installing ${chalk.cyan(exe)} to ${installLocation}...`);
const result = shelljs.exec(cmd, { shell: 'powershell.exe' });
if (result.code === 0) {
const testResults = this.win32Test(installLocation);
for (const [cli, success] of Object.entries(testResults)) {
this.logResult(cli, success);
}
results[url] = testResults;
}
else {
results[url] = {};
for (const cli of this.getTargets()) {
this.logResult(this.options.cli, false);
results[url][cli] = false;
}
}
}
return results;
}
// eslint-disable-next-line @typescript-eslint/require-await, class-methods-use-this
async linux() {
throw new Error('Installers not supported for linux.');
}
async ping() {
return this.s3.ping();
}
win32Test(installLocation) {
const results = {};
for (const cli of this.getTargets()) {
const binaryPath = path.join(installLocation, 'bin', `${cli}.cmd`);
ux.log(`Testing ${chalk.cyan(binaryPath)}`);
const result = shelljs.exec(`cmd /c "${binaryPath}" --version`);
results[cli] =
result.code === 0 && binaryPath.includes('x86')
? result.stdout.includes('win32-x86')
: result.stdout.includes('win32-x64');
}
return results;
}
nixTest() {
const results = {};
for (const cli of this.getTargets()) {
const binaryPath = `/usr/local/bin/${cli}`;
ux.log(`Testing ${chalk.cyan(binaryPath)}`);
const result = shelljs.exec(`${binaryPath} --version`);
results[cli] = result.code === 0;
}
return results;
}
}
export default class Test extends SfCommand {
static description = messages.getMessage('description');
static summary = messages.getMessage('description');
static examples = messages.getMessages('examples');
static flags = {
cli: Flags.string({
summary: messages.getMessage('flags.cli.summary'),
options: Object.values(CLI),
char: 'c',
required: true,
}),
method: Flags.string({
summary: messages.getMessage('flags.method.summary'),
options: Object.values(Method.Type),
char: 'm',
required: true,
}),
channel: Flags.string({
summary: messages.getMessage('flags.channel.summary'),
options: Object.values(Channel),
default: 'stable',
}),
'output-file': Flags.string({
summary: messages.getMessage('flags.output-file.summary'),
default: 'test-results.json',
}),
};
async run() {
const { flags } = await this.parse(Test);
const cli = ensure(flags.cli);
const method = ensure(flags.method);
const channel = ensure(flags.channel);
const outputFile = ensure(flags['output-file']);
const directory = await makeWorkingDir(cli, channel, method);
ux.log(`Working Directory: ${directory}`);
ux.log();
let results = {};
switch (method) {
case Method.Type.TARBALL: {
const tarball = new Tarball({ cli, channel, directory, method });
results = await tarball.execute();
break;
}
case Method.Type.NPM: {
const npm = new Npm({ cli, channel, directory, method });
results = await npm.execute();
break;
}
case Method.Type.INSTALLER: {
const installer = new Installer({ cli, channel, directory, method });
results = await installer.execute();
break;
}
}
const hasFailures = Object.values(results)
.flatMap(Object.values)
.some((r) => !r);
if (hasFailures)
process.exitCode = 1;
const fileData = JSON.stringify({ status: process.exitCode ?? 0, results }, null, 2);
await fs.writeFile(outputFile, fileData, {
encoding: 'utf8',
mode: '600',
});
ux.styledJSON(results);
ux.log(`Results written to ${outputFile}`);
}
}
const makeWorkingDir = async (cli, channel, method) => {
const tmpDir = path.join(os.tmpdir(), 'cli-install-test', cli, channel, method);
// ensure that we are starting with a clean directory
try {
await fs.rm(tmpDir, { recursive: true, force: true });
}
catch {
// error means that folder doesn't exist which is okay
}
await fs.mkdir(tmpDir, { recursive: true });
return tmpDir;
};
//# sourceMappingURL=test.js.map