Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Add config command #618

Merged
merged 15 commits into from
Apr 25, 2023
4 changes: 2 additions & 2 deletions lib/cli/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import ConsoleWriter from "@ui5/logger/writers/Console";

export default function(cli) {
cli.usage("Usage: ui5 <command> [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",
Expand Down Expand Up @@ -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);
});
Expand Down
88 changes: 88 additions & 0 deletions lib/cli/commands/config.js
Original file line number Diff line number Diff line change
@@ -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 <key> [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 <key>", "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"];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this information come from the Configuration class instead of hard-code it here?
It would allow us to add new options without having to adjust the CLI project.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. That's a good idea we should follow-up on 👍


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;
2 changes: 1 addition & 1 deletion test/lib/cli/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
260 changes: 260 additions & 0 deletions test/lib/cli/commands/config.js
Original file line number Diff line number Diff line change
@@ -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'",
});
});