diff --git a/.gitignore b/.gitignore index 1fb2798..234c552 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ src/version.ts node_modules/ .idea +octomind.config.json diff --git a/README.md b/README.md index 07665df..0aced45 100644 --- a/README.md +++ b/README.md @@ -13,33 +13,49 @@ See [API documentation](https://octomind.dev/docs/api-reference/) ## Commands +### Init + +Initialize configuration by setting up your API key and optionally a test target ID. This allows you to avoid passing these parameters for subsequent commands. + +```bash +octomind init \ + [--api-key ] \ + [--test-target-id ] \ + [--force] +``` + +Options: +- `-k, --api-key`: Your Octomind API key (will prompt if not provided) +- `-t, --test-target-id`: Test target ID (optional) +- `-f, --force`: Force overwrite existing configuration + +If you don't provide the API key via the command line option, the CLI will prompt you to enter it interactively. You can get your API key from https://octomind.dev/docs/run-tests/execution-curl#create-an-api-key. + ### Execute Tests Run test cases against a specified URL. ```bash octomind execute \ - --api-key \ - --test-target-id \ --url \ + [--api-key ] \ + [--test-target-id ] \ [--environment ] \ [--tags ] \ [-v, --variables-to-overwrite ] \ [--description ] \ - [--tags ] \ [--json] ``` Options: -- `-k, --api-key` (required): Your Octomind API key -- `-t, --test-target-id` (required): Test target ID - `-u, --url` (required): URL to test +- `-k, --api-key`: Your Octomind API key (optional if configured via `init`) +- `-t, --test-target-id`: Test target ID (optional if configured via `init`) - `-e, --environment`: Environment name (default: "default") - `-d, --description`: Test description - `-g, --tags`: comma separated list of tags for tests to execute - `-v, --variables-to-overwrite`: JSON object for variables to override for this run e.g. `{ "key": ["v1", "v2"]}` - `-j, --json`: Output raw JSON response -- `-g, --tags `: comma separated list of tags ### Get Test Report @@ -47,16 +63,16 @@ Retrieve details about a specific test report. ```bash octomind report \ - --api-key \ - --test-target-id \ --report-id \ + [--api-key ] \ + [--test-target-id ] \ [--json] ``` Options: -- `-k, --api-key` (required): Your Octomind API key -- `-t, --test-target-id` (required): Test target ID - `-r, --report-id` (required): Test report ID +- `-k, --api-key`: Your Octomind API key (optional if configured via `init`) +- `-t, --test-target-id`: Test target ID (optional if configured via `init`) - `-j, --json`: Output raw JSON response Example text output: @@ -104,20 +120,20 @@ Register a new private location worker. If you use the [private location worker] ```bash octomind register-location \ - --api-key \ --name \ --proxypass \ --proxyuser \ --address
\ + [--api-key ] \ [--json] ``` Options: -- `-k, --api-key` (required): Your Octomind API key - `-n, --name` (required): Location name - `-p, --proxypass` (required): Proxy password - `-u, --proxyuser` (required): Proxy user - `-a, --address` (required): Location address (format: IP:PORT) +- `-k, --api-key`: Your Octomind API key (optional if configured via `init`) - `-j, --json`: Output raw JSON response ### Unregister Private Location @@ -126,14 +142,14 @@ Remove a registered private location worker. If you use the [private location wo ```bash octomind unregister-location \ - --api-key \ --name \ + [--api-key ] \ [--json] ``` Options: -- `-k, --api-key` (required): Your Octomind API key - `-n, --name` (required): Location name +- `-k, --api-key`: Your Octomind API key (optional if configured via `init`) - `-j, --json`: Output raw JSON response ### List Private Locations @@ -142,12 +158,12 @@ List all registered private locations. ```bash octomind list-private-locations \ - --api-key \ + [--api-key ] \ [--json] ``` Options: -- `-k, --api-key` (required): Your Octomind API key +- `-k, --api-key`: Your Octomind API key (optional if configured via `init`) - `-j, --json`: Output raw JSON response Example text output: @@ -167,14 +183,14 @@ List all available environments. ```bash octomind list-environments \ - --api-key \ - --test-target-id \ + [--api-key ] \ + [--test-target-id ] \ [--json] ``` Options: -- `-k, --api-key` (required): Your Octomind API key -- `-t, --test-target-id` (required): Test target ID +- `-k, --api-key`: Your Octomind API key (optional if configured via `init`) +- `-t, --test-target-id`: Test target ID (optional if configured via `init`) - `-j, --json`: Output raw JSON response ### Create Environment @@ -183,10 +199,10 @@ Create a new environment for a test target. ```bash octomind create-environment \ - --api-key \ - --test-target-id \ --name \ --discovery-url \ + [--api-key ] \ + [--test-target-id ] \ [--test-account-username ] \ [--test-account-password ] \ [--test-account-otp-initializer-key ] \ @@ -198,10 +214,10 @@ octomind create-environment \ ``` Options: -- `-k, --api-key` (required): Your Octomind API key -- `-t, --test-target-id` (required): Test target ID - `-n, --name` (required): Environment name - `-d, --discovery-url` (required): Discovery URL +- `-k, --api-key`: Your Octomind API key (optional if configured via `init`) +- `-t, --test-target-id`: Test target ID (optional if configured via `init`) - `--test-account-username`: Test account username - `--test-account-password`: Test account password - `--test-account-otp-initializer-key`: OTP initializer key for test account @@ -217,9 +233,9 @@ Update an existing environment. ```bash octomind update-environment \ - --api-key \ - --test-target-id \ --environment-id \ + [--api-key ] \ + [--test-target-id ] \ [--name ] \ [--discovery-url ] \ [--test-account-username ] \ @@ -233,9 +249,9 @@ octomind update-environment \ ``` Options: -- `-k, --api-key` (required): Your Octomind API key -- `-t, --test-target-id` (required): Test target ID - `-e, --environment-id` (required): Environment ID +- `-k, --api-key`: Your Octomind API key (optional if configured via `init`) +- `-t, --test-target-id`: Test target ID (optional if configured via `init`) - `-n, --name`: New environment name - `-d, --discovery-url`: New discovery URL - `--test-account-username`: New test account username @@ -253,16 +269,16 @@ Delete an existing environment. ```bash octomind delete-environment \ - --api-key \ - --test-target-id \ --environment-id \ + [--api-key ] \ + [--test-target-id ] \ [--json] ``` Options: -- `-k, --api-key` (required): Your Octomind API key -- `-t, --test-target-id` (required): Test target ID - `-e, --environment-id` (required): Environment ID +- `-k, --api-key`: Your Octomind API key (optional if configured via `init`) +- `-t, --test-target-id`: Test target ID (optional if configured via `init`) - `-j, --json`: Output raw JSON response ## Output Formats @@ -305,7 +321,6 @@ pnpm install The CLI is written in TypeScript and uses the following dependencies: - `commander`: For command-line argument parsing -- `axios`: For making HTTP requests To build from source: ```bash diff --git a/package.json b/package.json index 1d4a692..4b62816 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint": "npx genversion -des src/version.ts && eslint src/**/*.ts tests/**/*.ts --max-warnings=0", "apigen": "openapi-typescript ./openapi.yaml --output src/api.ts", "build": "npx genversion -des src/version.ts && pnpm apigen && tsc --project tsconfig.build.json", - "octomind": "tsx src/cli.ts", + "octomind": "tsx src/index.ts", "test": "npx genversion -des src/version.ts && jest", "test:watch": "npx genversion -e src/version.ts && jest --watch" }, @@ -45,4 +45,4 @@ "tsx": "^4.19.3", "typescript": "^5.0.4" } -} +} \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index aae21a2..6a01c56 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,4 @@ -import { program, Option, Command } from "commander"; +import { program, Command } from "commander"; import { version } from "./version"; import { createEnvironment, @@ -11,22 +11,16 @@ import { unregisterLocation, updateEnvironment, } from "./tools"; - -const apiKeyOption = new Option( - "-k, --api-key ", - "the api key for authentication", -) - .env("APIKEY") - .makeOptionMandatory(); +import { Config, loadConfig, saveConfig } from "./config"; +import { promptUser, resolveTestTargetId } from "./helpers"; const createCommandWithCommonOptions = (command: string): Command => { return program .command(command) - .addOption(apiKeyOption) .option("-j, --json", "Output raw JSON response"); }; -const splitter = (value: string): string[] => value.split(/,| /); +const splitter = (value: string): string[] => value.split(/,| |\|/); const toJSON = (value: string): object => JSON.parse(value); export const buildCmd = (): Command => { @@ -37,10 +31,78 @@ export const buildCmd = (): Command => { ) .version(version); + program + .command("init") + .description("Initialize configuration by setting up API key") + .option("-t, --test-target-id ", "Test target ID") + .option("-k, --api-key ", "the api key for authentication") + .option("-f, --force", "Force overwrite existing configuration") + .action( + async (options: { + testTargetId?: string; + apiKey: string; + force?: boolean; + }) => { + try { + console.log("šŸš€ Initializing configuration...\n"); + + const existingConfig = await loadConfig(); + + if (existingConfig.apiKey && !options.force) { + console.log("āš ļø Configuration already exists."); + const overwrite = await promptUser( + "Do you want to overwrite it? (y/N): ", + ); + + if ( + overwrite.toLowerCase() !== "y" && + overwrite.toLowerCase() !== "yes" + ) { + console.log("Configuration unchanged."); + return; + } + } + + let apiKey; + if (!options.apiKey) { + apiKey = await promptUser( + "Enter your API key. Go to https://octomind.dev/docs/run-tests/execution-curl#create-an-api-key to learn how to generate one: ", + ); + if (!apiKey) { + console.log("āŒ API key is required."); + process.exit(1); + } + } + let testTargetId; + if (!options.testTargetId) { + testTargetId = await promptUser( + "Enter test target id (optional, press Enter to skip): ", + ); + } + + const newConfig: Config = { + ...existingConfig, + apiKey: options.apiKey ?? apiKey, + testTargetId: options.testTargetId ?? testTargetId, + }; + + await saveConfig(newConfig); + + console.log("\n✨ Initialization complete!"); + } catch (error) { + console.error( + "āŒ Error during initialization:", + (error as Error).message, + ); + process.exit(1); + } + }, + ); + createCommandWithCommonOptions("execute") .description("Execute test cases") - .requiredOption("-t, --test-target-id ", "Test target ID") .requiredOption("-u, --url ", "URL to test") + .option("-t, --test-target-id ", "Test target ID") .option("-e, --environmentName ", "Environment name", "default") .option("-d, --description ", "Test description") .option("-g, --tags ", "comma separated list of tags", splitter) @@ -49,13 +111,25 @@ export const buildCmd = (): Command => { "JSON object of variables to overwrite", toJSON, ) - .action(executeTests); + .action(async (options) => { + const testTargetId = await resolveTestTargetId(options.testTargetId); + await executeTests({ + ...options, + testTargetId, + }); + }); createCommandWithCommonOptions("test-report") .description("Get test report details") - .requiredOption("-t, --test-target-id ", "Test target ID") .requiredOption("-r, --test-report-id ", "Test report ID") - .action(getTestReport); + .option("-t, --test-target-id ", "Test target ID") + .action(async (options) => { + const testTargetId = await resolveTestTargetId(options.testTargetId); + await getTestReport({ + ...options, + testTargetId, + }); + }); createCommandWithCommonOptions("register-location") .description("Register a private location") @@ -76,14 +150,19 @@ export const buildCmd = (): Command => { createCommandWithCommonOptions("list-environments") .description("List all environments") - .requiredOption("-t, --test-target-id ", "Test target ID") - .action(listEnvironments); + .option("-t, --test-target-id ", "Test target ID") + .action((_, options) => + listEnvironments({ + ...options, + testTargetId: resolveTestTargetId(options.testTargetId), + }), + ); createCommandWithCommonOptions("create-environment") .description("Create a new environment") - .requiredOption("-t, --test-target-id ", "Test target ID") .requiredOption("-n, --name ", "Environment name") .requiredOption("-d, --discovery-url ", "Discovery URL") + .option("-t, --test-target-id ", "Test target ID") .option("--test-account-username ", "Test account username") .option("--test-account-password ", "Test account password") .option( @@ -93,12 +172,17 @@ export const buildCmd = (): Command => { .option("--basic-auth-username ", "Basic auth username") .option("--basic-auth-password ", "Basic auth password") .option("--private-location-name ", "Private location name") - .action(createEnvironment); + .action((_, options) => + createEnvironment({ + ...options, + testTargetId: resolveTestTargetId(options.testTargetId), + }), + ); createCommandWithCommonOptions("update-environment") .description("Update an existing environment") - .requiredOption("-t, --test-target-id ", "Test target ID") .requiredOption("-e, --environment-id ", "Environment ID") + .option("-t, --test-target-id ", "Test target ID") .option("-n, --name ", "Environment name") .option("-d, --discovery-url ", "Discovery URL") .option("--test-account-username ", "Test account username") @@ -110,12 +194,17 @@ export const buildCmd = (): Command => { .option("--basic-auth-username ", "Basic auth username") .option("--basic-auth-password ", "Basic auth password") .option("--private-location-name ", "Private location name") - .action(updateEnvironment); + .action((_, options) => + updateEnvironment({ + ...options, + testTargetId: resolveTestTargetId(options.testTargetId), + }), + ); createCommandWithCommonOptions("delete-environment") .description("Delete an environment") - .requiredOption("-t, --test-target-id ", "Test target ID") .requiredOption("-e, --environment-id ", "Environment ID") + .option("-t, --test-target-id ", "Test target ID") .action(deleteEnvironment); return program; }; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..cace405 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,36 @@ +import path from "path"; +import fs from "fs/promises"; + +export interface Config { + apiKey?: string; + testTargetId?: string; +} + +const OCTOMIND_CONFIG_FILE = "octomind.config.json"; + +export function getConfigPath(): string { + return path.join(process.cwd(), OCTOMIND_CONFIG_FILE); +} + +export async function loadConfig(): Promise { + try { + const configPath = getConfigPath(); + const data = await fs.readFile(configPath, "utf8"); + return JSON.parse(data); + } catch (error) { + console.error("āŒ Error parsing configuration:", (error as Error).message); + + return {}; + } +} + +export async function saveConfig(config: Config): Promise { + try { + const configPath = getConfigPath(); + await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + console.log(`āœ… Configuration saved to ${configPath}`); + } catch (error) { + console.error("āŒ Error saving configuration:", (error as Error).message); + process.exit(1); + } +} diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..efaf28c --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,30 @@ +import { createInterface } from "node:readline"; +import { stdin as input, stdout as output } from "node:process"; +import { loadConfig } from "./config"; + +export function promptUser(question: string): Promise { + const rl = createInterface({ input, output }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +export const resolveTestTargetId = async ( + providedTestTargetId?: string, +): Promise => { + if (providedTestTargetId) { + return providedTestTargetId; + } + + const config = await loadConfig(); + if (!config.testTargetId) { + throw new Error( + "testTargetId is required. Please provide it as a parameter or configure it first by running 'octomind init'", + ); + } + return config.testTargetId; +}; diff --git a/src/tools.ts b/src/tools.ts index 9ddf246..46ba0f3 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,5 +1,6 @@ import type { paths, components } from "./api"; // generated by openapi-typescript import createClient, { Middleware } from "openapi-fetch"; +import { loadConfig } from "./config"; export type listEnvironmentsOptions = paths["/apiKey/v2/test-targets/{testTargetId}/environments"]["get"]["parameters"]["path"]; @@ -25,15 +26,17 @@ export type EnvironmentResponse = components["schemas"]["EnvironmentResponse"]; export type ErrorResponse = components["schemas"]["ZodResponse"] | undefined; -const BASE_URL = "https://app.octomind.dev/api"; +const BASE_URL = "https://preview.octomind.dev/api"; const client = createClient({ baseUrl: BASE_URL }); const authMiddleware: Middleware = { async onRequest({ request }) { - const apiKey = process.env.APIKEY; + const { apiKey } = await loadConfig(); if (!apiKey) { - throw new Error("APIKEY environment variable is not set"); + throw new Error( + "API key is required. Please configure it first by running 'octomind init'", + ); } request.headers.set("x-api-key", apiKey); return request; diff --git a/tests/cli.spec.ts b/tests/cli.spec.ts index 06e1ebe..4549028 100644 --- a/tests/cli.spec.ts +++ b/tests/cli.spec.ts @@ -1,6 +1,7 @@ import { program } from "commander"; import { buildCmd } from "../src/cli"; import { executeTests } from "../src/tools"; + jest.mock("../src/tools"); describe("CLI Commands parsing options", () => { @@ -8,12 +9,10 @@ describe("CLI Commands parsing options", () => { "node", "cli.js", "execute", - "--api-key", - "test-api-key", - "--test-target-id", - "test-target-id", "--url", "https://example.com", + "--test-target-id", + "test-target-123", ]; beforeAll(() => { @@ -23,48 +22,48 @@ describe("CLI Commands parsing options", () => { }); }); - it("should parse executeTests tags option with comma", () => { - program.parse([...stdArgs, "--tags", "tag1,tags2"]); + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should parse executeTests tags option with comma", async () => { + await program.parseAsync([...stdArgs, "--tags", "tag1,tags2"]); expect(executeTests).toHaveBeenCalledWith( expect.objectContaining({ tags: ["tag1", "tags2"], }), - expect.anything(), ); }); - it("should parse executeTests tags option with space", () => { - program.parse([...stdArgs, "--tags", "tag1 tags2"]); + it("should parse executeTests tags option with space", async () => { + await program.parseAsync([...stdArgs, "--tags", "tag1 tags2"]); expect(executeTests).toHaveBeenCalledWith( expect.objectContaining({ tags: ["tag1", "tags2"], }), - expect.anything(), ); }); - it("should parse executeTests tags option with |", () => { - program.parse([...stdArgs, "--tags", "tag1|tags2"]); + it("should parse executeTests tags option with |", async () => { + await program.parseAsync([...stdArgs, "--tags", "tag1|tags2"]); expect(executeTests).toHaveBeenCalledWith( expect.objectContaining({ tags: ["tag1", "tags2"], }), - expect.anything(), ); }); - it("should parse executeTests tags option always as array", () => { - program.parse([...stdArgs, "--tags", "tag1"]); + it("should parse executeTests tags option always as array", async () => { + await program.parseAsync([...stdArgs, "--tags", "tag1"]); expect(executeTests).toHaveBeenCalledWith( expect.objectContaining({ tags: ["tag1"], }), - expect.anything(), ); }); - it("should parse executeTests vars option as JSON", () => { - program.parse([ + it("should parse executeTests vars option as JSON", async () => { + await program.parseAsync([ ...stdArgs, "--variables-to-overwrite", '{ "foo": ["bar"] }', @@ -73,7 +72,6 @@ describe("CLI Commands parsing options", () => { expect.objectContaining({ variablesToOverwrite: { foo: ["bar"] }, }), - expect.anything(), ); }); });