diff --git a/config/paths.js b/config/paths.js index ab925d5..e5dc834 100644 --- a/config/paths.js +++ b/config/paths.js @@ -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" diff --git a/src/cli/help.js b/src/cli/help.js new file mode 100644 index 0000000..5b211b3 --- /dev/null +++ b/src/cli/help.js @@ -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; diff --git a/src/cli/help.test.js b/src/cli/help.test.js new file mode 100644 index 0000000..9d6f058 --- /dev/null +++ b/src/cli/help.test.js @@ -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:/), + ]), + }); + }); +}); diff --git a/src/cli/main.js b/src/cli/main.js new file mode 100644 index 0000000..d1c15b4 --- /dev/null +++ b/src/cli/main.js @@ -0,0 +1,22 @@ +// @flow + +import {handlingErrors} from "./command"; +import sourcecred from "./sourcecred"; + +require("../tools/entry"); + +export default function main(): Promise { + 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(); +} diff --git a/src/cli/main.test.js b/src/cli/main.test.js new file mode 100644 index 0000000..caf6ae4 --- /dev/null +++ b/src/cli/main.test.js @@ -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); + }); +}); diff --git a/src/cli/sourcecred.js b/src/cli/sourcecred.js new file mode 100644 index 0000000..ea33c97 --- /dev/null +++ b/src/cli/sourcecred.js @@ -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; diff --git a/src/cli/sourcecred.test.js b/src/cli/sourcecred.test.js new file mode 100644 index 0000000..ca129aa --- /dev/null +++ b/src/cli/sourcecred.test.js @@ -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", + ], + }); + }); +});