cli: add `main`, `sourcecred`, and `help` (#741)

Summary:
This commit includes a minimal usage of an actual CLI application. It
provides the `help` command and no actual functionality.

Test Plan:
Unit tests added, with full coverage. To see it in action, first run
`yarn backend`, then run `node bin/cli.js help`.

wchargin-branch: cli-beginnings
This commit is contained in:
William Chargin 2018-09-02 15:53:24 -07:00 committed by GitHub
parent 4c433d417e
commit ff2d4f2fd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 290 additions and 0 deletions

View File

@ -29,6 +29,7 @@ module.exports = {
backendEntryPoints: {
sourcecred: resolveApp("src/oclif/sourcecred.js"),
"commands/load": resolveApp("src/oclif/commands/load.js"),
cli: resolveApp("src/cli/main.js"),
//
fetchAndPrintGithubRepo: resolveApp(
"src/plugins/github/bin/fetchAndPrintGithubRepo.js"

55
src/cli/help.js Normal file
View File

@ -0,0 +1,55 @@
// @flow
// Implementation of `sourcecred help`.
import type {Command} from "./command";
import dedent from "../util/dedent";
const help: Command = async (args, std) => {
if (args.length === 0) {
usage(std.out);
return 0;
}
const command = args[0];
const subHelps: {[string]: Command} = {
help: metaHelp,
};
if (subHelps[command] !== undefined) {
return subHelps[command](args.slice(1), std);
} else {
usage(std.err);
return 1;
}
};
function usage(print: (string) => void): void {
print(
dedent`\
usage: sourcecred COMMAND [ARGS...]
sourcecred [--version] [--help]
Commands:
help show this help message
Use 'sourcecred help COMMAND' for help about an individual command.
`.trimRight()
);
}
const metaHelp: Command = async (args, std) => {
if (args.length === 0) {
std.out(
dedent`\
usage: sourcecred help [COMMAND]
Use 'sourcecred help' for general help and a list of commands.
Use 'sourcecred help COMMAND' for help about COMMAND.
`.trimRight()
);
return 0;
} else {
usage(std.err);
return 1;
}
};
export default help;

49
src/cli/help.test.js Normal file
View File

@ -0,0 +1,49 @@
// @flow
import help from "./help";
import {run} from "./testUtil";
describe("cli/help", () => {
it("prints general help with no arguments", async () => {
expect(await run(help, [])).toEqual({
exitCode: 0,
stdout: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred/),
expect.stringMatching(/Commands:/),
]),
stderr: [],
});
});
it("prints help about itself", async () => {
expect(await run(help, ["help"])).toEqual({
exitCode: 0,
stdout: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred help/),
]),
stderr: [],
});
});
it("fails when given an unknown command", async () => {
expect(await run(help, ["wat"])).toEqual({
exitCode: 1,
stdout: [],
stderr: expect.arrayContaining([
expect.stringMatching(/^usage: /),
expect.stringMatching(/Commands:/),
]),
});
});
it("fails when given multiple arguments", async () => {
expect(await run(help, ["help", "help"])).toEqual({
exitCode: 1,
stdout: [],
stderr: expect.arrayContaining([
expect.stringMatching(/^usage: /),
expect.stringMatching(/Commands:/),
]),
});
});
});

22
src/cli/main.js Normal file
View File

@ -0,0 +1,22 @@
// @flow
import {handlingErrors} from "./command";
import sourcecred from "./sourcecred";
require("../tools/entry");
export default function main(): Promise<void> {
return handlingErrors(sourcecred)(process.argv.slice(2), {
out: (x) => console.log(x),
err: (x) => console.error(x),
}).then((exitCode) => {
process.exitCode = exitCode;
});
}
// Only run in the Webpack bundle, not as a Node module (during tests).
/* istanbul ignore next */
/*:: declare var __webpack_require__: mixed; */
if (typeof __webpack_require__ !== "undefined") {
main();
}

75
src/cli/main.test.js Normal file
View File

@ -0,0 +1,75 @@
// @flow
import main from "./main";
import sourcecred from "./sourcecred";
jest.mock("./sourcecred");
describe("cli/main", () => {
beforeAll(() => {
jest.spyOn(console, "log").mockImplementation(() => {});
jest.spyOn(console, "error").mockImplementation(() => {});
});
beforeEach(() => {
sourcecred.mockReset();
jest.spyOn(console, "log").mockClear();
jest.spyOn(console, "error").mockClear();
});
it("forwards the exit code", async () => {
process.argv = ["node", "sourcecred", "help"];
sourcecred.mockResolvedValueOnce(22);
await main();
expect(process.exitCode).toBe(22);
});
it("forwards arguments", async () => {
process.argv = ["node", "sourcecred", "help", "me"];
sourcecred.mockResolvedValueOnce(0);
await main();
expect(sourcecred).toHaveBeenCalledTimes(1);
expect(sourcecred).toHaveBeenCalledWith(["help", "me"], {
out: expect.any(Function),
err: expect.any(Function),
});
expect(process.exitCode).toBe(0);
});
it("forwards stdout and stderr", async () => {
process.argv = ["node", "sourcecred", "help"];
jest.spyOn(console, "log").mockImplementation(() => {});
jest.spyOn(console, "error").mockImplementation(() => {});
sourcecred.mockImplementation(async (args, std) => {
std.out("out and away");
std.err("err, what?");
return 0;
});
await main();
expect(console.log.mock.calls).toEqual([["out and away"]]);
expect(console.error.mock.calls).toEqual([["err, what?"]]);
expect(process.exitCode).toBe(0);
});
it("captures an error", async () => {
process.argv = ["node", "sourcecred", "wat"];
jest.spyOn(console, "error").mockImplementation(() => {});
sourcecred.mockImplementationOnce(() => {
throw new Error("wat");
});
await main();
expect(console.error).toHaveBeenCalledWith(
expect.stringMatching("Error: wat")
);
expect(process.exitCode).toBe(1);
});
it("captures a rejection", async () => {
process.argv = ["node", "sourcecred", "wat"];
jest.spyOn(console, "error").mockImplementation(() => {});
sourcecred.mockRejectedValueOnce("wat?");
await main();
expect(console.log).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith('"wat?"');
expect(process.exitCode).toBe(1);
});
});

29
src/cli/sourcecred.js Normal file
View File

@ -0,0 +1,29 @@
// @flow
// Implementation of the root `sourcecred` command.
import type {Command} from "./command";
import {VERSION_SHORT} from "../app/version";
import help from "./help";
const sourcecred: Command = async (args, std) => {
if (args.length === 0) {
help([], {out: std.err, err: std.err});
return 1;
}
switch (args[0]) {
case "--version":
std.out("sourcecred " + VERSION_SHORT);
return 0;
case "--help":
case "help":
return help(args.slice(1), std);
default:
std.err("fatal: unknown command: " + JSON.stringify(args[0]));
std.err("fatal: run 'sourcecred help' for commands and usage");
return 1;
}
};
export default sourcecred;

View File

@ -0,0 +1,59 @@
// @flow
import {run} from "./testUtil";
import sourcecred from "./sourcecred";
function mockCommand(name) {
return jest.fn().mockImplementation(async (args, std) => {
std.out(`out(${name}): ${JSON.stringify(args)}`);
std.err(`err(${name})`);
return args.length;
});
}
jest.mock("./help", () => mockCommand("help"));
describe("cli/sourcecred", () => {
it("fails with usage when invoked with no arguments", async () => {
expect(await run(sourcecred, [])).toEqual({
exitCode: 1,
stdout: [],
stderr: ["out(help): []", "err(help)"],
});
});
it("responds to '--version'", async () => {
expect(await run(sourcecred, ["--version"])).toEqual({
exitCode: 0,
stdout: [expect.stringMatching(/^sourcecred v\d+\.\d+\.\d+$/)],
stderr: [],
});
});
it("responds to '--help'", async () => {
expect(await run(sourcecred, ["--help"])).toEqual({
exitCode: 0,
stdout: ["out(help): []"],
stderr: ["err(help)"],
});
});
it("responds to 'help'", async () => {
expect(await run(sourcecred, ["help"])).toEqual({
exitCode: 0,
stdout: ["out(help): []"],
stderr: ["err(help)"],
});
});
it("fails given an unknown command", async () => {
expect(await run(sourcecred, ["wat"])).toEqual({
exitCode: 1,
stdout: [],
stderr: [
'fatal: unknown command: "wat"',
"fatal: run 'sourcecred help' for commands and usage",
],
});
});
});