diff --git a/.changeset/happy-items-battle.md b/.changeset/happy-items-battle.md new file mode 100644 index 0000000..a2cde20 --- /dev/null +++ b/.changeset/happy-items-battle.md @@ -0,0 +1,6 @@ +--- +"@mixedbread/cli": minor +--- + +- Update config object to store multiple api keys +- Add `--saved-key` global option diff --git a/.vscode/settings.json b/.vscode/settings.json index 69e02cd..9c4ec70 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,10 +21,6 @@ "editor.defaultFormatter": "biomejs.biome", "editor.formatOnPaste": true, "editor.formatOnSave": true, - "tailwindCSS.experimental.classRegex": [ - ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], - ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] - ], "typescript.tsdk": "node_modules/typescript/lib", "prettier.enable": false } diff --git a/biome.json b/biome.json index ac420e3..a73ab3a 100644 --- a/biome.json +++ b/biome.json @@ -40,7 +40,11 @@ "noUndeclaredVariables": "off", "noUndeclaredDependencies": "off", "useImportExtensions": "off", - "noNodejsModules": "off" + "noNodejsModules": "off", + "noUnusedImports": { + "level": "warn", + "fix": "safe" + } }, "nursery": { "noSecrets": "off", diff --git a/packages/cli/README.md b/packages/cli/README.md index 7c9c3e5..1d21a3f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,7 +11,10 @@ npm install -g @mixedbread/cli ## Quick Start ```bash -# Set your API key +# Add and set your API key +mxbai config keys add mxb_xxxxx work + +# Or use environment variable export MXBAI_API_KEY=mxb_xxxxx ## Check available commands and their options @@ -83,6 +86,11 @@ mxbai vs upload "My Documents" --manifest upload-manifest.yaml - `mxbai config set ` - Set configuration values - `mxbai config get [key]` - Get configuration values +- `mxbai config keys add [name]` - Add a new API key +- `mxbai config keys list` - List all API keys +- `mxbai config keys remove ` - Remove an API key + - Options: `--force` (skip confirmation) +- `mxbai config keys set-default ` - Set the default API key - `mxbai completion install` - Install shell completion - Options: `--shell ` (manually specify shell: bash, zsh, fish, pwsh) - `mxbai completion uninstall` - Uninstall shell completion @@ -220,8 +228,25 @@ mxbai config set defaults.upload.parallel 10 # Concurrent operations mxbai config set defaults.search.top_k 20 # Number of results (default: 10) mxbai config set defaults.search.rerank true # Enable reranking (default: false) -# API key (alternative to environment variable) -mxbai config set api_key mxb_xxxxx +# API key management (alternative to environment variable) +# Add API keys with names for easy switching +mxbai config keys add mxb_xxxxx work +mxbai config keys add mxb_xxxxx personal + +# List all API keys +mxbai config keys list +# Output: +# work +# * personal (default) + +# Set default API key +mxbai config keys set-default work + +# Remove an API key +mxbai config keys remove personal + +# Remove an API key without confirmation +mxbai config keys remove personal --force # Create aliases for frequently used vector stores mxbai config set aliases.docs "My Documentation" @@ -242,14 +267,35 @@ mxbai config get defaults.upload The CLI looks for your API key in this order: -1. `--api-key` command line flag +1. `--api-key` or `--saved-key` command line flags 2. `MXBAI_API_KEY` environment variable -3. Config file (platform-specific location): +3. Default API key from config file (platform-specific location): - **Linux/Unix**: `~/.config/mixedbread/config.json` (or `$XDG_CONFIG_HOME/mixedbread/config.json`) - **macOS**: `~/Library/Application Support/mixedbread/config.json` - **Windows**: `%APPDATA%\mixedbread\config.json` - **Custom**: Set `MXBAI_CONFIG_PATH` environment variable to override +### Multi-Organization Support + +The CLI supports multiple API keys for different organizations or environments: + +```bash +# Add API keys with descriptive names +mxbai config keys add mxb_xxxxx work +mxbai config keys add mxb_xxxxx personal + +# Use a specific saved API key for a command +mxbai vs upload "My Docs" "*.md" --saved-key work +mxbai vs search "Knowledge Base" "query" --saved-key personal + +# Or use an actual API key directly +mxbai vs upload "My Docs" "*.md" --api-key mxb_xxxxx + +# The last added key becomes default automatically +# Or explicitly set a default +mxbai config keys set-default personal +``` + ## Shell Completion The CLI supports tab completion for commands and subcommands. To set up completion: @@ -280,7 +326,8 @@ After installation, restart your shell or reload your shell configuration: All commands support these global options: -- `--api-key ` - API key for authentication (must start with "mxb\_") +- `--api-key ` - Actual API key for authentication +- `--saved-key ` - Name of saved API key from config - `--format ` - Output format: table, json, or csv (default: table) - `--debug` - Enable debug output (can also set `MXBAI_DEBUG=true`) @@ -315,8 +362,8 @@ This CLI is built on top of the `@mixedbread/sdk` and provides a convenient comm ```bash export MXBAI_API_KEY=mxb_xxxxx - # Or create a config file - cd packages/cli && pnpm build && pnpm mxbai config set api_key mxb_xxxxx + # Or add to config file + cd packages/cli && pnpm build && pnpm mxbai config keys add mxb_xxxxx dev ``` #### Development Workflow diff --git a/packages/cli/src/bin/mxbai.ts b/packages/cli/src/bin/mxbai.ts index 910905a..fd403be 100644 --- a/packages/cli/src/bin/mxbai.ts +++ b/packages/cli/src/bin/mxbai.ts @@ -58,7 +58,7 @@ program.action(() => { // Global error handling program.on("error", (error: Error) => { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); if (process.env.MXBAI_DEBUG === "true") { console.error(chalk.gray(error.stack)); } @@ -68,7 +68,7 @@ program.on("error", (error: Error) => { // Handle unknown commands program.on("command:*", () => { console.error( - chalk.red("\nError:"), + chalk.red("\n✗"), `Unknown command: ${program.args.join(" ")}\n` ); program.help(); @@ -80,7 +80,7 @@ async function main() { await program.parseAsync(process.argv); } catch (error) { if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); if (process.env.MXBAI_DEBUG === "true") { console.error(chalk.gray(error.stack)); } diff --git a/packages/cli/src/commands/completion.ts b/packages/cli/src/commands/completion.ts index b189f76..7289b7c 100644 --- a/packages/cli/src/commands/completion.ts +++ b/packages/cli/src/commands/completion.ts @@ -22,7 +22,7 @@ function getShellInfo(options: { shell?: string }) { shell = options.shell as SupportedShell; installMethod = "manual"; } else { - console.error(chalk.red(`Error: Unsupported shell '${options.shell}'.`)); + console.error(chalk.red("✗"), `Unsupported shell '${options.shell}'`); console.error( chalk.gray(`Supported shells: ${SUPPORTED_SHELLS.join(", ")}`) ); @@ -216,7 +216,19 @@ export function createCompletionServerCommand(): Command { // Config completions if (env.prev === "config") { - return log(["get", "set"], shell, console.log); + return log(["get", "set", "keys"], shell, console.log); + } + + if (env.prev === "keys") { + // Check if we're in "mxbai config keys " context + const words = env.line.trim().split(/\s+/); + if (words.length >= 3 && words[1] === "config") { + return log( + ["list", "add", "remove", "set-default"], + shell, + console.log + ); + } } // Completion completions diff --git a/packages/cli/src/commands/config/get.ts b/packages/cli/src/commands/config/get.ts index 33d03a6..0fafd45 100644 --- a/packages/cli/src/commands/config/get.ts +++ b/packages/cli/src/commands/config/get.ts @@ -25,7 +25,7 @@ export function createGetCommand(): Command { current = current[currentKey]; } else { console.error( - chalk.red("Error:"), + chalk.red("✗"), `Configuration key ${chalk.cyan(key)} not found` ); process.exit(1); @@ -35,7 +35,7 @@ export function createGetCommand(): Command { console.log(chalk.cyan(`${key}:`), JSON.stringify(current, null, 2)); } catch (error) { console.error( - chalk.red("Error:"), + chalk.red("✗"), "Failed to get configuration:", error instanceof Error ? error.message : String(error) ); diff --git a/packages/cli/src/commands/config/index.ts b/packages/cli/src/commands/config/index.ts index e113a34..0b75812 100644 --- a/packages/cli/src/commands/config/index.ts +++ b/packages/cli/src/commands/config/index.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { createGetCommand } from "./get"; +import { createKeysCommand } from "./keys"; import { createSetCommand } from "./set"; export function createConfigCommand(): Command { @@ -9,6 +10,7 @@ export function createConfigCommand(): Command { configCommand.addCommand(createSetCommand()); configCommand.addCommand(createGetCommand()); + configCommand.addCommand(createKeysCommand()); // Show help without error exit code when no subcommand provided configCommand.action(() => { diff --git a/packages/cli/src/commands/config/keys.ts b/packages/cli/src/commands/config/keys.ts new file mode 100644 index 0000000..fe7f688 --- /dev/null +++ b/packages/cli/src/commands/config/keys.ts @@ -0,0 +1,185 @@ +import chalk from "chalk"; +import { Command } from "commander"; +import inquirer from "inquirer"; +import { + isMxbaiAPIKey, + loadConfig, + outputAvailableKeys, + saveConfig, +} from "../../utils/config"; + +export function createKeysCommand(): Command { + const keysCommand = new Command("keys").description("Manage API keys"); + + keysCommand + .command("add [name]") + .description("Add a new API key") + .action(async (key: string, name?: string) => { + if (!isMxbaiAPIKey(key)) { + console.error(chalk.red("✗"), 'API key must start with "mxb_"'); + return; + } + + const config = loadConfig(); + + // Prompt for name if not provided + if (!name) { + const response = await inquirer.prompt<{ name: string }>({ + type: "input", + name: "name", + message: "Enter a name for this API key (e.g., 'work', 'personal'):", + validate: (input: string) => { + if (!input.trim()) { + return "Name cannot be empty"; + } + if (config.api_keys?.[input.trim()]) { + return `API key "${input.trim()}" already exists`; + } + return true; + }, + }); + name = response.name.trim(); + } else { + // Validate name if provided + if (!name.trim()) { + console.error(chalk.red("✗"), "Name cannot be empty"); + return; + } + if (config.api_keys?.[name]) { + console.error(chalk.red("✗"), `API key "${name}" already exists`); + return; + } + } + + // Add the API key + if (!config.api_keys) { + config.api_keys = {}; + } + config.api_keys[name] = key; + + // Set as default + if (!config.defaults) { + config.defaults = {}; + } + config.defaults.api_key = name; + + saveConfig(config); + + console.log( + chalk.green("✓"), + `API key "${name}" saved and set as default` + ); + }); + + keysCommand + .command("list") + .description("List all API keys") + .action(() => { + const config = loadConfig(); + + if (!config.api_keys || Object.keys(config.api_keys).length === 0) { + console.log("No API keys configured"); + console.log("\nAdd an API key:"); + console.log(chalk.cyan(" mxbai config keys add ")); + return; + } + + console.log("API Keys:"); + outputAvailableKeys(config); + }); + + keysCommand + .command("remove ") + .description("Remove an API key") + .option("--force", "Skip confirmation prompt") + .action(async (name: string, options: { force?: boolean }) => { + const config = loadConfig(); + + if (!config.api_keys?.[name]) { + console.error(chalk.red("✗"), `No API key found with name "${name}"`); + + if (config.api_keys && Object.keys(config.api_keys).length > 0) { + console.log("\nAvailable API keys:"); + outputAvailableKeys(config); + } + return; + } + + const isDefault = config.defaults?.api_key === name; + + // Confirm removal unless force flag is used + if (!options.force) { + const response = await inquirer.prompt<{ confirm: boolean }>({ + type: "confirm", + name: "confirm", + message: `Remove API key "${name}"${isDefault ? " (currently default)" : ""}?`, + default: false, + }); + + if (!response.confirm) { + console.log(chalk.yellow("Removal cancelled.")); + return; + } + } + + // Remove the key + delete config.api_keys[name]; + + // If this was the default, clear it and warn + if (isDefault) { + if (config.defaults) { + delete config.defaults.api_key; + } + + saveConfig(config); + console.log(chalk.green("✓"), `API key "${name}" removed`); + + console.log( + chalk.yellow("⚠"), + "No default API key set. Set a new default:" + ); + if (Object.keys(config.api_keys).length > 0) { + Object.keys(config.api_keys).forEach((keyName) => { + console.log(` mxbai config keys set-default ${keyName}`); + }); + } else { + console.log(` mxbai config keys set-default `); + } + } else { + saveConfig(config); + console.log(chalk.green("✓"), `API key "${name}" removed`); + } + }); + + keysCommand + .command("set-default ") + .description("Set the default API key") + .action((name: string) => { + const config = loadConfig(); + + if (!config.api_keys?.[name]) { + console.error(chalk.red("✗"), `No API key found with name "${name}"`); + + if (config.api_keys && Object.keys(config.api_keys).length > 0) { + console.log("\nAvailable API keys:"); + outputAvailableKeys(config); + } + return; + } + + if (!config.defaults) { + config.defaults = {}; + } + config.defaults.api_key = name; + + saveConfig(config); + console.log(chalk.green("✓"), `"${name}" set as default API key`); + }); + + // Show help when no subcommand provided + keysCommand.action(() => { + keysCommand.help(); + }); + + return keysCommand; +} diff --git a/packages/cli/src/commands/config/set.ts b/packages/cli/src/commands/config/set.ts index d1d1844..30f953d 100644 --- a/packages/cli/src/commands/config/set.ts +++ b/packages/cli/src/commands/config/set.ts @@ -35,7 +35,7 @@ export function createSetCommand(): Command { ); } catch (error) { console.error( - chalk.red("Error:"), + chalk.red("✗"), "Failed to set configuration:", error instanceof Error ? error.message : String(error) ); diff --git a/packages/cli/src/commands/vector-store/create.ts b/packages/cli/src/commands/vector-store/create.ts index 18c8b78..5619e9e 100644 --- a/packages/cli/src/commands/vector-store/create.ts +++ b/packages/cli/src/commands/vector-store/create.ts @@ -5,15 +5,15 @@ import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { validateMetadata } from "../../utils/metadata"; import { formatOutput } from "../../utils/output"; -const CreateVectorStoreSchema = GlobalOptionsSchema.extend({ +const CreateVectorStoreSchema = extendGlobalOptions({ name: z.string().min(1, { message: '"name" is required' }), description: z.string().optional(), expiresAfter: z.coerce @@ -84,13 +84,11 @@ export function createCreateCommand(): Command { parsedOptions.format ); } catch (error) { - if (spinner) { - spinner.fail("Failed to create vector store"); - } + spinner?.fail("Failed to create vector store"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to create vector store"); + console.error(chalk.red("\n✗"), "Failed to create vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/delete.ts b/packages/cli/src/commands/vector-store/delete.ts index 40ece7f..87cbf74 100644 --- a/packages/cli/src/commands/vector-store/delete.ts +++ b/packages/cli/src/commands/vector-store/delete.ts @@ -6,14 +6,14 @@ import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { resolveVectorStore } from "../../utils/vector-store"; -const DeleteVectorStoreSchema = GlobalOptionsSchema.extend({ +const DeleteVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), force: z.boolean().optional(), }); @@ -60,7 +60,7 @@ export function createDeleteCommand(): Command { ]); if (!confirmed) { - console.log(chalk.yellow("Cancelled.")); + console.log(chalk.yellow("Deletion cancelled.")); return; } } @@ -73,13 +73,11 @@ export function createDeleteCommand(): Command { `Vector store "${vectorStore.name}" deleted successfully` ); } catch (error) { - if (spinner) { - spinner.fail("Failed to delete vector store"); - } + spinner?.fail("Failed to delete vector store"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to delete vector store"); + console.error(chalk.red("\n✗"), "Failed to delete vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/files/delete.ts b/packages/cli/src/commands/vector-store/files/delete.ts index 77199aa..d25fc39 100644 --- a/packages/cli/src/commands/vector-store/files/delete.ts +++ b/packages/cli/src/commands/vector-store/files/delete.ts @@ -1,19 +1,19 @@ import chalk from "chalk"; import { Command } from "commander"; import inquirer from "inquirer"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import z from "zod"; import { createClient } from "../../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../../utils/global-options"; import { resolveVectorStore } from "../../../utils/vector-store"; -const DeleteFileSchema = GlobalOptionsSchema.extend({ +const DeleteFileSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), fileId: z.string().min(1, { message: '"file-id" is required' }), force: z.boolean().optional(), @@ -35,6 +35,7 @@ export function createDeleteCommand(): Command { fileId: string, options: GlobalOptions & { force?: boolean } ) => { + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(deleteCommand, options); @@ -62,12 +63,12 @@ export function createDeleteCommand(): Command { ]); if (!confirmed) { - console.log(chalk.yellow("Cancelled.")); + console.log(chalk.yellow("Deletion cancelled.")); return; } } - const spinner = ora("Deleting file...").start(); + spinner = ora("Deleting file...").start(); await client.vectorStores.files.delete(parsedOptions.fileId, { vector_store_identifier: vectorStore.id, @@ -75,10 +76,11 @@ export function createDeleteCommand(): Command { spinner.succeed(`File ${parsedOptions.fileId} deleted successfully`); } catch (error) { + spinner?.fail("Failed to delete file"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to delete file"); + console.error(chalk.red("\n✗"), "Failed to delete file"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/files/get.ts b/packages/cli/src/commands/vector-store/files/get.ts index cde79e6..dacf556 100644 --- a/packages/cli/src/commands/vector-store/files/get.ts +++ b/packages/cli/src/commands/vector-store/files/get.ts @@ -1,19 +1,19 @@ import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../../utils/global-options"; import { formatBytes, formatOutput } from "../../../utils/output"; import { resolveVectorStore } from "../../../utils/vector-store"; -const GetFileSchema = GlobalOptionsSchema.extend({ +const GetFileSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), fileId: z.string().min(1, { message: '"file-id" is required' }), }); @@ -28,7 +28,7 @@ export function createGetCommand(): Command { getCommand.action( async (nameOrId: string, fileId: string, options: GlobalOptions) => { - const spinner = ora("Loading file details...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(getCommand, options); @@ -40,6 +40,7 @@ export function createGetCommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Loading file details...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId @@ -70,9 +71,9 @@ export function createGetCommand(): Command { } catch (error) { spinner.fail("Failed to load file details"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to get file details"); + console.error(chalk.red("\n✗"), "Failed to get file details"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/files/list.ts b/packages/cli/src/commands/vector-store/files/list.ts index 37101f3..db31f5b 100644 --- a/packages/cli/src/commands/vector-store/files/list.ts +++ b/packages/cli/src/commands/vector-store/files/list.ts @@ -1,12 +1,12 @@ import type { VectorStoreFile } from "@mixedbread/sdk/resources/vector-stores"; import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../../utils/client"; import { addGlobalOptions, - GlobalOptionsSchema, + extendGlobalOptions, mergeCommandOptions, parseOptions, } from "../../../utils/global-options"; @@ -18,7 +18,7 @@ import { import { resolveVectorStore } from "../../../utils/vector-store"; import type { FilesOptions } from "."; -const ListFilesSchema = GlobalOptionsSchema.extend({ +const ListFilesSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), status: z .enum(["all", "completed", "in_progress", "failed"], { @@ -48,7 +48,7 @@ export function createListCommand(): Command { ); listCommand.action(async (nameOrId: string, options: FilesOptions) => { - const spinner = ora("Loading files...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(listCommand, options); @@ -58,6 +58,7 @@ export function createListCommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Loading files...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId @@ -100,9 +101,9 @@ export function createListCommand(): Command { } catch (error) { spinner.fail("Failed to load files"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to list files"); + console.error(chalk.red("\n✗"), "Failed to list files"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/get.ts b/packages/cli/src/commands/vector-store/get.ts index 5f924d4..254c367 100644 --- a/packages/cli/src/commands/vector-store/get.ts +++ b/packages/cli/src/commands/vector-store/get.ts @@ -1,19 +1,19 @@ import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { formatBytes, formatOutput } from "../../utils/output"; import { resolveVectorStore } from "../../utils/vector-store"; -const GetVectorStoreSchema = GlobalOptionsSchema.extend({ +const GetVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), }); @@ -27,7 +27,7 @@ export function createGetCommand(): Command { ); command.action(async (nameOrId: string, options: GetOptions) => { - const spinner = ora("Loading vector store details...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(command, options); @@ -38,6 +38,7 @@ export function createGetCommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Loading vector store details...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId @@ -71,14 +72,11 @@ export function createGetCommand(): Command { formatOutput(formattedData, parsedOptions.format); } catch (error) { - spinner.fail("Failed to load vector store details"); + spinner?.fail("Failed to load vector store details"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error( - chalk.red("\nError:"), - "Failed to get vector store details" - ); + console.error(chalk.red("\n✗"), "Failed to get vector store details"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/list.ts b/packages/cli/src/commands/vector-store/list.ts index 8f557ea..10a93e0 100644 --- a/packages/cli/src/commands/vector-store/list.ts +++ b/packages/cli/src/commands/vector-store/list.ts @@ -1,12 +1,12 @@ import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; @@ -16,7 +16,7 @@ import { formatOutput, } from "../../utils/output"; -const ListVectorStoreSchema = GlobalOptionsSchema.extend({ +const ListVectorStoreSchema = extendGlobalOptions({ filter: z.string().optional(), limit: z.coerce .number({ message: '"limit" must be a number' }) @@ -40,7 +40,7 @@ export function createListCommand(): Command { ); command.action(async (options: ListOptions) => { - const spinner = ora("Loading vector stores...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(command, options); @@ -50,6 +50,7 @@ export function createListCommand(): Command { ); const client = createClient(parsedOptions); + spinner = ora("Loading vector stores...").start(); const response = await client.vectorStores.list({ limit: parsedOptions.limit || 10, }); @@ -87,11 +88,11 @@ export function createListCommand(): Command { ); formatOutput(formattedData, parsedOptions.format); } catch (error) { - spinner.fail("Failed to load vector stores"); + spinner?.fail("Failed to load vector stores"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to list vector stores"); + console.error(chalk.red("\n✗"), "Failed to list vector stores"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/qa.ts b/packages/cli/src/commands/vector-store/qa.ts index 7f24030..9d0dff6 100644 --- a/packages/cli/src/commands/vector-store/qa.ts +++ b/packages/cli/src/commands/vector-store/qa.ts @@ -1,20 +1,20 @@ import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../utils/client"; import { loadConfig } from "../../utils/config"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { formatOutput } from "../../utils/output"; import { resolveVectorStore } from "../../utils/vector-store"; -const QAVectorStoreSchema = GlobalOptionsSchema.extend({ +const QAVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), question: z.string().min(1, { message: '"question" is required' }), topK: z.coerce @@ -54,7 +54,7 @@ export function createQACommand(): Command { command.action( async (nameOrId: string, question: string, options: QAOptions) => { - const spinner = ora("Processing question...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(command, options); @@ -65,6 +65,7 @@ export function createQACommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Processing question...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId @@ -120,11 +121,11 @@ export function createQACommand(): Command { formatOutput(sources, parsedOptions.format); } } catch (error) { - spinner.fail("Failed to process question"); + spinner?.fail("Failed to process question"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to process question"); + console.error(chalk.red("\n✗"), "Failed to process question"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/search.ts b/packages/cli/src/commands/vector-store/search.ts index 30797a2..5183316 100644 --- a/packages/cli/src/commands/vector-store/search.ts +++ b/packages/cli/src/commands/vector-store/search.ts @@ -1,21 +1,21 @@ import type Mixedbread from "@mixedbread/sdk"; import chalk from "chalk"; import { Command } from "commander"; -import ora from "ora"; +import ora, { type Ora } from "ora"; import { z } from "zod"; import { createClient } from "../../utils/client"; import { loadConfig } from "../../utils/config"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; import { formatCountWithSuffix, formatOutput } from "../../utils/output"; import { resolveVectorStore } from "../../utils/vector-store"; -const SearchVectorStoreSchema = GlobalOptionsSchema.extend({ +const SearchVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), query: z.string().min(1, { message: '"query" is required' }), topK: z.coerce @@ -93,7 +93,7 @@ export function createSearchCommand(): Command { command.action( async (nameOrId: string, query: string, options: SearchOptions) => { - const spinner = ora("Searching vector store...").start(); + let spinner: Ora; try { const mergedOptions = mergeCommandOptions(command, options); @@ -104,6 +104,7 @@ export function createSearchCommand(): Command { }); const client = createClient(parsedOptions); + spinner = ora("Searching vector store...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId @@ -163,11 +164,11 @@ export function createSearchCommand(): Command { formatOutput(output, parsedOptions.format); } catch (error) { - spinner.fail("Search failed"); + spinner?.fail("Search failed"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to search vector store"); + console.error(chalk.red("\n✗"), "Failed to search vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/sync.ts b/packages/cli/src/commands/vector-store/sync.ts index c30f249..5867fbf 100644 --- a/packages/cli/src/commands/vector-store/sync.ts +++ b/packages/cli/src/commands/vector-store/sync.ts @@ -7,8 +7,8 @@ import { createClient } from "../../utils/client"; import { getGitInfo } from "../../utils/git"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; @@ -23,7 +23,7 @@ import { import { getSyncedFiles } from "../../utils/sync-state"; import { resolveVectorStore } from "../../utils/vector-store"; -const SyncVectorStoreSchema = GlobalOptionsSchema.extend({ +const SyncVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), patterns: z .array(z.string()) @@ -131,7 +131,7 @@ export function createSyncCommand(): Command { ); } else if (fromGit && !gitInfo.isRepo) { console.error( - chalk.red("✗ Error:"), + chalk.red("✗"), "--from-git specified but not in a git repository" ); process.exit(1); @@ -163,7 +163,7 @@ export function createSyncCommand(): Command { if (totalChanges === 0) { console.log( chalk.green( - "🎉 Vector store is already in sync - no changes needed!" + "✓ Vector store is already in sync - no changes needed!" ) ); return; @@ -196,7 +196,7 @@ export function createSyncCommand(): Command { ]); if (!proceed) { - console.log(chalk.yellow("❌ Sync cancelled by user")); + console.log(chalk.yellow("Sync cancelled by user")); return; } } else if (parsedOptions.ci) { @@ -226,9 +226,9 @@ export function createSyncCommand(): Command { }); } catch (error) { if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to sync vector store"); + console.error(chalk.red("\n✗"), "Failed to sync vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/update.ts b/packages/cli/src/commands/vector-store/update.ts index 11484e6..fc49e76 100644 --- a/packages/cli/src/commands/vector-store/update.ts +++ b/packages/cli/src/commands/vector-store/update.ts @@ -6,8 +6,8 @@ import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; @@ -15,7 +15,7 @@ import { validateMetadata } from "../../utils/metadata"; import { formatOutput } from "../../utils/output"; import { resolveVectorStore } from "../../utils/vector-store"; -const UpdateVectorStoreSchema = GlobalOptionsSchema.extend({ +const UpdateVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), name: z.string().optional(), description: z.string().optional(), @@ -81,7 +81,7 @@ export function createUpdateCommand(): Command { if (Object.keys(updateData).length === 0) { console.error( - chalk.red("Error:"), + chalk.red("✗"), "No update fields provided. Use --name, --description, or --metadata" ); process.exit(1); @@ -119,13 +119,11 @@ export function createUpdateCommand(): Command { parsedOptions.format ); } catch (error) { - if (spinner) { - spinner.fail("Failed to update vector store"); - } + spinner?.fail("Failed to update vector store"); if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to update vector store"); + console.error(chalk.red("\n✗"), "Failed to update vector store"); } process.exit(1); } diff --git a/packages/cli/src/commands/vector-store/upload.ts b/packages/cli/src/commands/vector-store/upload.ts index 6b80e60..323f4fc 100644 --- a/packages/cli/src/commands/vector-store/upload.ts +++ b/packages/cli/src/commands/vector-store/upload.ts @@ -9,8 +9,8 @@ import { createClient } from "../../utils/client"; import { loadConfig } from "../../utils/config"; import { addGlobalOptions, + extendGlobalOptions, type GlobalOptions, - GlobalOptionsSchema, mergeCommandOptions, parseOptions, } from "../../utils/global-options"; @@ -23,7 +23,7 @@ import { resolveVectorStore, } from "../../utils/vector-store"; -const UploadVectorStoreSchema = GlobalOptionsSchema.extend({ +const UploadVectorStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { message: '"name-or-id" is required' }), patterns: z.array(z.string()).optional(), strategy: z @@ -80,8 +80,6 @@ export function createUploadCommand(): Command { command.action( async (nameOrId: string, patterns: string[], options: UploadOptions) => { - const spinner = ora("Initializing upload...").start(); - try { const mergedOptions = mergeCommandOptions(command, options); @@ -92,6 +90,7 @@ export function createUploadCommand(): Command { }); const client = createClient(parsedOptions); + const spinner = ora("Initializing upload...").start(); const vectorStore = await resolveVectorStore( client, parsedOptions.nameOrId @@ -112,7 +111,7 @@ export function createUploadCommand(): Command { if (!parsedOptions.patterns || parsedOptions.patterns.length === 0) { console.error( - chalk.red("Error:"), + chalk.red("✗"), "No file patterns provided. Use --manifest for manifest-based uploads." ); process.exit(1); @@ -176,7 +175,7 @@ export function createUploadCommand(): Command { console.log(` Metadata: ${JSON.stringify(metadata)}`); } } catch (_error) { - console.log(` ${file} (${chalk.red("Error: File not found")})`); + console.log(` ${file} (${chalk.red("✗ File not found")})`); } }); return; @@ -232,9 +231,9 @@ export function createUploadCommand(): Command { }); } catch (error) { if (error instanceof Error) { - console.error(chalk.red("\nError:"), error.message); + console.error(chalk.red("\n✗"), error.message); } else { - console.error(chalk.red("\nError:"), "Failed to upload files"); + console.error(chalk.red("\n✗"), "Failed to upload files"); } process.exit(1); } diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 39dfb74..c7247e2 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -48,20 +48,23 @@ export const SearchDefaultsSchema = z.object({ export const DefaultsSchema = z.object({ upload: UploadDefaultsSchema.optional(), search: SearchDefaultsSchema.optional(), + api_key: z.string().optional(), }); -export const CliConfigSchema = z.object({ +export const CLIConfigSchema = z.object({ version: z.string(), - api_key: z - .string() - .startsWith("mxb_", 'API key must start with "mxb_"') + api_keys: z + .record( + z.string().min(1, "API key name cannot be empty"), + z.string().startsWith("mxb_", 'API key must start with "mxb_"') + ) .optional(), base_url: z.string().url("Base URL must be a valid URL").optional(), defaults: DefaultsSchema.optional(), aliases: z.record(z.string(), z.string()).optional(), }); -export type CliConfig = z.infer; +export type CLIConfig = z.infer; function getConfigDir(): string { if (process.env.MXBAI_CONFIG_PATH) { @@ -93,8 +96,9 @@ function getConfigDir(): string { const CONFIG_DIR = getConfigDir(); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); -const DEFAULT_CONFIG: CliConfig = { +const DEFAULT_CONFIG: CLIConfig = { version: "1.0", + api_keys: {}, defaults: { upload: { strategy: "fast", @@ -105,11 +109,12 @@ const DEFAULT_CONFIG: CliConfig = { top_k: 10, rerank: false, }, + api_key: null, }, aliases: {}, }; -export function loadConfig(): CliConfig { +export function loadConfig(): CLIConfig { if (!existsSync(CONFIG_FILE)) { return DEFAULT_CONFIG; } @@ -119,7 +124,7 @@ export function loadConfig(): CliConfig { const rawConfig = JSON.parse(content); // Validate with Zod - const parseResult = CliConfigSchema.safeParse(rawConfig); + const parseResult = CLIConfigSchema.safeParse(rawConfig); if (parseResult.success) { return { ...DEFAULT_CONFIG, ...parseResult.data }; } else { @@ -142,7 +147,7 @@ export function loadConfig(): CliConfig { } } -export function saveConfig(config: CliConfig): void { +export function saveConfig(config: CLIConfig): void { if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true }); } @@ -150,24 +155,110 @@ export function saveConfig(config: CliConfig): void { writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } -export function getApiKey(options?: { apiKey?: string }): string { - // Priority: 1. Command line flag, 2. Environment variable, 3. Config file - const apiKey = - options?.apiKey || process.env.MXBAI_API_KEY || loadConfig().api_key; - - if (!apiKey) { - console.error(chalk.red("\n\nError:"), "No API key found.\n"); - console.error("Please provide your API key using one of these methods:"); - console.error(" 1. Command flag: --api-key mxb_xxxxx"); - console.error(" 2. Environment variable: export MXBAI_API_KEY=mxb_xxxxx"); - console.error(" 3. Config file: mxbai config set api_key mxb_xxxxx\n"); - console.error( - "Get your API key at: https://www.platform.mixedbread.com/platform?next=api-keys" - ); +export function getApiKey(options?: { + apiKey?: string; + savedKey?: string; +}): string { + // Priority: 1. Command line flags, 2. Environment variable, 3. Config file + + // Handle --api-key (actual API key) + if (options?.apiKey) { + if (!isMxbaiAPIKey(options.apiKey)) { + console.error( + chalk.red("✗"), + "Invalid API key format. API keys must start with 'mxb_'." + ); + process.exit(1); + } + displayApiKeyUsage(options.apiKey, "from --api-key"); + return options.apiKey; + } + + // Handle --saved-key (key name from config) + if (options?.savedKey) { + const config = loadConfig(); + if (!config.api_keys?.[options.savedKey]) { + console.error( + chalk.red("✗"), + `No saved API key found with name "${options.savedKey}".` + ); + if (config.api_keys && Object.keys(config.api_keys).length > 0) { + console.log("\nAvailable saved keys:"); + outputAvailableKeys(config); + } else { + console.log( + "\nNo saved keys found. Add one with: ", + chalk.cyan("mxbai config keys add ") + ); + } + process.exit(1); + } + const resolvedKey = config.api_keys[options.savedKey]; + displayApiKeyUsage(resolvedKey, "from --saved-key", options.savedKey); + return resolvedKey; + } + + if (process.env.MXBAI_API_KEY) { + displayApiKeyUsage(process.env.MXBAI_API_KEY, "from MXBAI_API_KEY"); + return process.env.MXBAI_API_KEY; + } + + const config = loadConfig(); + + // Check for old format and prompt for migration + if (existsSync(CONFIG_FILE)) { + try { + const rawConfig = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + if ( + rawConfig.api_key && + Object.keys(rawConfig.api_keys || {}).length === 0 + ) { + console.log(chalk.yellow("\n\n⚠ Migration Required")); + console.log( + "The API key storage format has changed. Please migrate your existing API key:" + ); + console.log( + chalk.cyan(" mxbai config keys add ") + ); + console.log("\nYour current key will not work until migrated.\n"); + process.exit(1); + } + } catch { + // If we can't read the config file, continue with normal flow + } + } + + // Get default API key from new format + const defaultKeyName = config.defaults?.api_key; + if (defaultKeyName && config.api_keys?.[defaultKeyName]) { + const apiKey = config.api_keys[defaultKeyName]; + displayApiKeyUsage(apiKey, "from config", defaultKeyName); + return apiKey; + } + + // If no default but keys exist, show available keys + if (config.api_keys && Object.keys(config.api_keys).length > 0) { + console.error(chalk.red("\n\n✗"), "No default API key set.\n"); + console.log("Available API keys:"); + outputAvailableKeys(config); + console.log("\nSet a default API key:"); + console.log(chalk.cyan(" mxbai config keys set-default \n")); process.exit(1); } - return apiKey; + console.error(chalk.red("\n\n✗"), "No API key found.\n"); + console.log("Please add an API key using:"); + console.log(" 1. Command flag: --api-key or --saved-key "); + console.log(" 2. Environment variable: export MXBAI_API_KEY=mxb_xxxxx"); + console.log(" 3. Config file: mxbai config keys add \n"); + console.log( + "Get your API key at: https://www.platform.mixedbread.com/platform?next=api-keys" + ); + process.exit(1); +} + +export function isMxbaiAPIKey(key: string): boolean { + return key.startsWith("mxb_"); } export function getBaseURL(options?: { baseURL?: string }): string { @@ -215,28 +306,56 @@ export function parseConfigValue(key: string, value: string) { const pathSegments = key.split("."); // Try to resolve the schema for this key path - const targetSchema = resolveSchemaPath(CliConfigSchema, pathSegments); + const targetSchema = resolveSchemaPath(CLIConfigSchema, pathSegments); if (!targetSchema) { console.error( - chalk.red("Error:"), + chalk.red("✗"), `Unknown config key: ${key}. Use 'mxbai config --help' to see available options.` ); process.exit(1); - return; // This won't be reached in normal execution, but helps with testing } const parsed = targetSchema.safeParse(value); if (!parsed.success) { console.error( - chalk.red("Error:"), - `Invalid value for ${key}:`, - parsed.error.issues.map((i) => i.message).join(", ") + chalk.red("✗"), + `Invalid value for ${key}: ${parsed.error.issues.map((i) => i.message).join(", ")}` ); process.exit(1); - return; // This won't be reached in normal execution, but helps with testing } return parsed.data; } + +export function outputAvailableKeys(config?: CLIConfig) { + if (!config) { + config = loadConfig(); + } + + Object.keys(config.api_keys).forEach((name) => { + const isDefault = config.defaults?.api_key === name; + console.log( + ` ${isDefault ? "*" : " "} ${name}${isDefault ? " (default)" : ""}` + ); + }); +} + +function truncateApiKey(apiKey: string): string { + if (apiKey.length <= 11) return apiKey; + return `${apiKey.slice(0, 7)}...${apiKey.slice(-4)}`; +} + +function displayApiKeyUsage(apiKey: string, source: string, keyName?: string) { + let message = "[Using API key: "; + + if (keyName) { + message += `${chalk.bold.cyan(keyName)} (${truncateApiKey(apiKey)}) `; + } else { + message += `${chalk.bold.cyan(truncateApiKey(apiKey))} `; + } + + message += `${source}]`; + console.log(message); +} diff --git a/packages/cli/src/utils/global-options.ts b/packages/cli/src/utils/global-options.ts index 0a916a9..9073651 100644 --- a/packages/cli/src/utils/global-options.ts +++ b/packages/cli/src/utils/global-options.ts @@ -4,16 +4,15 @@ import { z } from "zod"; export interface GlobalOptions { apiKey?: string; + savedKey?: string; format?: "table" | "json" | "csv"; baseURL?: string; debug?: boolean; } -export const GlobalOptionsSchema = z.object({ - apiKey: z - .string() - .startsWith("mxb_", '"api-key" must start with "mxb_"') - .optional(), +const BaseGlobalOptionsSchema = z.object({ + apiKey: z.string().optional(), + savedKey: z.string().optional(), baseURL: z.string().url('"base-url" must be a valid URL').optional(), format: z .enum(["table", "json", "csv"], { @@ -23,9 +22,31 @@ export const GlobalOptionsSchema = z.object({ debug: z.boolean({ message: '"debug" must be "true" or "false"' }).optional(), }); +export const GlobalOptionsSchema = BaseGlobalOptionsSchema.refine( + (data) => !(data.apiKey && data.savedKey), + { + message: "Cannot specify both --api-key and --saved-key options", + path: ["apiKey"], + } +); + +// Helper function to extend global options while preserving mutual exclusivity validation +export const extendGlobalOptions = (extension: T) => { + return BaseGlobalOptionsSchema.extend(extension).refine( + (data) => !(data.apiKey && data.savedKey), + { + message: "Cannot specify both --api-key and --saved-key options", + path: ["apiKey"], + } + ); +}; + +export { BaseGlobalOptionsSchema }; + export function setupGlobalOptions(program: Command): void { program - .option("--api-key ", "API key for authentication") + .option("--api-key ", "Actual API key for authentication") + .option("--saved-key ", "Name of saved API key from config") .option("--base-url ", "Base URL for the API") .option("--format ", "Output format", "table") .option("--debug", "Enable debug output", false) @@ -39,7 +60,8 @@ export function setupGlobalOptions(program: Command): void { export function addGlobalOptions(command: Command): Command { return command - .option("--api-key ", "API key for authentication") + .option("--api-key ", "Actual API key for authentication") + .option("--saved-key ", "Name of saved API key from config") .option("--base-url ", "Base URL for the API") .option("--format ", "Output format (table|json|csv)"); } @@ -79,7 +101,7 @@ export function parseOptions( if (!parsed.success) { console.error( - chalk.red("\nError:"), + chalk.red("\n✗"), parsed.error.issues.map((i) => i.message).join(", ") ); process.exit(1); diff --git a/packages/cli/src/utils/manifest.ts b/packages/cli/src/utils/manifest.ts index 65a8b61..16a093a 100644 --- a/packages/cli/src/utils/manifest.ts +++ b/packages/cli/src/utils/manifest.ts @@ -163,7 +163,7 @@ export async function uploadFromManifest( console.log(` Metadata: ${JSON.stringify(file.metadata)}`); } } catch (_error) { - console.log(` ${file.path} (${chalk.red("Error: File not found")})`); + console.log(` ${file.path} (${chalk.red("✗ File not found")})`); } }); return; @@ -210,14 +210,14 @@ export async function uploadFromManifest( }); } catch (error) { if (error instanceof z.ZodError) { - console.error(chalk.red("Error:"), "Invalid manifest file format:"); + console.error(chalk.red("✗"), "Invalid manifest file format:"); error.errors.forEach((err) => { console.error(chalk.red(` - ${err.path.join(".")}: ${err.message}`)); }); } else if (error instanceof Error) { - console.error(chalk.red("Error:"), error.message); + console.error(chalk.red("✗"), error.message); } else { - console.error(chalk.red("Error:"), "Failed to process manifest file"); + console.error(chalk.red("✗"), "Failed to process manifest file"); } process.exit(1); } diff --git a/packages/cli/src/utils/metadata.ts b/packages/cli/src/utils/metadata.ts index 861f712..6a1cd90 100644 --- a/packages/cli/src/utils/metadata.ts +++ b/packages/cli/src/utils/metadata.ts @@ -16,7 +16,7 @@ export function validateMetadata( try { return JSON.parse(metadataString); } catch (_error) { - console.error(chalk.red("Error:"), "Invalid JSON in metadata option\n"); + console.error(chalk.red("\n✗"), "Invalid JSON in metadata option\n"); process.exit(1); } } diff --git a/packages/cli/src/utils/vector-store.ts b/packages/cli/src/utils/vector-store.ts index fee3ec1..40831ea 100644 --- a/packages/cli/src/utils/vector-store.ts +++ b/packages/cli/src/utils/vector-store.ts @@ -29,10 +29,7 @@ export async function resolveVectorStore( ); if (fuzzyMatches.length === 0) { - console.error( - chalk.red("Error:"), - `Vector store "${nameOrId}" not found.\n` - ); + console.error(chalk.red("✗"), `Vector store "${nameOrId}" not found.\n`); console.error("Run 'mxbai vs list' to see all vector stores."); process.exit(1); } @@ -56,15 +53,12 @@ export async function resolveVectorStore( ]); return selected; } else { - console.error( - chalk.red("Error:"), - `Vector store "${nameOrId}" not found.\n` - ); - console.error("Did you mean one of these?"); + console.error(chalk.red("✗"), `Vector store "${nameOrId}" not found.\n`); + console.log("Did you mean one of these?"); fuzzyMatches.forEach((vs) => { - console.error(` • ${vs.name}`); + console.log(` • ${vs.name}`); }); - console.error("\nRun 'mxbai vs list' to see all vector stores."); + console.log("\nRun 'mxbai vs list' to see all vector stores."); process.exit(1); } } diff --git a/packages/cli/tests/__mocks__/chalk.ts b/packages/cli/tests/__mocks__/chalk.ts index 86ec819..b386bbc 100644 --- a/packages/cli/tests/__mocks__/chalk.ts +++ b/packages/cli/tests/__mocks__/chalk.ts @@ -1,12 +1,37 @@ // Mock chalk to avoid ESM issues in tests -const chalk = { - red: (str: string) => str, - green: (str: string) => str, - yellow: (str: string) => str, - blue: (str: string) => str, - cyan: (str: string) => str, - gray: (str: string) => str, - bold: (str: string) => str, + +const colors = [ + "red", + "green", + "yellow", + "blue", + "cyan", + "gray", + "white", + "black", +]; +const modifiers = [ + "bold", + "dim", + "italic", + "underline", + "inverse", + "hidden", + "strikethrough", +]; +const allMethods = [...colors, ...modifiers]; + +const createChainableStyle = () => { + const style = (str: string) => str; + + // Make each property return the same chainable style to enable chaining + allMethods.forEach((prop) => { + style[prop] = style; + }); + + return style; }; +const chalk = createChainableStyle(); + export default chalk; diff --git a/packages/cli/tests/commands/completion.test.ts b/packages/cli/tests/commands/completion.test.ts index 43759b7..4277d5c 100644 --- a/packages/cli/tests/commands/completion.test.ts +++ b/packages/cli/tests/commands/completion.test.ts @@ -561,13 +561,82 @@ describe("Completion Commands", () => { await parseCommand(command, []); expect(mockLog).toHaveBeenCalledWith( - ["get", "set"], + ["get", "set", "keys"], "pwsh", console.log ); }); }); + describe("keys subcommand completions", () => { + const keysCommands = ["list", "add", "remove", "set-default"]; + + it("should provide keys completions for 'mxbai config keys' context", async () => { + mockParseEnv.mockReturnValue({ + complete: true, + words: 3, + point: 0, + line: "mxbai config keys ", + partial: "", + last: "keys", + lastPartial: "", + prev: "keys", + }); + mockGetShellFromEnv.mockReturnValue("bash"); + + const command = createCompletionServerCommand(); + await parseCommand(command, []); + + expect(mockLog).toHaveBeenCalledWith( + keysCommands, + "bash", + console.log + ); + }); + + it("should not provide keys completions for non-config contexts", async () => { + mockParseEnv.mockReturnValue({ + complete: true, + words: 2, + point: 0, + line: "mxbai vs keys ", + partial: "", + last: "keys", + lastPartial: "", + prev: "keys", + }); + mockGetShellFromEnv.mockReturnValue("bash"); + + const command = createCompletionServerCommand(); + await parseCommand(command, []); + + expect(mockLog).not.toHaveBeenCalled(); + }); + + it("should handle keys completions with different word counts", async () => { + mockParseEnv.mockReturnValue({ + complete: true, + words: 4, + point: 0, + line: "mxbai config keys add ", + partial: "", + last: "add", + lastPartial: "", + prev: "keys", + }); + mockGetShellFromEnv.mockReturnValue("zsh"); + + const command = createCompletionServerCommand(); + await parseCommand(command, []); + + expect(mockLog).toHaveBeenCalledWith( + keysCommands, + "zsh", + console.log + ); + }); + }); + describe("completion completions", () => { it("should provide completion completions", async () => { mockParseEnv.mockReturnValue({ diff --git a/packages/cli/tests/commands/config/get.test.ts b/packages/cli/tests/commands/config/get.test.ts index 78094c2..cb10bd2 100644 --- a/packages/cli/tests/commands/config/get.test.ts +++ b/packages/cli/tests/commands/config/get.test.ts @@ -30,12 +30,16 @@ describe("Config Get Command", () => { it("should display all config when no key provided", () => { const testConfig = { version: "1.0", - api_key: "mxb_test123", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, defaults: { upload: { strategy: "fast", parallel: 5, }, + api_key: "work", }, aliases: { docs: "vs_abc123", @@ -82,6 +86,10 @@ describe("Config Get Command", () => { [configFile]: JSON.stringify({ version: "1.0", api_key: "mxb_test123", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, defaults: { upload: { strategy: "high_quality", @@ -92,6 +100,7 @@ describe("Config Get Command", () => { top_k: 20, rerank: false, }, + api_key: "work", }, aliases: { docs: "vs_abc123", @@ -102,9 +111,9 @@ describe("Config Get Command", () => { }); it("should get top-level values", () => { - command.parse(["node", "get", "api_key"]); + command.parse(["node", "get", "version"]); - expect(console.log).toHaveBeenCalledWith("api_key:", '"mxb_test123"'); + expect(console.log).toHaveBeenCalledWith("version:", '"1.0"'); }); it("should get nested values", () => { @@ -172,6 +181,37 @@ describe("Config Get Command", () => { ) ); }); + + it("should get api_keys object", () => { + command.parse(["node", "get", "api_keys"]); + + expect(console.log).toHaveBeenCalledWith( + "api_keys:", + JSON.stringify( + { + work: "mxb_work123", + personal: "mxb_personal123", + }, + null, + 2 + ) + ); + }); + + it("should get specific api_key", () => { + command.parse(["node", "get", "api_keys.work"]); + + expect(console.log).toHaveBeenCalledWith( + "api_keys.work:", + '"mxb_work123"' + ); + }); + + it("should get default api_key name", () => { + command.parse(["node", "get", "defaults.api_key"]); + + expect(console.log).toHaveBeenCalledWith("defaults.api_key:", '"work"'); + }); }); describe("Error handling", () => { diff --git a/packages/cli/tests/commands/config/keys.test.ts b/packages/cli/tests/commands/config/keys.test.ts new file mode 100644 index 0000000..5d832a6 --- /dev/null +++ b/packages/cli/tests/commands/config/keys.test.ts @@ -0,0 +1,415 @@ +import { join } from "node:path"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; +import type { Command } from "commander"; +import mockFs from "mock-fs"; +import { createKeysCommand } from "../../../src/commands/config/keys"; +import { loadConfig } from "../../../src/utils/config"; +import { getTestConfigDir } from "../../helpers/test-utils"; + +// Mock inquirer +jest.mock("inquirer"); + +describe("Config Keys Command", () => { + const configDir = getTestConfigDir(); + const configFile = join(configDir, "config.json"); + let command: Command; + let mockInquirer: jest.Mocked; + + beforeEach(async () => { + command = createKeysCommand(); + mockInquirer = (await import("inquirer")).default as jest.Mocked< + typeof import("inquirer").default + >; + jest.clearAllMocks(); + }); + + afterEach(() => { + mockFs.restore(); + jest.clearAllMocks(); + }); + + describe("add command", () => { + it("should add API key with provided name", () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0", api_keys: {} }), + }); + + command.parse(["node", "keys", "add", "mxb_test123", "work"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBe("mxb_test123"); + expect(config.defaults?.api_key).toBe("work"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + expect.stringContaining('API key "work" saved and set as default') + ); + }); + + it("should prompt for name if not provided", async () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0", api_keys: {} }), + }); + + mockInquirer.prompt.mockResolvedValue({ name: "personal" }); + + await command.parseAsync(["node", "keys", "add", "mxb_test123"]); + + const config = loadConfig(); + expect(config.api_keys?.personal).toBe("mxb_test123"); + expect(config.defaults?.api_key).toBe("personal"); + expect(mockInquirer.prompt).toHaveBeenCalledWith({ + type: "input", + name: "name", + message: expect.stringContaining("Enter a name for this API key"), + validate: expect.any(Function), + }); + }); + + it("should validate API key format", () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0" }), + }); + + command.parse(["node", "keys", "add", "invalid_key", "test"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'API key must start with "mxb_"' + ); + }); + + it("should validate name is not empty", () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0", api_keys: {} }), + }); + + command.parse(["node", "keys", "add", "mxb_test123", " "]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("✗"), + "Name cannot be empty" + ); + }); + + it("should check for duplicate names", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { work: "mxb_existing" }, + }), + }); + + command.parse(["node", "keys", "add", "mxb_test123", "work"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'API key "work" already exists' + ); + }); + + it("should validate name in prompt", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { existing: "mxb_existing" }, + }), + }); + + mockInquirer.prompt.mockResolvedValue({ name: "new" }); + + await command.parseAsync(["node", "keys", "add", "mxb_test123"]); + + const promptCall = mockInquirer.prompt.mock.calls[0][0] as any; + const validate = promptCall.validate; + + expect(validate("")).toBe("Name cannot be empty"); + expect(validate(" ")).toBe("Name cannot be empty"); + expect(validate("existing")).toBe('API key "existing" already exists'); + expect(validate("different")).toBe(true); + }); + }); + + describe("list command", () => { + it("should list all API keys with default marked", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + command.parse(["node", "keys", "list"]); + + expect(console.log).toHaveBeenCalledWith("API Keys:"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("* work (default)") + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(" personal") + ); + }); + + it("should show message when no API keys configured", () => { + mockFs({ + [configFile]: JSON.stringify({ version: "1.0", api_keys: {} }), + }); + + command.parse(["node", "keys", "list"]); + + expect(console.log).toHaveBeenCalledWith("No API keys configured"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("mxbai config keys add") + ); + }); + }); + + describe("remove command", () => { + it("should remove API key with confirmation", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "personal", + }, + }), + }); + + mockInquirer.prompt.mockResolvedValue({ confirm: true }); + + await command.parseAsync(["node", "keys", "remove", "work"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBeUndefined(); + expect(config.api_keys?.personal).toBe("mxb_personal123"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + 'API key "work" removed' + ); + }); + + it("should warn when removing default API key", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + mockInquirer.prompt.mockResolvedValue({ confirm: true }); + + await command.parseAsync(["node", "keys", "remove", "work"]); + + const config = loadConfig(); + expect(config.defaults?.api_key).toBeUndefined(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("⚠"), + "No default API key set. Set a new default:" + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("mxbai config keys set-default personal") + ); + }); + + it("should cancel removal when not confirmed", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + mockInquirer.prompt.mockResolvedValue({ confirm: false }); + + await command.parseAsync(["node", "keys", "remove", "work"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBe("mxb_work123"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("Removal cancelled.") + ); + }); + + it("should handle removing non-existent key", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + await command.parseAsync(["node", "keys", "remove", "nonexistent"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'No API key found with name "nonexistent"' + ); + }); + + it("should remove API key with --force flag without confirmation", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + }), + }); + + await command.parseAsync(["node", "keys", "remove", "work", "--force"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBeUndefined(); + expect(config.api_keys?.personal).toBe("mxb_personal123"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + 'API key "work" removed' + ); + // Should not call inquirer.prompt when using --force + expect(mockInquirer.prompt).not.toHaveBeenCalled(); + }); + + it("should remove default API key with --force flag without confirmation", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + await command.parseAsync(["node", "keys", "remove", "work", "--force"]); + + const config = loadConfig(); + expect(config.api_keys?.work).toBeUndefined(); + expect(config.defaults?.api_key).toBeUndefined(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + 'API key "work" removed' + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("⚠"), + "No default API key set. Set a new default:" + ); + // Should not call inquirer.prompt when using --force + expect(mockInquirer.prompt).not.toHaveBeenCalled(); + }); + + it("should handle --force flag with non-existent key", async () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + await command.parseAsync([ + "node", + "keys", + "remove", + "nonexistent", + "--force", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'No API key found with name "nonexistent"' + ); + // Should not call inquirer.prompt even with --force when key doesn't exist + expect(mockInquirer.prompt).not.toHaveBeenCalled(); + }); + }); + + describe("set-default command", () => { + it("should set default API key", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + command.parse(["node", "keys", "set-default", "personal"]); + + const config = loadConfig(); + expect(config.defaults?.api_key).toBe("personal"); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("✓"), + '"personal" set as default API key' + ); + }); + + it("should handle setting default for non-existent key", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + command.parse(["node", "keys", "set-default", "nonexistent"]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("✗"), + 'No API key found with name "nonexistent"' + ); + expect(console.log).toHaveBeenCalledWith("\nAvailable API keys:"); + }); + }); + + describe("help command", () => { + it("should show help when no subcommand provided", () => { + const helpSpy = jest.spyOn(command, "help").mockImplementation((() => { + console.log("Keys help"); + return command; + }) as any); + + command.parse(["node", "keys"]); + + expect(helpSpy).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Keys help"); + }); + }); +}); diff --git a/packages/cli/tests/commands/config/set.test.ts b/packages/cli/tests/commands/config/set.test.ts index fdd83da..3bb5fa2 100644 --- a/packages/cli/tests/commands/config/set.test.ts +++ b/packages/cli/tests/commands/config/set.test.ts @@ -28,18 +28,23 @@ describe("Config Set Command", () => { }); describe("Basic value setting", () => { - it("should set API key", () => { + it("should set default API key name in new format", () => { mockFs({ - [configFile]: JSON.stringify({ version: "1.0" }), + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), }); - command.parse(["node", "set", "api_key", "mxb_test123"]); + command.parse(["node", "set", "defaults.api_key", "work"]); const config = loadConfig(); - expect(config.api_key).toBe("mxb_test123"); + expect(config.defaults?.api_key).toBe("work"); expect(console.log).toHaveBeenCalledWith( expect.stringContaining("✓"), - expect.stringContaining("Set api_key to mxb_test123") + expect.stringContaining("Set defaults.api_key to work") ); }); @@ -140,21 +145,6 @@ describe("Config Set Command", () => { }); describe("Validation", () => { - it("should validate API key format", () => { - mockFs({ - [configFile]: JSON.stringify({ version: "1.0" }), - }); - - command.parse(["node", "set", "api_key", "invalid_key"]); - - expect(console.error).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining("Invalid value for api_key:"), - expect.any(String) - ); - expect(process.exit).toHaveBeenCalledWith(1); - }); - it("should validate enum values", () => { mockFs({ [configFile]: JSON.stringify({ version: "1.0" }), @@ -164,8 +154,7 @@ describe("Config Set Command", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for defaults.upload.strategy:"), - expect.any(String) + expect.stringContaining("Invalid value for defaults.upload.strategy:") ); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -204,8 +193,7 @@ describe("Config Set Command", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for defaults.upload.parallel:"), - expect.any(String) + expect.stringContaining("Invalid value for defaults.upload.parallel:") ); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -245,10 +233,10 @@ describe("Config Set Command", () => { it("should create config file if it does not exist", () => { mockFs({}); - command.parse(["node", "set", "api_key", "mxb_test123"]); + command.parse(["node", "set", "base_url", "https://api.example.com"]); const config = loadConfig(); - expect(config.api_key).toBe("mxb_test123"); + expect(config.base_url).toBe("https://api.example.com"); }); }); }); diff --git a/packages/cli/tests/commands/vector-store/create.test.ts b/packages/cli/tests/commands/vector-store/create.test.ts index 35b06e9..ce819be 100644 --- a/packages/cli/tests/commands/vector-store/create.test.ts +++ b/packages/cli/tests/commands/vector-store/create.test.ts @@ -204,7 +204,7 @@ describe("Vector Store Create Command", () => { ]); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Error:"), + expect.stringContaining("✗"), expect.stringContaining("Invalid JSON in metadata option") ); expect(process.exit).toHaveBeenCalledWith(1); diff --git a/packages/cli/tests/commands/vector-store/update.test.ts b/packages/cli/tests/commands/vector-store/update.test.ts index 073cb29..bb71d9f 100644 --- a/packages/cli/tests/commands/vector-store/update.test.ts +++ b/packages/cli/tests/commands/vector-store/update.test.ts @@ -257,7 +257,7 @@ describe("Vector Store Update Command", () => { ]); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Error:"), + expect.stringContaining("✗"), expect.stringContaining("Invalid JSON in metadata option") ); expect(process.exit).toHaveBeenCalledWith(1); diff --git a/packages/cli/tests/commands/vector-store/upload.test.ts b/packages/cli/tests/commands/vector-store/upload.test.ts index c966cd3..2f33e2f 100644 --- a/packages/cli/tests/commands/vector-store/upload.test.ts +++ b/packages/cli/tests/commands/vector-store/upload.test.ts @@ -286,7 +286,7 @@ describe("Vector Store Upload Command", () => { ]); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Error:"), + expect.stringContaining("✗"), expect.stringContaining("Invalid JSON in metadata option") ); expect(process.exit).toHaveBeenCalledWith(1); @@ -406,7 +406,7 @@ describe("Vector Store Upload Command", () => { ]); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Error:"), + expect.stringContaining("✗"), expect.stringContaining("List failed") ); expect(process.exit).toHaveBeenCalledWith(1); diff --git a/packages/cli/tests/utils/config.test.ts b/packages/cli/tests/utils/config.test.ts index 6f1333c..c3de2a1 100644 --- a/packages/cli/tests/utils/config.test.ts +++ b/packages/cli/tests/utils/config.test.ts @@ -1,16 +1,9 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { - afterEach, - beforeEach, - describe, - expect, - it, - jest, -} from "@jest/globals"; +import { afterEach, describe, expect, it, jest } from "@jest/globals"; import mockFs from "mock-fs"; import { - type CliConfig, + type CLIConfig, getApiKey, loadConfig, parseConfigValue, @@ -52,14 +45,18 @@ describe("Config Utils", () => { }); it("should load and validate config from file", () => { - const testConfig: CliConfig = { + const testConfig: CLIConfig = { version: "1.0", - api_key: "mxb_test123", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, defaults: { upload: { strategy: "high_quality", parallel: 10, }, + api_key: "work", }, aliases: { docs: "vs_abc123", @@ -72,7 +69,9 @@ describe("Config Utils", () => { const config = loadConfig(); - expect(config.api_key).toBe("mxb_test123"); + expect(config.api_keys?.work).toBe("mxb_work123"); + expect(config.api_keys?.personal).toBe("mxb_personal123"); + expect(config.defaults?.api_key).toBe("work"); expect(config.defaults?.upload?.strategy).toBe("high_quality"); expect(config.aliases?.docs).toBe("vs_abc123"); }); @@ -95,14 +94,16 @@ describe("Config Utils", () => { mockFs({ [configFile]: JSON.stringify({ version: "1.0", - api_key: "invalid_key", // Should start with mxb_ + api_keys: { + invalid: "invalid_key", // Should start with mxb_ + }, }), }); const config = loadConfig(); expect(console.warn).toHaveBeenCalled(); - expect(config.api_key).toBeUndefined(); + expect(config.api_keys).toEqual({}); }); }); @@ -110,9 +111,11 @@ describe("Config Utils", () => { it("should create config directory if it does not exist", () => { mockFs({}); - const config: CliConfig = { + const config: CLIConfig = { version: "1.0", - api_key: "mxb_test123", + api_keys: { + test: "mxb_test123", + }, }; saveConfig(config); @@ -122,8 +125,14 @@ describe("Config Utils", () => { }); it("should overwrite existing config file", () => { - const oldConfig = { version: "1.0", api_key: "mxb_old" }; - const newConfig: CliConfig = { version: "1.0", api_key: "mxb_new" }; + const oldConfig: CLIConfig = { + version: "1.0", + api_keys: { old: "mxb_old" }, + }; + const newConfig: CLIConfig = { + version: "1.0", + api_keys: { new: "mxb_new" }, + }; mockFs({ [configFile]: JSON.stringify(oldConfig), @@ -132,7 +141,7 @@ describe("Config Utils", () => { saveConfig(newConfig); const savedContent = readFileSync(configFile, "utf-8"); - expect(JSON.parse(savedContent).api_key).toBe("mxb_new"); + expect(JSON.parse(savedContent).api_keys.new).toBe("mxb_new"); }); }); @@ -147,12 +156,17 @@ describe("Config Utils", () => { } }); - it("should prioritize command line option", () => { + it("should prioritize command line option with direct API key", () => { process.env.MXBAI_API_KEY = "mxb_env123"; mockFs({ [configFile]: JSON.stringify({ version: "1.0", - api_key: "mxb_config123", + api_keys: { + work: "mxb_work123", + }, + defaults: { + api_key: "work", + }, }), }); @@ -161,12 +175,51 @@ describe("Config Utils", () => { expect(apiKey).toBe("mxb_cli123"); }); + it("should exit with error when --api-key has invalid format", () => { + // Mock process.exit to throw an error to stop execution + const mockExit = jest.fn().mockImplementation(() => { + throw new Error("process.exit called"); + }); + process.exit = mockExit as never; + + expect(() => { + getApiKey({ apiKey: "invalid_key_format" }); + }).toThrow("process.exit called"); + + expect(console.error).toHaveBeenCalledWith( + expect.any(String), + "Invalid API key format. API keys must start with 'mxb_'." + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("should resolve API key name from --saved-key option", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, + }), + }); + + const apiKey = getApiKey({ savedKey: "personal" }); + + expect(apiKey).toBe("mxb_personal123"); + }); + it("should use environment variable when no CLI option", () => { process.env.MXBAI_API_KEY = "mxb_env123"; mockFs({ [configFile]: JSON.stringify({ version: "1.0", - api_key: "mxb_config123", + api_keys: { + work: "mxb_work123", + }, }), }); @@ -175,23 +228,101 @@ describe("Config Utils", () => { expect(apiKey).toBe("mxb_env123"); }); - it("should use config file when no CLI option or env var", () => { + it("should use default API key from new format", () => { delete process.env.MXBAI_API_KEY; mockFs({ [configFile]: JSON.stringify({ version: "1.0", - api_key: "mxb_config123", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + defaults: { + api_key: "work", + }, }), }); const apiKey = getApiKey(); - expect(apiKey).toBe("mxb_config123"); + expect(apiKey).toBe("mxb_work123"); + }); + + it("should prompt for migration when old format detected", () => { + delete process.env.MXBAI_API_KEY; + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_key: "mxb_old123", + api_keys: {}, + }), + }); + + getApiKey(); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("⚠ Migration Required") + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("should show available keys when no default set", () => { + delete process.env.MXBAI_API_KEY; + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + personal: "mxb_personal123", + }, + }), + }); + + getApiKey(); + + expect(console.error).toHaveBeenCalledWith( + expect.any(String), + "No default API key set.\n" + ); + expect(console.log).toHaveBeenCalledWith("Available API keys:"); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("should exit with error when saved API key name not found", () => { + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: { + work: "mxb_work123", + }, + }), + }); + + // Mock process.exit to throw an error to stop execution + const mockExit = jest.fn().mockImplementation(() => { + throw new Error("process.exit called"); + }); + process.exit = mockExit as never; + + expect(() => { + getApiKey({ savedKey: "nonexistent" }); + }).toThrow("process.exit called"); + + expect(console.error).toHaveBeenCalledWith( + expect.any(String), + 'No saved API key found with name "nonexistent".' + ); + expect(process.exit).toHaveBeenCalledWith(1); }); it("should exit with error when no API key found", () => { delete process.env.MXBAI_API_KEY; - mockFs({}); + mockFs({ + [configFile]: JSON.stringify({ + version: "1.0", + api_keys: {}, + }), + }); getApiKey(); @@ -264,17 +395,18 @@ describe("Config Utils", () => { }); it("should parse string values", () => { - expect(parseConfigValue("api_key", "mxb_test123")).toBe("mxb_test123"); + expect(parseConfigValue("base_url", "https://api.example.com")).toBe( + "https://api.example.com" + ); expect(parseConfigValue("aliases.docs", "vs_abc123")).toBe("vs_abc123"); }); - it("should validate api_key format", () => { - parseConfigValue("api_key", "invalid_key"); + it("should validate URL format", () => { + parseConfigValue("base_url", "invalid_url"); expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for api_key:"), - expect.any(String) + expect.stringContaining("Invalid value for base_url:") ); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -284,8 +416,7 @@ describe("Config Utils", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for defaults.upload.strategy:"), - expect.any(String) + expect.stringContaining("Invalid value for defaults.upload.strategy:") ); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -295,14 +426,21 @@ describe("Config Utils", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining("Invalid value for defaults.upload.parallel:"), - expect.stringContaining("positive") + expect.stringContaining("Invalid value for defaults.upload.parallel:") ); expect(process.exit).toHaveBeenCalledWith(1); }); it("should handle unknown config keys", () => { - parseConfigValue("unknown.key", "value"); + // Mock process.exit to throw an error to stop execution + const mockExit = jest.fn().mockImplementation(() => { + throw new Error("process.exit called"); + }); + process.exit = mockExit as never; + + expect(() => { + parseConfigValue("unknown.key", "value"); + }).toThrow("process.exit called"); expect(console.error).toHaveBeenCalledWith( expect.any(String), diff --git a/packages/cli/tests/utils/global-options.test.ts b/packages/cli/tests/utils/global-options.test.ts index b26e012..fc6e47a 100644 --- a/packages/cli/tests/utils/global-options.test.ts +++ b/packages/cli/tests/utils/global-options.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { Command } from "commander"; import { z } from "zod"; import { + BaseGlobalOptionsSchema, GlobalOptionsSchema, mergeCommandOptions, parseOptions, @@ -18,6 +19,9 @@ describe("Global Options", () => { expect(options).toContainEqual( expect.objectContaining({ long: "--api-key" }) ); + expect(options).toContainEqual( + expect.objectContaining({ long: "--saved-key" }) + ); expect(options).toContainEqual( expect.objectContaining({ long: "--format" }) ); @@ -149,27 +153,62 @@ describe("Global Options", () => { expect(parsed).toEqual(options); }); - it("should allow optional fields to be undefined", () => { - const options = {}; + it("should parse valid options with savedKey", () => { + const options = { + savedKey: "work", + format: "json", + debug: true, + }; + const parsed = parseOptions(GlobalOptionsSchema, options); - expect(parsed).toEqual({}); + expect(parsed).toEqual(options); }); - it("should validate API key format", () => { + it("should reject both apiKey and savedKey options", () => { const options = { - apiKey: "invalid_key", + apiKey: "mxb_test123", + savedKey: "work", }; parseOptions(GlobalOptionsSchema, options); expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining('"api-key" must start with "mxb_"') + expect.stringContaining( + "Cannot specify both --api-key and --saved-key options" + ) ); expect(process.exit).toHaveBeenCalledWith(1); }); + it("should allow optional fields to be undefined", () => { + const options = {}; + const parsed = parseOptions(GlobalOptionsSchema, options); + + expect(parsed).toEqual({}); + }); + + it("should accept any string as API key (validation moved to resolution)", () => { + const options = { + apiKey: "work", // API key name + }; + + const parsed = parseOptions(GlobalOptionsSchema, options); + + expect(parsed).toEqual(options); + }); + + it("should accept actual API keys", () => { + const options = { + apiKey: "mxb_test123", // Actual API key + }; + + const parsed = parseOptions(GlobalOptionsSchema, options); + + expect(parsed).toEqual(options); + }); + it("should validate format enum", () => { const options = { format: "invalid", @@ -186,9 +225,9 @@ describe("Global Options", () => { expect(process.exit).toHaveBeenCalledWith(1); }); - it("should handle multiple validation errors", () => { + it("should handle validation errors for format", () => { const options = { - apiKey: "invalid", + apiKey: "work", // Valid API key name format: "invalid", }; @@ -196,13 +235,15 @@ describe("Global Options", () => { expect(console.error).toHaveBeenCalledWith( expect.any(String), - expect.stringContaining('must start with "mxb_"') + expect.stringContaining( + '"format" must be either "table", "json", or "csv"' + ) ); expect(process.exit).toHaveBeenCalledWith(1); }); it("should parse custom schemas", () => { - const customSchema = GlobalOptionsSchema.extend({ + const customSchema = BaseGlobalOptionsSchema.extend({ customField: z.string(), }); @@ -215,5 +256,28 @@ describe("Global Options", () => { expect(parsed).toEqual(options); }); + + it("should reject both apiKey and savedKey in extended schemas", () => { + const { extendGlobalOptions } = require("../../src/utils/global-options"); + const customSchema = extendGlobalOptions({ + customField: z.string(), + }); + + const options = { + apiKey: "mxb_test123", + savedKey: "work", + customField: "test", + }; + + parseOptions(customSchema, options); + + expect(console.error).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining( + "Cannot specify both --api-key and --saved-key options" + ) + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); }); });