diff --git a/lib/cli/base.js b/lib/cli/base.js index ab8fbc4e..83f0f991 100644 --- a/lib/cli/base.js +++ b/lib/cli/base.js @@ -4,7 +4,7 @@ import ConsoleWriter from "@ui5/logger/writers/Console"; export default function(cli) { cli.usage("Usage: ui5 [options]") - .demandCommand(1, "Command required! Please have a look at the help documentation above.") + .demandCommand(1, "Command required") .option("config", { alias: "c", describe: "Path to project configuration file in YAML format", @@ -121,7 +121,7 @@ export default function(cli) { console.log(chalk.bold.yellow("Command Failed:")); console.log(`${msg}`); console.log(""); - console.log(chalk.dim(`See 'ui5 --help' or 'ui5 build --help' for help`)); + console.log(chalk.dim(`See 'ui5 --help'`)); } process.exit(1); }); diff --git a/lib/cli/commands/config.js b/lib/cli/commands/config.js new file mode 100644 index 00000000..bfdc810a --- /dev/null +++ b/lib/cli/commands/config.js @@ -0,0 +1,88 @@ +import chalk from "chalk"; +import process from "node:process"; +import baseMiddleware from "../middlewares/base.js"; + +const configCommand = { + command: "config", + describe: "Get and set UI5 Tooling configuration options", + middlewares: [baseMiddleware], + handler: handleConfig +}; + +configCommand.builder = function(cli) { + return cli + .demandCommand(1, "Command required. Available commands are 'set', 'get', and 'list'") + .command("set [value]", "Set the value for a given configuration key. " + + "Clear an existing configuration by omitting the value", { + handler: handleConfig, + builder: noop, + middlewares: [baseMiddleware], + }) + .command("get ", "Get the value for a given configuration key", { + handler: handleConfig, + builder: noop, + middlewares: [baseMiddleware], + }) + .command("list", "Display the current configuration", { + handler: handleConfig, + builder: noop, + middlewares: [baseMiddleware], + }) + .example("$0 set mavenSnapshotEndpointUrl http://example.com/snapshots/", + "Set a value for the mavenSnapshotEndpointUrl configuration") + .example("$0 set mavenSnapshotEndpointUrl", + "Unset the current value of the mavenSnapshotEndpointUrl configuration"); +}; + +function noop() {} + +async function handleConfig(argv) { + const {_: commandArgs, key, value} = argv; + const command = commandArgs[commandArgs.length - 1]; + + const {default: Configuration} = await import( "@ui5/project/config/Configuration"); + const allowedKeys = ["mavenSnapshotEndpointUrl"]; + + if (["set", "get"].includes(command) && !allowedKeys.includes(key)) { + throw new Error( + `The provided key is not a valid configuration option. Valid options are: ${allowedKeys.join(", ")}`); + } + + const config = await Configuration.fromFile(); + if (command === "list") { + // Print all configuration values to stdout + process.stdout.write(formatJsonForOutput(config.toJson())); + } else if (command === "get") { + // Get a single configuration value and print to stdout + let configValue = config.toJson()[key]; + if (configValue === undefined) { + configValue = ""; + } + process.stdout.write(`${configValue}\n`); + } else if (command === "set") { + const jsonConfig = config.toJson(); + if (value === undefined || value === "") { + delete jsonConfig[key]; + process.stderr.write(`Configuration option ${chalk.bold(key)} has been unset\n`); + } else { + jsonConfig[key] = value; + process.stderr.write(`Configuration option ${chalk.bold(key)} has been updated: +${formatJsonForOutput(jsonConfig, key)}`); + } + + await Configuration.toFile(new Configuration(jsonConfig)); + } else { + throw new Error(`Unknown 'ui5 config' command '${command}'`); + } +} + +function formatJsonForOutput(config, filterKey) { + return Object.keys(config) + .filter((key) => !filterKey || filterKey === key) + .filter((key) => config[key] !== undefined) // Don't print undefined config values + .map((key) => { + return ` ${key} = ${config[key]}\n`; + }).join(""); +} + +export default configCommand; diff --git a/test/lib/cli/base.js b/test/lib/cli/base.js index 85954511..ae0d0650 100644 --- a/test/lib/cli/base.js +++ b/test/lib/cli/base.js @@ -86,7 +86,7 @@ test.serial("Yargs error handling", async (t) => { t.deepEqual(consoleLogStub.getCall(1).args, ["Unknown argument: invalid"], "Correct error log"); t.deepEqual(consoleLogStub.getCall(2).args, [""], "Correct error log"); t.deepEqual(consoleLogStub.getCall(3).args, [ - chalk.dim(`See 'ui5 --help' or 'ui5 build --help' for help`) + chalk.dim(`See 'ui5 --help'`) ], "Correct error log"); }); diff --git a/test/lib/cli/commands/config.js b/test/lib/cli/commands/config.js new file mode 100644 index 00000000..85ee69ec --- /dev/null +++ b/test/lib/cli/commands/config.js @@ -0,0 +1,260 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; +import chalk from "chalk"; + +function getDefaultArgv() { + // This has been taken from the actual argv object yargs provides + return { + "_": ["build"], + "loglevel": "info", + "log-level": "info", + "logLevel": "info", + "perf": false, + "silent": false, + "include-all-dependencies": false, + "all": false, + "a": false, + "includeAllDependencies": false, + "create-build-manifest": false, + "createBuildManifest": false, + "dest": "./dist", + "clean-dest": false, + "cleanDest": false, + "experimental-css-variables": false, + "experimentalCssVariables": false, + "$0": "ui5" + }; +} + +test.beforeEach(async (t) => { + t.context.argv = getDefaultArgv(); + + t.context.builder = sinon.stub().resolves(); + t.context.stdoutWriteStub = sinon.stub(process.stdout, "write"); + t.context.stderrWriteStub = sinon.stub(process.stderr, "write"); + t.context.configToJson = sinon.stub().returns({}); + + const {default: Configuration} = await import( "@ui5/project/config/Configuration"); + t.context.Configuration = Configuration; + sinon.stub(Configuration, "fromFile").resolves(new Configuration({})); + sinon.stub(Configuration, "toFile").resolves(); + + t.context.config = await esmock.p("../../../../lib/cli/commands/config.js", { + "@ui5/project/config/Configuration": t.context.Configuration + }); +}); + +test.afterEach.always((t) => { + sinon.restore(); + esmock.purge(t.context.config); +}); + +test.serial("ui5 config list", async (t) => { + const {config, argv, stdoutWriteStub, stderrWriteStub, Configuration} = t.context; + + const configurationStub = new Configuration({}); + sinon.stub(configurationStub, "toJson").returns({ + mavenSnapshotEndpointUrl: "my/url", + otherConfig: false, + pony: 130, + horses: null, + cows: undefined // Won't be rendered + }); + Configuration.fromFile.resolves(configurationStub); + + argv["_"] = ["list"]; + await config.handler(argv); + + t.is(stdoutWriteStub.firstCall.firstArg, ` mavenSnapshotEndpointUrl = my/url + otherConfig = false + pony = 130 + horses = null +`, "Logged expected text to stdout"); + t.is(stderrWriteStub.callCount, 0, "Nothing written to stderr"); +}); + +test.serial("ui5 config list: No config", async (t) => { + const {config, argv, stdoutWriteStub, stderrWriteStub} = t.context; + + argv["_"] = ["list"]; + await config.handler(argv); + + t.is(stdoutWriteStub.firstCall.firstArg, "", + "Logged no text to stdout"); + t.is(stderrWriteStub.callCount, 0, "Nothing written to stderr"); +}); + +test.serial("ui5 config get", async (t) => { + const {config, argv, stdoutWriteStub, stderrWriteStub, Configuration} = t.context; + + Configuration.fromFile.resolves(new Configuration({ + mavenSnapshotEndpointUrl: "my/url" + })); + + argv["_"] = ["get"]; + argv["key"] = "mavenSnapshotEndpointUrl"; + await config.handler(argv); + + t.is(stdoutWriteStub.firstCall.firstArg, "my/url\n", + "Logged configuration value to stdout"); + t.is(stderrWriteStub.callCount, 0, "Nothing written to stderr"); +}); + +test.serial("ui5 config get: Empty value", async (t) => { + const {config, argv, stdoutWriteStub} = t.context; + + argv["_"] = ["get"]; + argv["key"] = "mavenSnapshotEndpointUrl"; + await config.handler(argv); + + t.is(stdoutWriteStub.firstCall.firstArg, "\n", "Logged no value to console"); +}); + +test.serial("ui5 config set", async (t) => { + const {config, argv, stderrWriteStub, stdoutWriteStub, Configuration} = t.context; + + const configurationStub = new Configuration({}); + sinon.stub(configurationStub, "toJson").returns({ + mavenSnapshotEndpointUrl: "my/url" + }); + + Configuration.fromFile.resolves(configurationStub); + + argv["_"] = ["set"]; + argv["key"] = "mavenSnapshotEndpointUrl"; + argv["value"] = "https://_snapshot_endpoint_"; + await config.handler(argv); + + t.is(stderrWriteStub.firstCall.firstArg, + `Configuration option ${chalk.bold("mavenSnapshotEndpointUrl")} has been updated: + mavenSnapshotEndpointUrl = https://_snapshot_endpoint_\n`, + "Logged expected message to stderr"); + t.is(stdoutWriteStub.callCount, 0, "Nothing written to stdout"); + + t.is(Configuration.toFile.callCount, 1, "Configuration#toFile got called once"); + t.deepEqual(Configuration.toFile.firstCall.firstArg.toJson(), { + mavenSnapshotEndpointUrl: "https://_snapshot_endpoint_" + }, "Configuration#toFile got called with expected argument"); +}); + +test.serial("ui5 config set without a value should delete the configuration", async (t) => { + const {config, argv, stderrWriteStub, stdoutWriteStub, Configuration} = t.context; + + argv["_"] = ["set"]; + argv["key"] = "mavenSnapshotEndpointUrl"; + argv["value"] = undefined; // Simulating no value parameter provided to Yargs + await config.handler(argv); + + t.is(stderrWriteStub.firstCall.firstArg, + `Configuration option ${chalk.bold("mavenSnapshotEndpointUrl")} has been unset\n`, + "Logged expected message to stderr"); + t.is(stdoutWriteStub.callCount, 0, "Nothing written to stdout"); + + t.is(Configuration.toFile.callCount, 1, "Configuration#toFile got called once"); + t.deepEqual(Configuration.toFile.firstCall.firstArg.toJson(), { + mavenSnapshotEndpointUrl: undefined + }, "Configuration#toFile got called with expected argument"); +}); + +test.serial("ui5 config set with an empty value should delete the configuration", async (t) => { + const {config, argv, stderrWriteStub, stdoutWriteStub, Configuration} = t.context; + + argv["_"] = ["set"]; + argv["key"] = "mavenSnapshotEndpointUrl"; + argv["value"] = ""; // Simulating empty value provided to Yargs using quotes "" + await config.handler(argv); + + t.is(stderrWriteStub.firstCall.firstArg, + `Configuration option ${chalk.bold("mavenSnapshotEndpointUrl")} has been unset\n`, + "Logged expected message to stderr"); + t.is(stdoutWriteStub.callCount, 0, "Nothing written to stdout"); + + t.is(Configuration.toFile.callCount, 1, "Configuration#toFile got called once"); + t.deepEqual(Configuration.toFile.firstCall.firstArg.toJson(), { + mavenSnapshotEndpointUrl: undefined + }, "Configuration#toFile got called with expected argument"); +}); + +test.serial("ui5 config set null should update the configuration", async (t) => { + const {config, argv, stderrWriteStub, stdoutWriteStub, Configuration} = t.context; + + argv["_"] = ["set"]; + argv["key"] = "mavenSnapshotEndpointUrl"; + + // Yargs would never provide us with other types than string. Still, our code should + // check for empty strings and nothing else (like falsy) + argv["value"] = 0; + await config.handler(argv); + + t.is(stderrWriteStub.firstCall.firstArg, + `Configuration option ${chalk.bold("mavenSnapshotEndpointUrl")} has been updated: + mavenSnapshotEndpointUrl = 0\n`, + "Logged expected message to stderr"); + t.is(stdoutWriteStub.callCount, 0, "Nothing written to stdout"); + + t.is(Configuration.toFile.callCount, 1, "Configuration#toFile got called once"); + t.deepEqual(Configuration.toFile.firstCall.firstArg.toJson(), { + mavenSnapshotEndpointUrl: 0 + }, "Configuration#toFile got called with expected argument"); +}); + +test.serial("ui5 config set false should update the configuration", async (t) => { + const {config, argv, stderrWriteStub, stdoutWriteStub, Configuration} = t.context; + + argv["_"] = ["set"]; + argv["key"] = "mavenSnapshotEndpointUrl"; + + // Yargs would never provide us with other types than string. Still, our code should + // check for empty strings and nothing else (like falsyness) + argv["value"] = false; + await config.handler(argv); + + t.is(stderrWriteStub.firstCall.firstArg, + `Configuration option ${chalk.bold("mavenSnapshotEndpointUrl")} has been updated: + mavenSnapshotEndpointUrl = false\n`, + "Logged expected message to stderr"); + t.is(stdoutWriteStub.callCount, 0, "Nothing written to stdout"); + + t.is(Configuration.toFile.callCount, 1, "Configuration#toFile got called once"); + t.deepEqual(Configuration.toFile.firstCall.firstArg.toJson(), { + mavenSnapshotEndpointUrl: false + }, "Configuration#toFile got called with expected argument"); +}); + +test.serial("ui5 config invalid key", async (t) => { + const {config, argv} = t.context; + + argv["_"] = ["get"]; + argv["key"] = "_invalid_key_"; + argv["value"] = "https://_snapshot_endpoint_"; + + await t.throwsAsync(config.handler(argv), { + message: + "The provided key is not a valid configuration option. Valid options are: mavenSnapshotEndpointUrl", + }); +}); + +test.serial("ui5 config empty key", async (t) => { + const {config, argv} = t.context; + + argv["_"] = ["set"]; + argv["key"] = ""; + argv["value"] = undefined; + + await t.throwsAsync(config.handler(argv), { + message: + "The provided key is not a valid configuration option. Valid options are: mavenSnapshotEndpointUrl", + }); +}); + +test.serial("ui5 config unknown command", async (t) => { + const {config, argv} = t.context; + + argv["_"] = ["foo"]; + + await t.throwsAsync(config.handler(argv), { + message: + "Unknown 'ui5 config' command 'foo'", + }); +});