diff --git a/package.json b/package.json index 7730d0c..8c25e4e 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "description": "REST API for managing notes with tags and search — demo project for Agentic AI Workshop (Advanced)", "main": "dist/index.js", + "bin": { + "fleet-e2e-toy": "dist/cli.js" + }, "scripts": { "build": "tsc", "start": "ts-node src/index.ts", diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..e40e0d4 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,67 @@ +const VERSION = "fleet-e2e-toy v1.0.0"; + +const HELP_TEXT = `Usage: fleet-e2e-toy [command] [options] [arguments] + +Commands: + help Show this help message + serve Start the API server (default) + +Flags: + --version, -v Print version and exit + --help, -h Show this help message + --port Port to listen on (default: 3000)`; + +export function parseArgs(argv: string[]): { action: string; args: string[] } { + const args = argv.slice(2); + + if (args.includes("--version") || args.includes("-v")) { + return { action: "version", args: [] }; + } + + if (args.includes("--help") || args.includes("-h") || args[0] === "help") { + return { action: "help", args: [] }; + } + + const positional = args.filter((a) => !a.startsWith("-")); + return { action: "serve", args: positional }; +} + +export function validateStringArg(value: string): string | null { + if (value.trim().length === 0) { + return "Error: argument must not be empty or whitespace-only"; + } + return null; +} + +export function run(argv: string[]): number { + const parsed = parseArgs(argv); + + switch (parsed.action) { + case "version": + console.log(VERSION); + return 0; + + case "help": + console.log(HELP_TEXT); + return 0; + + case "serve": { + for (const arg of parsed.args) { + const error = validateStringArg(arg); + if (error) { + console.error(error); + return 1; + } + } + return 0; + } + + default: + return 0; + } +} + +if (require.main === module) { + const code = run(process.argv); + process.exit(code); +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..c17bc6d --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,152 @@ +import { parseArgs, validateStringArg, run } from "../src/cli"; + +describe("parseArgs", () => { + it("detects --version flag", () => { + expect(parseArgs(["node", "cli", "--version"])).toEqual({ action: "version", args: [] }); + }); + + it("detects -v flag", () => { + expect(parseArgs(["node", "cli", "-v"])).toEqual({ action: "version", args: [] }); + }); + + it("detects --version alongside other flags", () => { + expect(parseArgs(["node", "cli", "--port", "3000", "--version"])).toEqual({ + action: "version", + args: [], + }); + }); + + it("detects --help flag", () => { + expect(parseArgs(["node", "cli", "--help"])).toEqual({ action: "help", args: [] }); + }); + + it("detects -h flag", () => { + expect(parseArgs(["node", "cli", "-h"])).toEqual({ action: "help", args: [] }); + }); + + it("detects help subcommand", () => { + expect(parseArgs(["node", "cli", "help"])).toEqual({ action: "help", args: [] }); + }); + + it("defaults to serve with no args", () => { + expect(parseArgs(["node", "cli"])).toEqual({ action: "serve", args: [] }); + }); + + it("collects positional args for serve", () => { + expect(parseArgs(["node", "cli", "myarg"])).toEqual({ action: "serve", args: ["myarg"] }); + }); +}); + +describe("--version flag (gh-toy-4ef)", () => { + it("prints fleet-e2e-toy v1.0.0", () => { + const spy = jest.spyOn(console, "log").mockImplementation(); + const code = run(["node", "cli", "--version"]); + expect(code).toBe(0); + expect(spy).toHaveBeenCalledWith("fleet-e2e-toy v1.0.0"); + spy.mockRestore(); + }); + + it("prints version with -v alias", () => { + const spy = jest.spyOn(console, "log").mockImplementation(); + const code = run(["node", "cli", "-v"]); + expect(code).toBe(0); + expect(spy).toHaveBeenCalledWith("fleet-e2e-toy v1.0.0"); + spy.mockRestore(); + }); + + it("--version takes priority when mixed with other flags", () => { + const spy = jest.spyOn(console, "log").mockImplementation(); + const code = run(["node", "cli", "--port", "8080", "--version"]); + expect(code).toBe(0); + expect(spy).toHaveBeenCalledWith("fleet-e2e-toy v1.0.0"); + spy.mockRestore(); + }); +}); + +describe("help command (gh-toy-kbk)", () => { + it("--help prints usage and exits 0", () => { + const spy = jest.spyOn(console, "log").mockImplementation(); + const code = run(["node", "cli", "--help"]); + expect(code).toBe(0); + const output = spy.mock.calls[0][0] as string; + expect(output).toContain("Usage:"); + expect(output).toContain("--version"); + expect(output).toContain("--help"); + expect(output).toContain("help"); + expect(output).toContain("serve"); + spy.mockRestore(); + }); + + it("-h prints same usage", () => { + const spy = jest.spyOn(console, "log").mockImplementation(); + const code = run(["node", "cli", "-h"]); + expect(code).toBe(0); + const output = spy.mock.calls[0][0] as string; + expect(output).toContain("Usage:"); + spy.mockRestore(); + }); + + it("help subcommand prints same usage as --help", () => { + const helpSpy = jest.spyOn(console, "log").mockImplementation(); + run(["node", "cli", "--help"]); + const helpOutput = helpSpy.mock.calls[0][0]; + helpSpy.mockRestore(); + + const subSpy = jest.spyOn(console, "log").mockImplementation(); + run(["node", "cli", "help"]); + const subOutput = subSpy.mock.calls[0][0]; + subSpy.mockRestore(); + + expect(subOutput).toBe(helpOutput); + }); + + it("help output lists all subcommands and flags", () => { + const spy = jest.spyOn(console, "log").mockImplementation(); + run(["node", "cli", "--help"]); + const output = spy.mock.calls[0][0] as string; + expect(output).toContain("help"); + expect(output).toContain("serve"); + expect(output).toContain("--version"); + expect(output).toContain("-v"); + expect(output).toContain("--help"); + expect(output).toContain("-h"); + expect(output).toContain("--port"); + spy.mockRestore(); + }); +}); + +describe("input validation (gh-toy-v6z)", () => { + it("validateStringArg rejects empty string", () => { + expect(validateStringArg("")).not.toBeNull(); + }); + + it("validateStringArg rejects whitespace-only string", () => { + expect(validateStringArg(" ")).not.toBeNull(); + expect(validateStringArg("\t\n")).not.toBeNull(); + }); + + it("validateStringArg accepts valid string", () => { + expect(validateStringArg("hello")).toBeNull(); + }); + + it("run exits non-zero on empty string argument", () => { + const spy = jest.spyOn(console, "error").mockImplementation(); + const code = run(["node", "cli", ""]); + expect(code).toBe(1); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("empty or whitespace")); + spy.mockRestore(); + }); + + it("run exits non-zero on whitespace-only argument", () => { + const spy = jest.spyOn(console, "error").mockImplementation(); + const code = run(["node", "cli", " "]); + expect(code).toBe(1); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("empty or whitespace")); + spy.mockRestore(); + }); + + it("run exits 0 on valid string argument", () => { + const code = run(["node", "cli", "validarg"]); + expect(code).toBe(0); + }); +});