web-ext
Version:
A command line tool to help build, run, and test web extensions
652 lines (639 loc) • 24.3 kB
JavaScript
import os from 'os';
import path from 'path';
import { readFileSync } from 'fs';
import camelCase from 'camelcase';
import decamelize from 'decamelize';
import yargs from 'yargs';
import { Parser as yargsParser } from 'yargs/helpers';
import defaultCommands from './cmd/index.js';
import { UsageError } from './errors.js';
import { createLogger, consoleStream as defaultLogStream } from './util/logger.js';
import { coerceCLICustomPreference } from './firefox/preferences.js';
import { checkForUpdates as defaultUpdateChecker } from './util/updates.js';
import { discoverConfigFiles as defaultConfigDiscovery, loadJSConfigFile as defaultLoadJSConfigFile, applyConfigToArgv as defaultApplyConfigToArgv } from './config.js';
const log = createLogger(import.meta.url);
const envPrefix = 'WEB_EXT';
// Default to "development" (the value actually assigned will be interpolated
// by babel-plugin-transform-inline-environment-variables).
const defaultGlobalEnv = "production" || 'development';
export const AMO_BASE_URL = 'https://addons.mozilla.org/api/v5/';
/*
* The command line program.
*/
export class Program {
absolutePackageDir;
yargs;
commands;
shouldExitProgram;
verboseEnabled;
options;
programArgv;
demandedOptions;
constructor(argv, {
absolutePackageDir = process.cwd()
} = {}) {
// This allows us to override the process argv which is useful for
// testing.
// NOTE: process.argv.slice(2) removes the path to node and web-ext
// executables from the process.argv array.
argv = argv || process.argv.slice(2);
this.programArgv = argv;
// NOTE: always initialize yargs explicitly with the package dir
// to avoid side-effects due to yargs looking for its configuration
// section from a package.json file stored in an arbitrary directory
// (e.g. in tests yargs would end up loading yargs config from the
// mocha package.json). web-ext package.json doesn't contain any yargs
// section as it is deprecated and we configure yargs using
// yargs.parserConfiguration. See web-ext#469 for rationale.
const yargsInstance = yargs(argv, absolutePackageDir);
this.absolutePackageDir = absolutePackageDir;
this.verboseEnabled = false;
this.shouldExitProgram = true;
this.yargs = yargsInstance;
this.yargs.parserConfiguration({
'boolean-negation': true
});
this.yargs.strict();
this.yargs.wrap(this.yargs.terminalWidth());
this.commands = {};
this.options = {};
}
command(name, description, executor, commandOptions = {}) {
this.options[camelCase(name)] = commandOptions;
this.yargs.command(name, description, yargsForCmd => {
if (!commandOptions) {
return;
}
return yargsForCmd
// Make sure the user does not add any extra commands. For example,
// this would be a mistake because lint does not accept arguments:
// web-ext lint ./src/path/to/file.js
.demandCommand(0, 0, undefined, 'This command does not take any arguments').strict().exitProcess(this.shouldExitProgram)
// Calling env() will be unnecessary after
// https://github.com/yargs/yargs/issues/486 is fixed
.env(envPrefix).options(commandOptions);
});
this.commands[name] = executor;
return this;
}
setGlobalOptions(options) {
// This is a convenience for setting global options.
// An option is only global (i.e. available to all sub commands)
// with the `global` flag so this makes sure every option has it.
this.options = {
...this.options,
...options
};
Object.keys(options).forEach(key => {
options[key].global = true;
if (options[key].demandOption === undefined) {
// By default, all options should be "demanded" otherwise
// yargs.strict() will think they are missing when declared.
options[key].demandOption = true;
}
});
this.yargs.options(options);
return this;
}
enableVerboseMode(logStream, version) {
if (this.verboseEnabled) {
return;
}
logStream.makeVerbose();
log.info('Version:', version);
this.verboseEnabled = true;
}
// Retrieve the yargs argv object and apply any further fix needed
// on the output of the yargs options parsing.
getArguments() {
// To support looking up required parameters via config files, we need to
// temporarily disable the requiredArguments validation. Otherwise yargs
// would exit early. Validation is enforced by the checkRequiredArguments()
// method, after reading configuration files.
//
// This is an undocumented internal API of yargs! Unit tests to avoid
// regressions are located at: tests/functional/test.cli.sign.js
//
// Replace hack if possible: https://github.com/mozilla/web-ext/issues/1930
const validationInstance = this.yargs.getInternalMethods().getValidationInstance();
const {
requiredArguments
} = validationInstance;
// Initialize demandedOptions (which is going to be set to an object with one
// property for each mandatory global options, then the arrow function below
// will receive as its demandedOptions parameter a new one that also includes
// all mandatory options for the sub command selected).
this.demandedOptions = this.yargs.getDemandedOptions();
validationInstance.requiredArguments = (args, demandedOptions) => {
this.demandedOptions = demandedOptions;
};
let argv;
try {
argv = this.yargs.argv;
} catch (err) {
if (err.name === 'YError' && err.message.startsWith('Unknown argument: ')) {
throw new UsageError(err.message);
}
throw err;
}
validationInstance.requiredArguments = requiredArguments;
// Yargs boolean options doesn't define the no* counterpart
// with negate-boolean on Yargs 15. Define as expected by the
// web-ext execute method.
if (argv.configDiscovery != null) {
argv.noConfigDiscovery = !argv.configDiscovery;
}
if (argv.reload != null) {
argv.noReload = !argv.reload;
}
// Yargs doesn't accept --no-input as a valid option if there isn't a
// --input option defined to be negated, to fix that the --input is
// defined and hidden from the yargs help output and we define here
// the negated argument name that we expect to be set in the parsed
// arguments (and fix https://github.com/mozilla/web-ext/issues/1860).
if (argv.input != null) {
argv.noInput = !argv.input;
}
// Replacement for the "requiresArg: true" parameter until the following bug
// is fixed: https://github.com/yargs/yargs/issues/1098
if (argv.ignoreFiles && !argv.ignoreFiles.length) {
throw new UsageError('Not enough arguments following: ignore-files');
}
if (argv.startUrl && !argv.startUrl.length) {
throw new UsageError('Not enough arguments following: start-url');
}
return argv;
}
// getArguments() disables validation of required parameters, to allow us to
// read parameters from config files first. Before the program continues, it
// must call checkRequiredArguments() to ensure that required parameters are
// defined (in the CLI or in a config file).
checkRequiredArguments(adjustedArgv) {
const validationInstance = this.yargs.getInternalMethods().getValidationInstance();
validationInstance.requiredArguments(adjustedArgv, this.demandedOptions);
}
// Remove WEB_EXT_* environment vars that are not a global cli options
// or an option supported by the current command (See #793).
cleanupProcessEnvConfigs(systemProcess) {
const cmd = yargsParser(this.programArgv)._[0];
const env = systemProcess.env || {};
const toOptionKey = k => decamelize(camelCase(k.replace(envPrefix, '')), {
separator: '-'
});
if (cmd) {
Object.keys(env).filter(k => k.startsWith(envPrefix)).forEach(k => {
const optKey = toOptionKey(k);
const globalOpt = this.options[optKey];
const cmdOpt = this.options[cmd] && this.options[cmd][optKey];
if (!globalOpt && !cmdOpt) {
log.debug(`Environment ${k} not supported by web-ext ${cmd}`);
delete env[k];
}
});
}
}
async execute({
checkForUpdates = defaultUpdateChecker,
systemProcess = process,
logStream = defaultLogStream,
getVersion = defaultVersionGetter,
applyConfigToArgv = defaultApplyConfigToArgv,
discoverConfigFiles = defaultConfigDiscovery,
loadJSConfigFile = defaultLoadJSConfigFile,
shouldExitProgram = true,
globalEnv = defaultGlobalEnv
} = {}) {
this.shouldExitProgram = shouldExitProgram;
this.yargs.exitProcess(this.shouldExitProgram);
this.cleanupProcessEnvConfigs(systemProcess);
const argv = this.getArguments();
const cmd = argv._[0];
const version = await getVersion(this.absolutePackageDir);
const runCommand = this.commands[cmd];
if (argv.verbose) {
this.enableVerboseMode(logStream, version);
}
let adjustedArgv = {
...argv,
webextVersion: version
};
try {
if (cmd === undefined) {
throw new UsageError('No sub-command was specified in the args');
}
if (!runCommand) {
throw new UsageError(`Unknown command: ${cmd}`);
}
if (globalEnv === 'production') {
checkForUpdates({
version
});
}
const configFiles = [];
if (argv.configDiscovery) {
log.debug('Discovering config files. ' + 'Set --no-config-discovery to disable');
const discoveredConfigs = await discoverConfigFiles();
configFiles.push(...discoveredConfigs);
} else {
log.debug('Not discovering config files');
}
if (argv.config) {
configFiles.push(path.resolve(argv.config));
}
if (configFiles.length) {
const niceFileList = configFiles.map(f => f.replace(process.cwd(), '.')).map(f => f.replace(os.homedir(), '~')).join(', ');
log.debug('Applying config file' + `${configFiles.length !== 1 ? 's' : ''}: ` + `${niceFileList}`);
}
for (const configFileName of configFiles) {
const configObject = await loadJSConfigFile(configFileName);
adjustedArgv = applyConfigToArgv({
argv: adjustedArgv,
argvFromCLI: argv,
configFileName,
configObject,
options: this.options
});
}
if (adjustedArgv.verbose) {
// Ensure that the verbose is enabled when specified in a config file.
this.enableVerboseMode(logStream, version);
}
this.checkRequiredArguments(adjustedArgv);
await runCommand(adjustedArgv, {
shouldExitProgram
});
} catch (error) {
if (!(error instanceof UsageError) || adjustedArgv.verbose) {
log.error(`\n${error.stack}\n`);
} else {
log.error(`\n${String(error)}\n`);
}
if (error.code) {
log.error(`Error code: ${error.code}\n`);
}
log.debug(`Command executed: ${cmd}`);
if (this.shouldExitProgram) {
systemProcess.exit(1);
} else {
throw error;
}
}
}
}
//A definition of type of argument for defaultVersionGetter
export async function defaultVersionGetter(absolutePackageDir, {
globalEnv = defaultGlobalEnv
} = {}) {
if (globalEnv === 'production') {
log.debug('Getting the version from package.json');
const packageData = readFileSync(path.join(absolutePackageDir, 'package.json'));
return JSON.parse(packageData).version;
} else {
log.debug('Getting version from the git revision');
// This branch is only reached during development.
// git-rev-sync is in devDependencies, and lazily imported using require.
// This also avoids logspam from https://github.com/mozilla/web-ext/issues/1916
// eslint-disable-next-line import/no-extraneous-dependencies
const git = await import('git-rev-sync');
return `${git.branch(absolutePackageDir)}-${git.long(absolutePackageDir)}`;
}
}
export function throwUsageErrorIfArray(errorMessage) {
return value => {
if (Array.isArray(value)) {
throw new UsageError(errorMessage);
}
return value;
};
}
export async function main(absolutePackageDir, {
getVersion = defaultVersionGetter,
commands = defaultCommands,
argv,
runOptions = {}
} = {}) {
const program = new Program(argv, {
absolutePackageDir
});
const version = await getVersion(absolutePackageDir);
// yargs uses magic camel case expansion to expose options on the
// final argv object. For example, the 'artifacts-dir' option is alternatively
// available as argv.artifactsDir.
program.yargs.usage(`Usage: $0 [options] command
Option values can also be set by declaring an environment variable prefixed
with $${envPrefix}_. For example: $${envPrefix}_SOURCE_DIR=/path is the same as
--source-dir=/path.
To view specific help for any given command, add the command name.
Example: $0 --help run.
`).help('help').alias('h', 'help').env(envPrefix).version(version).demandCommand(1, 'You must specify a command').strict().recommendCommands();
program.setGlobalOptions({
'source-dir': {
alias: 's',
describe: 'Web extension source directory.',
default: process.cwd(),
requiresArg: true,
type: 'string',
coerce: arg => arg ?? undefined
},
'artifacts-dir': {
alias: 'a',
describe: 'Directory where artifacts will be saved.',
default: path.join(process.cwd(), 'web-ext-artifacts'),
normalize: true,
requiresArg: true,
type: 'string'
},
verbose: {
alias: 'v',
describe: 'Show verbose output',
type: 'boolean',
demandOption: false
},
'ignore-files': {
alias: 'i',
describe: 'A list of glob patterns to define which files should be ' + 'ignored. (Example: --ignore-files=path/to/first.js ' + 'path/to/second.js "**/*.log")',
demandOption: false,
// The following option prevents yargs>=11 from parsing multiple values,
// so the minimum value requirement is enforced in execute instead.
// Upstream bug: https://github.com/yargs/yargs/issues/1098
// requiresArg: true,
type: 'array'
},
'no-input': {
describe: 'Disable all features that require standard input',
type: 'boolean',
demandOption: false
},
input: {
// This option is defined to make yargs to accept the --no-input
// defined above, but we hide it from the yargs help output.
hidden: true,
type: 'boolean',
demandOption: false
},
config: {
alias: 'c',
describe: 'Path to a CommonJS config file to set ' + 'option defaults',
default: undefined,
demandOption: false,
requiresArg: true,
type: 'string'
},
'config-discovery': {
describe: 'Discover config files in home directory and ' + 'working directory. Disable with --no-config-discovery.',
demandOption: false,
default: true,
type: 'boolean'
}
});
program.command('build', 'Create an extension package from source', commands.build, {
'as-needed': {
describe: 'Watch for file changes and re-build as needed',
type: 'boolean'
},
filename: {
alias: 'n',
describe: 'Name of the created extension package file.',
default: undefined,
normalize: false,
demandOption: false,
requiresArg: true,
type: 'string',
coerce: arg => arg == null ? undefined : throwUsageErrorIfArray('Multiple --filename/-n option are not allowed')(arg)
},
'overwrite-dest': {
alias: 'o',
describe: 'Overwrite destination package if it exists.',
type: 'boolean'
}
}).command('dump-config', 'Run config discovery and dump the resulting config data as JSON', commands.dumpConfig, {}).command('sign', 'Sign the extension so it can be installed in Firefox', commands.sign, {
'amo-base-url': {
describe: 'Submission API URL prefix',
default: AMO_BASE_URL,
demandOption: true,
type: 'string'
},
'api-key': {
describe: 'API key (JWT issuer) from addons.mozilla.org',
demandOption: true,
type: 'string'
},
'api-secret': {
describe: 'API secret (JWT secret) from addons.mozilla.org',
demandOption: true,
type: 'string'
},
'api-proxy': {
describe: 'Use a proxy to access the signing API. ' + 'Example: https://yourproxy:6000 ',
demandOption: false,
type: 'string'
},
timeout: {
describe: 'Number of milliseconds to wait before giving up',
type: 'number'
},
'approval-timeout': {
describe: 'Number of milliseconds to wait for approval before giving up. ' + 'Set to 0 to disable waiting for approval. Fallback to `timeout` if not set.',
type: 'number'
},
channel: {
describe: "The channel for which to sign the addon. Either 'listed' or 'unlisted'.",
demandOption: true,
type: 'string'
},
'amo-metadata': {
describe: 'Path to a JSON file containing an object with metadata to be passed to the API. ' + 'See https://addons-server.readthedocs.io/en/latest/topics/api/addons.html for details.',
type: 'string'
},
'upload-source-code': {
describe: 'Path to an archive file containing human readable source code of this submission, ' + 'if the code in --source-dir has been processed to make it unreadable. ' + 'See https://extensionworkshop.com/documentation/publish/source-code-submission/ for ' + 'details.',
type: 'string'
}
}).command('run', 'Run the extension', commands.run, {
target: {
alias: 't',
describe: 'The extensions runners to enable. Specify this option ' + 'multiple times to run against multiple targets.',
default: 'firefox-desktop',
demandOption: false,
type: 'array',
choices: ['firefox-desktop', 'firefox-android', 'chromium']
},
firefox: {
alias: ['f', 'firefox-binary'],
describe: 'Path or alias to a Firefox executable such as firefox-bin ' + 'or firefox.exe. ' + 'If not specified, the default Firefox will be used. ' + 'You can specify the following aliases in lieu of a path: ' + 'firefox, beta, nightly, firefoxdeveloperedition (or deved). ' + 'For Flatpak, use `flatpak:org.mozilla.firefox` where ' + '`org.mozilla.firefox` is the application ID.',
demandOption: false,
type: 'string'
},
'firefox-profile': {
alias: 'p',
describe: 'Run Firefox using a copy of this profile. The profile ' + 'can be specified as a directory or a name, such as one ' + 'you would see in the Profile Manager. If not specified, ' + 'a new temporary profile will be created.',
demandOption: false,
type: 'string'
},
'chromium-binary': {
describe: 'Path or alias to a Chromium executable such as ' + 'google-chrome, google-chrome.exe or opera.exe etc. ' + 'If not specified, the default Google Chrome will be used.',
demandOption: false,
type: 'string'
},
'chromium-profile': {
describe: 'Path to a custom Chromium profile',
demandOption: false,
type: 'string'
},
'profile-create-if-missing': {
describe: 'Create the profile directory if it does not already exist',
demandOption: false,
type: 'boolean'
},
'keep-profile-changes': {
describe: 'Run Firefox directly in custom profile. Any changes to ' + 'the profile will be saved.',
demandOption: false,
type: 'boolean'
},
reload: {
describe: 'Reload the extension when source files change. ' + 'Disable with --no-reload.',
demandOption: false,
default: true,
type: 'boolean'
},
'watch-file': {
alias: ['watch-files'],
describe: 'Reload the extension only when the contents of this' + ' file changes. This is useful if you use a custom' + ' build process for your extension',
demandOption: false,
type: 'array'
},
'watch-ignored': {
describe: 'Paths and globs patterns that should not be ' + 'watched for changes. This is useful if you want ' + 'to explicitly prevent web-ext from watching part ' + 'of the extension directory tree, ' + 'e.g. the node_modules folder.',
demandOption: false,
type: 'array'
},
'pre-install': {
describe: 'Pre-install the extension into the profile before ' + 'startup. This is only needed to support older versions ' + 'of Firefox.',
demandOption: false,
type: 'boolean'
},
pref: {
describe: 'Launch firefox with a custom preference ' + '(example: --pref=general.useragent.locale=fr-FR). ' + 'You can repeat this option to set more than one ' + 'preference.',
demandOption: false,
requiresArg: true,
type: 'array',
coerce: arg => arg != null ? coerceCLICustomPreference(arg) : undefined
},
'start-url': {
alias: ['u', 'url'],
describe: 'Launch firefox at specified page',
demandOption: false,
type: 'array'
},
devtools: {
describe: 'Open the DevTools for the installed add-on ' + '(Firefox 106 and later)',
demandOption: false,
type: 'boolean'
},
'browser-console': {
alias: ['bc'],
describe: 'Open the DevTools Browser Console.',
demandOption: false,
type: 'boolean'
},
args: {
alias: ['arg'],
describe: 'Additional CLI options passed to the Browser binary',
demandOption: false,
type: 'array'
},
// Firefox for Android CLI options.
'adb-bin': {
describe: 'Specify a custom path to the adb binary',
demandOption: false,
type: 'string',
requiresArg: true
},
'adb-host': {
describe: 'Connect to adb on the specified host',
demandOption: false,
type: 'string',
requiresArg: true
},
'adb-port': {
describe: 'Connect to adb on the specified port',
demandOption: false,
type: 'string',
requiresArg: true
},
'adb-device': {
alias: ['android-device'],
describe: 'Connect to the specified adb device name',
demandOption: false,
type: 'string',
requiresArg: true
},
'adb-discovery-timeout': {
describe: 'Number of milliseconds to wait before giving up',
demandOption: false,
type: 'number',
requiresArg: true
},
'adb-remove-old-artifacts': {
describe: 'Remove old artifacts directories from the adb device',
demandOption: false,
type: 'boolean'
},
'firefox-apk': {
describe: 'Run a specific Firefox for Android APK. ' + 'Example: org.mozilla.fennec_aurora',
demandOption: false,
type: 'string',
requiresArg: true
},
'firefox-apk-component': {
describe: 'Run a specific Android Component (defaults to <firefox-apk>/.App)',
demandOption: false,
type: 'string',
requiresArg: true
}
}).command('lint', 'Validate the extension source', commands.lint, {
output: {
alias: 'o',
describe: 'The type of output to generate',
type: 'string',
default: 'text',
choices: ['json', 'text']
},
metadata: {
describe: 'Output only metadata as JSON',
type: 'boolean',
default: false
},
'warnings-as-errors': {
describe: 'Treat warnings as errors by exiting non-zero for warnings',
alias: 'w',
type: 'boolean',
default: false
},
pretty: {
describe: 'Prettify JSON output',
type: 'boolean',
default: false
},
privileged: {
describe: 'Treat your extension as a privileged extension',
type: 'boolean',
default: false
},
'self-hosted': {
describe: 'Your extension will be self-hosted. This disables messages ' + 'related to hosting on addons.mozilla.org.',
type: 'boolean',
default: false
},
boring: {
describe: 'Disables colorful shell output',
type: 'boolean',
default: false
}
}).command('docs', 'Open the web-ext documentation in a browser', commands.docs, {});
return program.execute({
getVersion,
...runOptions
});
}
//# sourceMappingURL=program.js.map