diff --git a/.changeset/fluffy-lines-visit.md b/.changeset/fluffy-lines-visit.md new file mode 100644 index 0000000..1662747 --- /dev/null +++ b/.changeset/fluffy-lines-visit.md @@ -0,0 +1,14 @@ +--- +"@mixedbread/cli": patch +--- + +Improved CLI I/O, concurrency, and code deduplication + +- Routed log and spinner output to stderr to keep stdout clean for piped data +- Replaced internal `mergeCommandOptions` with Commander's built-in `optsWithGlobals()` +- Switched to sliding-window concurrency (`p-limit`) for file uploads +- Used async file I/O in upload and sync hot paths +- Extracted shared `checkExistingFiles` utility +- Fixed missing `process.exit(1)` in completion command error handlers +- Fixed spinners not being stopped in catch blocks for sync and upload commands +- Changed `formatBytes` to return `"-"` instead of `"0 B"` for undefined/NaN values diff --git a/packages/cli/package.json b/packages/cli/package.json index 1888137..1b63e49 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,7 +55,6 @@ "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^14.0.3", - "dotenv": "^17.3.1", "glob": "^13.0.3", "mime-types": "^3.0.2", "minimatch": "^10.2.0", diff --git a/packages/cli/src/bin/mxbai.ts b/packages/cli/src/bin/mxbai.ts index cfbcf59..87dea68 100644 --- a/packages/cli/src/bin/mxbai.ts +++ b/packages/cli/src/bin/mxbai.ts @@ -15,25 +15,19 @@ import { setupGlobalOptions } from "../utils/global-options"; import { checkForUpdates } from "../utils/update-checker"; // Find package.json relative to the compiled file location -// In the published package, from bin/mxbai.js, package.json is one level up +const VERSION_PATHS = [ + join(__dirname, "..", "package.json"), + join(__dirname, "..", "..", "package.json"), +]; let version = "0.0.0"; -try { - // First try one level up (for published package) - const packageJsonPath = join(__dirname, "..", "package.json"); - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); - version = packageJson.version; -} catch (_error) { +for (const pkgPath of VERSION_PATHS) { try { - // Fallback to two levels up (for development/build environment) - const packageJsonPath = join(__dirname, "..", "..", "package.json"); - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); - version = packageJson.version; - } catch (_error2) { - // Final fallback if package.json is not found - console.warn( - "Warning: Could not read package.json for version information" - ); - } + version = JSON.parse(readFileSync(pkgPath, "utf-8")).version; + break; + } catch {} +} +if (version === "0.0.0") { + console.warn("Warning: Could not read package.json for version information"); } const program = new Command(); @@ -76,7 +70,7 @@ program.on("command:*", () => { // Parse arguments async function main() { try { - await checkForUpdates(version); + const updateCheck = checkForUpdates(version); // Show help if no arguments provided if (process.argv.length === 2) { @@ -84,6 +78,11 @@ async function main() { } await program.parseAsync(process.argv); + + const banner = await updateCheck; + if (banner) { + console.error(banner); + } } catch (error) { if (error instanceof Error) { console.error(chalk.red("\nāœ—"), error.message); diff --git a/packages/cli/src/commands/completion.ts b/packages/cli/src/commands/completion.ts index d0b4202..91b69b9 100644 --- a/packages/cli/src/commands/completion.ts +++ b/packages/cli/src/commands/completion.ts @@ -1,10 +1,9 @@ import path from "node:path"; -import { log as clackLog, spinner } from "@clack/prompts"; import { getShellFromEnv, install, - log, parseEnv, + log as tabtabLog, uninstall, } from "@pnpm/tabtab"; import chalk from "chalk"; @@ -17,10 +16,9 @@ import { import { addGlobalOptions, BaseGlobalOptionsSchema, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../utils/global-options"; +import { log, spinner } from "../utils/logger"; const SUPPORTED_SHELLS = ["bash", "zsh", "fish", "pwsh"] as const; export type SupportedShell = (typeof SUPPORTED_SHELLS)[number]; @@ -141,6 +139,7 @@ export function createCompletionCommand(): Command { } } catch (error) { console.error(chalk.red("āœ—"), "Error installing completion:", error); + process.exit(1); } }); @@ -158,6 +157,7 @@ export function createCompletionCommand(): Command { ); } catch (error) { console.error(chalk.red("āœ—"), "Error uninstalling completion:", error); + process.exit(1); } }); @@ -167,8 +167,8 @@ export function createCompletionCommand(): Command { ) ); - refreshCommand.action(async (options: GlobalOptions) => { - const mergedOptions = mergeCommandOptions(refreshCommand, options); + refreshCommand.action(async () => { + const mergedOptions = refreshCommand.optsWithGlobals(); const parsedOptions = parseOptions(BaseGlobalOptionsSchema, { ...mergedOptions, }); @@ -180,11 +180,12 @@ export function createCompletionCommand(): Command { refreshSpinner.stop("Completion cache refreshed successfully"); } catch (error) { refreshSpinner.stop(); - clackLog.error( + log.error( error instanceof Error ? error.message : "Failed to refresh completion cache" ); + process.exit(1); } }); @@ -215,7 +216,7 @@ export function createCompletionServerCommand(): Command { // Root level completions - "mxbai " (when no previous command) if (env.words === 1) { - return log( + return tabtabLog( ["config", "store", "completion", "--help", "--version"], shell, console.log @@ -241,7 +242,7 @@ export function createCompletionServerCommand(): Command { if (keyName) { const stores = getStoresForCompletion(keyName); if (stores.length > 0) { - return log(stores, shell, console.log); + return tabtabLog(stores, shell, console.log); } } } @@ -249,7 +250,7 @@ export function createCompletionServerCommand(): Command { // Store completions if (env.prev === "store") { - return log( + return tabtabLog( [ "create", "delete", @@ -272,7 +273,7 @@ export function createCompletionServerCommand(): Command { // Check if we're in "mxbai store files " context const words = env.line.trim().split(/\s+/); if (words.length >= 3 && words[1] === "store") { - return log(["list", "get", "delete"], shell, console.log); + return tabtabLog(["list", "get", "delete"], shell, console.log); } } @@ -291,7 +292,7 @@ export function createCompletionServerCommand(): Command { if (keyName) { const stores = getStoresForCompletion(keyName); if (stores.length > 0) { - return log(stores, shell, console.log); + return tabtabLog(stores, shell, console.log); } } } @@ -299,14 +300,14 @@ export function createCompletionServerCommand(): Command { // Config completions if (env.prev === "config") { - return log(["get", "set", "keys"], shell, console.log); + return tabtabLog(["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( + return tabtabLog( ["list", "add", "remove", "set-default"], shell, console.log @@ -316,7 +317,11 @@ export function createCompletionServerCommand(): Command { // Completion completions if (env.prev === "completion") { - return log(["install", "uninstall", "refresh"], shell, console.log); + return tabtabLog( + ["install", "uninstall", "refresh"], + shell, + console.log + ); } }); diff --git a/packages/cli/src/commands/config/keys.ts b/packages/cli/src/commands/config/keys.ts index 5c3ce2f..10d07d3 100644 --- a/packages/cli/src/commands/config/keys.ts +++ b/packages/cli/src/commands/config/keys.ts @@ -1,4 +1,4 @@ -import { cancel, confirm, isCancel, log, text } from "@clack/prompts"; +import { cancel, confirm, isCancel, text } from "@clack/prompts"; import chalk from "chalk"; import { Command } from "commander"; import { z } from "zod"; @@ -16,10 +16,9 @@ import { import { addGlobalOptions, BaseGlobalOptionsSchema, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log } from "../../utils/logger"; const RemoveKeySchema = z.object({ name: z.string().min(1, { error: '"name" is required' }), @@ -34,8 +33,8 @@ export function createKeysCommand(): Command { keysCommand .command("add [name]") .description("Add a new API key") - .action(async (key: string, name?: string, options?: GlobalOptions) => { - const mergedOptions = mergeCommandOptions(keysCommand, options); + .action(async (key: string, name?: string) => { + const mergedOptions = keysCommand.optsWithGlobals(); const parsedOptions = parseOptions(BaseGlobalOptionsSchema, { ...mergedOptions, }); @@ -185,8 +184,8 @@ export function createKeysCommand(): Command { keysCommand .command("set-default ") .description("Set the default API key") - .action(async (name: string, options: GlobalOptions) => { - const mergedOptions = mergeCommandOptions(keysCommand, options); + .action(async (name: string) => { + const mergedOptions = keysCommand.optsWithGlobals(); const parsedOptions = parseOptions(BaseGlobalOptionsSchema, { ...mergedOptions, }); diff --git a/packages/cli/src/commands/store/create.ts b/packages/cli/src/commands/store/create.ts index e0113ba..bdffa2c 100644 --- a/packages/cli/src/commands/store/create.ts +++ b/packages/cli/src/commands/store/create.ts @@ -1,4 +1,3 @@ -import { log, spinner } from "@clack/prompts"; import { Command } from "commander"; import { z } from "zod"; import { createClient } from "../../utils/client"; @@ -9,10 +8,9 @@ import { import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log, spinner } from "../../utils/logger"; import { validateMetadata } from "../../utils/metadata"; import { formatOutput } from "../../utils/output"; import { buildStoreConfig, parsePublicFlag } from "../../utils/store"; @@ -30,14 +28,6 @@ const CreateStoreSchema = extendGlobalOptions({ metadata: z.string().optional(), }); -interface CreateOptions extends GlobalOptions { - description?: string; - public?: boolean | string; - contextualization?: boolean | string; - expiresAfter?: number; - metadata?: string; -} - export function createCreateCommand(): Command { const command = addGlobalOptions( new Command("create") @@ -56,11 +46,11 @@ export function createCreateCommand(): Command { .option("--metadata ", "Additional metadata as JSON string") ); - command.action(async (name: string, options: CreateOptions) => { + command.action(async (name: string) => { const createSpinner = spinner(); try { - const mergedOptions = mergeCommandOptions(command, options); + const mergedOptions = command.optsWithGlobals(); const client = createClient(mergedOptions); const parsedOptions = parseOptions(CreateStoreSchema, { diff --git a/packages/cli/src/commands/store/delete.ts b/packages/cli/src/commands/store/delete.ts index 4f5e992..b436169 100644 --- a/packages/cli/src/commands/store/delete.ts +++ b/packages/cli/src/commands/store/delete.ts @@ -1,4 +1,4 @@ -import { confirm, isCancel, log, spinner } from "@clack/prompts"; +import { confirm, isCancel } from "@clack/prompts"; import { Command } from "commander"; import { z } from "zod"; import { createClient } from "../../utils/client"; @@ -9,10 +9,9 @@ import { import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log, spinner } from "../../utils/logger"; import { resolveStore } from "../../utils/store"; const DeleteStoreSchema = extendGlobalOptions({ @@ -20,10 +19,6 @@ const DeleteStoreSchema = extendGlobalOptions({ yes: z.boolean().optional(), }); -interface DeleteOptions extends GlobalOptions { - yes?: boolean; -} - export function createDeleteCommand(): Command { const command = addGlobalOptions( new Command("delete") @@ -33,11 +28,10 @@ export function createDeleteCommand(): Command { .option("-y, --yes", "Skip confirmation prompt") ); - command.action(async (nameOrId: string, options: DeleteOptions) => { + command.action(async (nameOrId: string) => { const deleteSpinner = spinner(); - try { - const mergedOptions = mergeCommandOptions(command, options); + const mergedOptions = command.optsWithGlobals(); const parsedOptions = parseOptions(DeleteStoreSchema, { ...mergedOptions, diff --git a/packages/cli/src/commands/store/files/delete.ts b/packages/cli/src/commands/store/files/delete.ts index 074a51d..5b5799a 100644 --- a/packages/cli/src/commands/store/files/delete.ts +++ b/packages/cli/src/commands/store/files/delete.ts @@ -1,14 +1,13 @@ -import { confirm, isCancel, log, spinner } from "@clack/prompts"; +import { confirm, isCancel } from "@clack/prompts"; import { Command } from "commander"; import z from "zod"; import { createClient } from "../../../utils/client"; import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../../utils/global-options"; +import { log, spinner } from "../../../utils/logger"; import { resolveStore } from "../../../utils/store"; const DeleteFileSchema = extendGlobalOptions({ @@ -27,50 +26,49 @@ export function createDeleteCommand(): Command { .option("-y, --yes", "Skip confirmation prompt") ); - deleteCommand.action( - async (nameOrId: string, fileId: string, options: GlobalOptions) => { - const deleteSpinner = spinner(); - try { - const mergedOptions = mergeCommandOptions(deleteCommand, options); + deleteCommand.action(async (nameOrId: string, fileId: string) => { + const deleteSpinner = spinner(); - const parsedOptions = parseOptions(DeleteFileSchema, { - ...mergedOptions, - nameOrId, - fileId, - }); + try { + const mergedOptions = deleteCommand.optsWithGlobals(); + + const parsedOptions = parseOptions(DeleteFileSchema, { + ...mergedOptions, + nameOrId, + fileId, + }); - const client = createClient(parsedOptions); - const store = await resolveStore(client, parsedOptions.nameOrId); + const client = createClient(parsedOptions); + const store = await resolveStore(client, parsedOptions.nameOrId); - // Confirmation prompt unless --yes is used - if (!parsedOptions.yes) { - const confirmed = await confirm({ - message: `Are you sure you want to delete file "${parsedOptions.fileId}" from store "${store.name}" (${store.id})? This action cannot be undone.`, - initialValue: false, - }); + // Confirmation prompt unless --yes is used + if (!parsedOptions.yes) { + const confirmed = await confirm({ + message: `Are you sure you want to delete file "${parsedOptions.fileId}" from store "${store.name}" (${store.id})? This action cannot be undone.`, + initialValue: false, + }); - if (isCancel(confirmed) || !confirmed) { - log.warn("Deletion cancelled."); - return; - } + if (isCancel(confirmed) || !confirmed) { + log.warn("Deletion cancelled."); + return; } + } - deleteSpinner.start("Deleting file..."); + deleteSpinner.start("Deleting file..."); - await client.stores.files.delete(parsedOptions.fileId, { - store_identifier: store.id, - }); + await client.stores.files.delete(parsedOptions.fileId, { + store_identifier: store.id, + }); - deleteSpinner.stop(`File ${parsedOptions.fileId} deleted successfully`); - } catch (error) { - deleteSpinner.stop(); - log.error( - error instanceof Error ? error.message : "Failed to delete file" - ); - process.exit(1); - } + deleteSpinner.stop(`File ${parsedOptions.fileId} deleted successfully`); + } catch (error) { + deleteSpinner.stop(); + log.error( + error instanceof Error ? error.message : "Failed to delete file" + ); + process.exit(1); } - ); + }); return deleteCommand; } diff --git a/packages/cli/src/commands/store/files/get.ts b/packages/cli/src/commands/store/files/get.ts index 258e4a2..2b48fba 100644 --- a/packages/cli/src/commands/store/files/get.ts +++ b/packages/cli/src/commands/store/files/get.ts @@ -1,14 +1,12 @@ -import { log, spinner } from "@clack/prompts"; import { Command } from "commander"; import { z } from "zod"; import { createClient } from "../../../utils/client"; import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../../utils/global-options"; +import { log, spinner } from "../../../utils/logger"; import { formatBytes, formatOutput } from "../../../utils/output"; import { resolveStore } from "../../../utils/store"; @@ -25,51 +23,49 @@ export function createGetCommand(): Command { .argument("", "ID of the file") ); - getCommand.action( - async (nameOrId: string, fileId: string, options: GlobalOptions) => { - const getSpinner = spinner(); + getCommand.action(async (nameOrId: string, fileId: string) => { + const getSpinner = spinner(); - try { - const mergedOptions = mergeCommandOptions(getCommand, options); + try { + const mergedOptions = getCommand.optsWithGlobals(); - const parsedOptions = parseOptions(GetFileSchema, { - ...mergedOptions, - nameOrId, - fileId, - }); + const parsedOptions = parseOptions(GetFileSchema, { + ...mergedOptions, + nameOrId, + fileId, + }); - const client = createClient(parsedOptions); - getSpinner.start("Loading file details..."); - const store = await resolveStore(client, parsedOptions.nameOrId); + const client = createClient(parsedOptions); + getSpinner.start("Loading file details..."); + const store = await resolveStore(client, parsedOptions.nameOrId); - const file = await client.stores.files.retrieve(parsedOptions.fileId, { - store_identifier: store.id, - }); + const file = await client.stores.files.retrieve(parsedOptions.fileId, { + store_identifier: store.id, + }); - getSpinner.stop("File details loaded"); + getSpinner.stop("File details loaded"); - const formattedData = { - id: file.id, - name: file.filename, - status: file.status, - size: formatBytes(file.usage_bytes), - "created at": new Date(file.created_at).toLocaleString(), - metadata: - parsedOptions.format === "table" - ? JSON.stringify(file.metadata, null, 2) - : file.metadata, - }; + const formattedData = { + id: file.id, + name: file.filename, + status: file.status, + size: formatBytes(file.usage_bytes), + "created at": new Date(file.created_at).toLocaleString(), + metadata: + parsedOptions.format === "table" + ? JSON.stringify(file.metadata, null, 2) + : file.metadata, + }; - formatOutput(formattedData, parsedOptions.format); - } catch (error) { - getSpinner.stop(); - log.error( - error instanceof Error ? error.message : "Failed to get file details" - ); - process.exit(1); - } + formatOutput(formattedData, parsedOptions.format); + } catch (error) { + getSpinner.stop(); + log.error( + error instanceof Error ? error.message : "Failed to get file details" + ); + process.exit(1); } - ); + }); return getCommand; } diff --git a/packages/cli/src/commands/store/files/list.ts b/packages/cli/src/commands/store/files/list.ts index 951c751..93319a0 100644 --- a/packages/cli/src/commands/store/files/list.ts +++ b/packages/cli/src/commands/store/files/list.ts @@ -1,4 +1,3 @@ -import { log, spinner } from "@clack/prompts"; import type { StoreFile } from "@mixedbread/sdk/resources/stores"; import { Command } from "commander"; import { z } from "zod"; @@ -6,16 +5,15 @@ import { createClient } from "../../../utils/client"; import { addGlobalOptions, extendGlobalOptions, - mergeCommandOptions, parseOptions, } from "../../../utils/global-options"; +import { log, spinner } from "../../../utils/logger"; import { formatBytes, formatCountWithSuffix, formatOutput, } from "../../../utils/output"; import { resolveStore } from "../../../utils/store"; -import type { FilesOptions } from "."; const ListFilesSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { error: '"name-or-id" is required' }), @@ -46,11 +44,11 @@ export function createListCommand(): Command { .option("--limit ", "Maximum number of results", "10") ); - listCommand.action(async (nameOrId: string, options: FilesOptions) => { + listCommand.action(async (nameOrId: string) => { const listSpinner = spinner(); try { - const mergedOptions = mergeCommandOptions(listCommand, options); + const mergedOptions = listCommand.optsWithGlobals(); const parsedOptions = parseOptions(ListFilesSchema, { ...mergedOptions, nameOrId, diff --git a/packages/cli/src/commands/store/get.ts b/packages/cli/src/commands/store/get.ts index 834ac47..6c35ece 100644 --- a/packages/cli/src/commands/store/get.ts +++ b/packages/cli/src/commands/store/get.ts @@ -1,14 +1,12 @@ -import { log, spinner } from "@clack/prompts"; import { Command } from "commander"; import { z } from "zod"; import { createClient } from "../../utils/client"; import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log, spinner } from "../../utils/logger"; import { formatBytes, formatOutput } from "../../utils/output"; import { resolveStore } from "../../utils/store"; @@ -16,8 +14,6 @@ const GetStoreSchema = extendGlobalOptions({ nameOrId: z.string().min(1, { error: '"name-or-id" is required' }), }); -interface GetOptions extends GlobalOptions {} - export function createGetCommand(): Command { const command = addGlobalOptions( new Command("get") @@ -25,11 +21,11 @@ export function createGetCommand(): Command { .argument("", "Name or ID of the store") ); - command.action(async (nameOrId: string, options: GetOptions) => { + command.action(async (nameOrId: string) => { const getSpinner = spinner(); try { - const mergedOptions = mergeCommandOptions(command, options); + const mergedOptions = command.optsWithGlobals(); const parsedOptions = parseOptions(GetStoreSchema, { ...mergedOptions, diff --git a/packages/cli/src/commands/store/list.ts b/packages/cli/src/commands/store/list.ts index 9b5b836..5557bb8 100644 --- a/packages/cli/src/commands/store/list.ts +++ b/packages/cli/src/commands/store/list.ts @@ -1,4 +1,3 @@ -import { log, spinner } from "@clack/prompts"; import { Command } from "commander"; import { z } from "zod"; import { createClient } from "../../utils/client"; @@ -9,10 +8,9 @@ import { import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log, spinner } from "../../utils/logger"; import { formatBytes, formatCountWithSuffix, @@ -29,11 +27,6 @@ const ListStoreSchema = extendGlobalOptions({ .optional(), }); -interface ListOptions extends GlobalOptions { - filter?: string; - limit?: number; -} - export function createListCommand(): Command { const command = addGlobalOptions( new Command("list") @@ -42,11 +35,11 @@ export function createListCommand(): Command { .option("--limit ", "Maximum number of results", "100") ); - command.action(async (options: ListOptions) => { + command.action(async () => { const listSpinner = spinner(); try { - const mergedOptions = mergeCommandOptions(command, options); + const mergedOptions = command.optsWithGlobals(); const parsedOptions = parseOptions( ListStoreSchema, mergedOptions as Record @@ -87,7 +80,9 @@ export function createListCommand(): Command { created: new Date(store.created_at).toLocaleDateString(), })); - listSpinner.stop(`Found ${formatCountWithSuffix(stores.length, "store")}`); + listSpinner.stop( + `Found ${formatCountWithSuffix(stores.length, "store")}` + ); formatOutput(formattedData, parsedOptions.format); // Update completion cache with the fetched stores diff --git a/packages/cli/src/commands/store/qa.ts b/packages/cli/src/commands/store/qa.ts index 3e8582c..1660c13 100644 --- a/packages/cli/src/commands/store/qa.ts +++ b/packages/cli/src/commands/store/qa.ts @@ -1,4 +1,3 @@ -import { log, spinner } from "@clack/prompts"; import chalk from "chalk"; import { Command } from "commander"; import { z } from "zod"; @@ -7,10 +6,9 @@ import { loadConfig } from "../../utils/config"; import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log, spinner } from "../../utils/logger"; import { formatOutput } from "../../utils/output"; import { resolveStore } from "../../utils/store"; @@ -33,14 +31,6 @@ const QAStoreSchema = extendGlobalOptions({ returnMetadata: z.boolean().optional(), }); -interface QAOptions extends GlobalOptions { - topK?: number; - threshold?: number; - cite?: boolean; - multimodal?: boolean; - returnMetadata?: boolean; -} - export function createQACommand(): Command { const command = addGlobalOptions( new Command("qa") @@ -52,80 +42,78 @@ export function createQACommand(): Command { .option("--return-metadata", "Return source metadata") ); - command.action( - async (nameOrId: string, question: string, options: QAOptions) => { - const qaSpinner = spinner(); - - try { - const mergedOptions = mergeCommandOptions(command, options); - const parsedOptions = parseOptions(QAStoreSchema, { - ...mergedOptions, - nameOrId, - question, + command.action(async (nameOrId: string, question: string) => { + const qaSpinner = spinner(); + + try { + const mergedOptions = command.optsWithGlobals(); + const parsedOptions = parseOptions(QAStoreSchema, { + ...mergedOptions, + nameOrId, + question, + }); + + const client = createClient(parsedOptions); + qaSpinner.start("Processing question..."); + const store = await resolveStore(client, parsedOptions.nameOrId); + const config = loadConfig(); + + // Get default values from config + const topK = parsedOptions.topK || config.defaults?.search?.top_k || 10; + + const response = await client.stores.questionAnswering({ + query: parsedOptions.question, + store_identifiers: [store.id], + top_k: topK, + search_options: { + score_threshold: parsedOptions.threshold + ? parsedOptions.threshold + : undefined, + return_metadata: parsedOptions.returnMetadata + ? parsedOptions.returnMetadata + : undefined, + }, + }); + + qaSpinner.stop("Question processed"); + + // Display the answer + console.log(chalk.bold(chalk.blue("\nAnswer:"))); + console.log(response.answer); + + // Display sources if available + if (response.sources && response.sources.length > 0) { + console.log(chalk.bold(chalk.blue("\nSources:"))); + + const sources = response.sources.map((source) => { + const metadata = + parsedOptions.format === "table" + ? JSON.stringify(source.metadata, null, 2) + : source.metadata; + + const output: Record = { + filename: source.filename, + score: source.score.toFixed(2), + chunk_index: source.chunk_index, + }; + + if (parsedOptions.returnMetadata) { + output.metadata = metadata; + } + + return output; }); - const client = createClient(parsedOptions); - qaSpinner.start("Processing question..."); - const store = await resolveStore(client, parsedOptions.nameOrId); - const config = loadConfig(); - - // Get default values from config - const topK = parsedOptions.topK || config.defaults?.search?.top_k || 10; - - const response = await client.stores.questionAnswering({ - query: parsedOptions.question, - store_identifiers: [store.id], - top_k: topK, - search_options: { - score_threshold: parsedOptions.threshold - ? parsedOptions.threshold - : undefined, - return_metadata: parsedOptions.returnMetadata - ? parsedOptions.returnMetadata - : undefined, - }, - }); - - qaSpinner.stop("Question processed"); - - // Display the answer - console.log(chalk.bold(chalk.blue("\nAnswer:"))); - console.log(response.answer); - - // Display sources if available - if (response.sources && response.sources.length > 0) { - console.log(chalk.bold(chalk.blue("\nSources:"))); - - const sources = response.sources.map((source) => { - const metadata = - parsedOptions.format === "table" - ? JSON.stringify(source.metadata, null, 2) - : source.metadata; - - const output: Record = { - filename: source.filename, - score: source.score.toFixed(2), - chunk_index: source.chunk_index, - }; - - if (parsedOptions.returnMetadata) { - output.metadata = metadata; - } - - return output; - }); - - formatOutput(sources, parsedOptions.format); - } - } catch (error) { - qaSpinner.stop(); - log.error( - error instanceof Error ? error.message : "Failed to process question" - ); - process.exit(1); + formatOutput(sources, parsedOptions.format); } + } catch (error) { + qaSpinner.stop(); + log.error( + error instanceof Error ? error.message : "Failed to process question" + ); + process.exit(1); } - ); + }); return command; } diff --git a/packages/cli/src/commands/store/search.ts b/packages/cli/src/commands/store/search.ts index be01031..f921b86 100644 --- a/packages/cli/src/commands/store/search.ts +++ b/packages/cli/src/commands/store/search.ts @@ -1,4 +1,3 @@ -import { log, spinner } from "@clack/prompts"; import type Mixedbread from "@mixedbread/sdk"; import { Command } from "commander"; import { z } from "zod"; @@ -7,10 +6,9 @@ import { loadConfig } from "../../utils/config"; import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log, spinner } from "../../utils/logger"; import { formatCountWithSuffix, formatOutput } from "../../utils/output"; import { resolveStore } from "../../utils/store"; @@ -69,14 +67,6 @@ async function searchStoreChunks( }); } -interface SearchOptions extends GlobalOptions { - topK?: number; - threshold?: number; - returnMetadata?: boolean; - rerank?: boolean; - fileSearch?: boolean; -} - export function createSearchCommand(): Command { const command = addGlobalOptions( new Command("search") @@ -90,83 +80,83 @@ export function createSearchCommand(): Command { .option("--file-search", "Search files instead of chunks", false) ); - command.action( - async (nameOrId: string, query: string, options: SearchOptions) => { - const searchSpinner = spinner(); - - try { - const mergedOptions = mergeCommandOptions(command, options); - const parsedOptions = parseOptions(SearchStoreSchema, { - ...mergedOptions, - nameOrId, - query, - }); - - const client = createClient(parsedOptions); - searchSpinner.start("Searching store..."); - const store = await resolveStore(client, parsedOptions.nameOrId); - const config = loadConfig(); - - // Get default values from config - const topK = parsedOptions.topK || config.defaults?.search?.top_k || 10; - const rerank = - parsedOptions.rerank ?? config.defaults?.search?.rerank ?? false; - - const results = parsedOptions.fileSearch - ? await searchStoreFiles(client, { - ...parsedOptions, - storeIdentifier: store.id, - topK, - rerank, - }) - : await searchStoreChunks(client, { - ...parsedOptions, - storeIdentifier: store.id, - topK, - rerank, - }); - - if (!results.data || results.data.length === 0) { - searchSpinner.stop(); - log.info("No results found."); - return; - } + command.action(async (nameOrId: string, query: string) => { + const searchSpinner = spinner(); + + try { + const mergedOptions = command.optsWithGlobals(); + const parsedOptions = parseOptions(SearchStoreSchema, { + ...mergedOptions, + nameOrId, + query, + }); + + const client = createClient(parsedOptions); + searchSpinner.start("Searching store..."); + const store = await resolveStore(client, parsedOptions.nameOrId); + const config = loadConfig(); + + // Get default values from config + const topK = parsedOptions.topK || config.defaults?.search?.top_k || 10; + const rerank = + parsedOptions.rerank ?? config.defaults?.search?.rerank ?? false; + + const results = parsedOptions.fileSearch + ? await searchStoreFiles(client, { + ...parsedOptions, + storeIdentifier: store.id, + topK, + rerank, + }) + : await searchStoreChunks(client, { + ...parsedOptions, + storeIdentifier: store.id, + topK, + rerank, + }); + + if (!results.data || results.data.length === 0) { + searchSpinner.stop(); + log.info("No results found."); + return; + } - searchSpinner.stop(`Found ${formatCountWithSuffix(results.data.length, "result")}`); + searchSpinner.stop( + `Found ${formatCountWithSuffix(results.data.length, "result")}` + ); - const output = results.data.map((result) => { - const metadata = - parsedOptions.format === "table" - ? JSON.stringify(result.metadata, null, 2) - : result.metadata; + const output = results.data.map((result) => { + const metadata = + parsedOptions.format === "table" + ? JSON.stringify(result.metadata, null, 2) + : result.metadata; - const output: Record = { - filename: result.filename, - score: result.score.toFixed(2), - store_id: result.store_id, - }; + const output: Record = { + filename: result.filename, + score: result.score.toFixed(2), + store_id: result.store_id, + }; - if (!parsedOptions.fileSearch) { - output.chunk_index = result.chunk_index; - } + if (!parsedOptions.fileSearch) { + output.chunk_index = result.chunk_index; + } - if (parsedOptions.returnMetadata) { - output.metadata = metadata; - } + if (parsedOptions.returnMetadata) { + output.metadata = metadata; + } - return output; - }); + return output; + }); - formatOutput(output, parsedOptions.format); - } catch (error) { - searchSpinner.stop(); - log.error( - error instanceof Error ? error.message : "Failed to search store" - ); - process.exit(1); - } + formatOutput(output, parsedOptions.format); + } catch (error) { + searchSpinner.stop(); + log.error( + error instanceof Error ? error.message : "Failed to search store" + ); + process.exit(1); } - ); + }); return command; } diff --git a/packages/cli/src/commands/store/sync.ts b/packages/cli/src/commands/store/sync.ts index b2fccf7..1375c75 100644 --- a/packages/cli/src/commands/store/sync.ts +++ b/packages/cli/src/commands/store/sync.ts @@ -1,5 +1,4 @@ -import { confirm, isCancel, log, spinner } from "@clack/prompts"; -import type { FileCreateParams } from "@mixedbread/sdk/resources/stores"; +import { confirm, isCancel } from "@clack/prompts"; import chalk from "chalk"; import { Command } from "commander"; import { z } from "zod"; @@ -9,10 +8,9 @@ import { getGitInfo } from "../../utils/git"; import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log, spinner } from "../../utils/logger"; import { validateMetadata } from "../../utils/metadata"; import { formatBytes, formatCountWithSuffix } from "../../utils/output"; import { resolveStore } from "../../utils/store"; @@ -47,17 +45,6 @@ const SyncStoreSchema = extendGlobalOptions({ .default(100), }); -interface SyncOptions extends GlobalOptions { - strategy?: FileCreateParams.Config["parsing_strategy"]; - contextualization?: boolean; - fromGit?: string; - dryRun?: boolean; - yes?: boolean; - force?: boolean; - metadata?: string; - parallel?: number; -} - export function createSyncCommand(): Command { const command = addGlobalOptions( new Command("sync") @@ -86,145 +73,143 @@ export function createSyncCommand(): Command { .option("--parallel ", "Number of concurrent operations (1-200)") ); - command.action( - async (nameOrId: string, patterns: string[], options: SyncOptions) => { - try { - const mergedOptions = mergeCommandOptions(command, options); - const parsedOptions = parseOptions(SyncStoreSchema, { - ...mergedOptions, - nameOrId, - patterns, - }); + command.action(async (nameOrId: string, patterns: string[]) => { + let activeSpinner: ReturnType | null = null; + try { + const mergedOptions = command.optsWithGlobals(); + const parsedOptions = parseOptions(SyncStoreSchema, { + ...mergedOptions, + nameOrId, + patterns, + }); - const client = createClient(parsedOptions); + const client = createClient(parsedOptions); - console.log(chalk.bold.blue("šŸ”„ Starting Store Sync")); + console.log(chalk.bold.blue("šŸ”„ Starting Store Sync")); - if (parsedOptions.contextualization) { - warnContextualizationDeprecated("store sync"); - } + if (parsedOptions.contextualization) { + warnContextualizationDeprecated("store sync"); + } - // Step 0: Resolve store - const resolveSpinner = spinner(); - resolveSpinner.start(`Looking up store "${parsedOptions.nameOrId}"...`); - const store = await resolveStore(client, parsedOptions.nameOrId); - resolveSpinner.stop(`Found store: ${store.name}`); + // Step 0: Resolve store + activeSpinner = spinner(); + activeSpinner.start(`Looking up store "${parsedOptions.nameOrId}"...`); + const store = await resolveStore(client, parsedOptions.nameOrId); + activeSpinner.stop(`Found store: ${store.name}`); + activeSpinner = null; - // Parse metadata if provided - const additionalMetadata = validateMetadata(parsedOptions.metadata); + // Parse metadata if provided + const additionalMetadata = validateMetadata(parsedOptions.metadata); - // Get git info - const gitInfo = await getGitInfo(); + // Get git info + const gitInfo = await getGitInfo(); - const loadSpinner = spinner(); - loadSpinner.start("Loading existing files from store..."); + activeSpinner = spinner(); + activeSpinner.start("Loading existing files from store..."); - const syncedFiles = await getSyncedFiles(client, store.id); + const syncedFiles = await getSyncedFiles(client, store.id); - loadSpinner.stop( - `Found ${formatCountWithSuffix(syncedFiles.size, "existing file")} in store` - ); + activeSpinner.stop( + `Found ${formatCountWithSuffix(syncedFiles.size, "existing file")} in store` + ); + activeSpinner = null; - const fromGit = parsedOptions.fromGit; - - if (parsedOptions.force) { - log.success("Force upload enabled - all files will be re-uploaded"); - } else if (fromGit && gitInfo.isRepo) { - log.success( - `Git-based detection enabled (from commit ${fromGit.substring(0, 7)})` - ); - } else if (fromGit && !gitInfo.isRepo) { - log.error("--from-git specified but not in a git repository"); - process.exit(1); - } else { - log.success("Hash-based detection enabled (comparing file contents)"); - } + const fromGit = parsedOptions.fromGit; - const analyzeSpinner = spinner(); - analyzeSpinner.start("Scanning files and detecting changes..."); - const analysis = await analyzeChanges({ - patterns, - syncedFiles, - gitInfo, - fromGit, - forceUpload: parsedOptions.force, - }); + if (parsedOptions.force) { + log.success("Force upload enabled - all files will be re-uploaded"); + } else if (fromGit && gitInfo.isRepo) { + log.success( + `Git-based detection enabled (from commit ${fromGit.substring(0, 7)})` + ); + } else if (fromGit && !gitInfo.isRepo) { + log.error("--from-git specified but not in a git repository"); + process.exit(1); + } else { + log.success("Hash-based detection enabled (comparing file contents)"); + } - analyzeSpinner.stop("Change analysis complete"); + activeSpinner = spinner(); + activeSpinner.start("Scanning files and detecting changes..."); + const analysis = await analyzeChanges({ + patterns, + syncedFiles, + gitInfo, + fromGit, + forceUpload: parsedOptions.force, + }); + + activeSpinner.stop("Change analysis complete"); + activeSpinner = null; + + const totalChanges = + analysis.added.length + + analysis.modified.length + + analysis.deleted.length; + + if (totalChanges === 0) { + log.success("Store is already in sync - no changes needed!"); + return; + } - const totalChanges = - analysis.added.length + - analysis.modified.length + - analysis.deleted.length; + // Show summary + if (parsedOptions.force) { + console.log(chalk.bold("\n--force enabled")); + console.log( + `All ${formatCountWithSuffix(analysis.totalFiles, "file")} will be re-uploaded to the store.` + ); + console.log(`Upload size: ${formatBytes(analysis.totalSize)}\n`); + } else { + console.log(`${formatChangeSummary(analysis)}\n`); + } - if (totalChanges === 0) { - log.success("Store is already in sync - no changes needed!"); - return; - } + // Dry run mode - just show what would happen + if (parsedOptions.dryRun) { + console.log(chalk.yellow.bold("Dry Run Complete")); + console.log( + chalk.yellow( + "No changes were made - this was a preview of what would happen." + ) + ); + return; + } - // Show summary - if (parsedOptions.force) { - console.log(chalk.bold("\n--force enabled")); - console.log( - `All ${formatCountWithSuffix(analysis.totalFiles, "file")} will be re-uploaded to the store.` - ); - console.log(`Upload size: ${formatBytes(analysis.totalSize)}\n`); - } else { - console.log(`${formatChangeSummary(analysis)}\n`); - } + // Confirm changes unless yes flag is set + if (!parsedOptions.yes) { + const proceed = await confirm({ + message: "Apply these changes to the store?", + }); - // Dry run mode - just show what would happen - if (parsedOptions.dryRun) { - console.log(chalk.yellow.bold("Dry Run Complete")); - console.log( - chalk.yellow( - "No changes were made - this was a preview of what would happen." - ) - ); + if (isCancel(proceed) || !proceed) { + log.warn("Sync cancelled by user"); return; } + } else if (parsedOptions.yes) { + log.success("Auto-proceeding with --yes flag"); + } - // Confirm changes unless yes flag is set - if (!parsedOptions.yes) { - const proceed = await confirm({ - message: "Apply these changes to the store?", - }); - - if (isCancel(proceed) || !proceed) { - log.warn("Sync cancelled by user"); - return; - } - } else if (parsedOptions.yes) { - log.success("Auto-proceeding with --yes flag"); - } - - // Execute changes - const syncResults = await executeSyncChanges( - client, - store.id, - analysis, - { - strategy: parsedOptions.strategy, - metadata: additionalMetadata, - gitInfo: gitInfo.isRepo ? gitInfo : undefined, - parallel: parsedOptions.parallel, - } - ); - - // Display summary - displaySyncResultsSummary(syncResults, gitInfo, fromGit, { - strategy: parsedOptions.strategy, - }); - } catch (error) { - if (error instanceof Error) { - log.error(error.message); - } else { - log.error("Failed to sync store"); - } - process.exit(1); + // Execute changes + const syncResults = await executeSyncChanges(client, store.id, analysis, { + strategy: parsedOptions.strategy, + metadata: additionalMetadata, + gitInfo: gitInfo.isRepo ? gitInfo : undefined, + parallel: parsedOptions.parallel, + }); + + // Display summary + displaySyncResultsSummary(syncResults, gitInfo, fromGit, { + strategy: parsedOptions.strategy, + }); + } catch (error) { + activeSpinner?.stop(); + if (error instanceof Error) { + log.error(error.message); + } else { + log.error("Failed to sync store"); } + process.exit(1); } - ); + }); return command; } diff --git a/packages/cli/src/commands/store/update.ts b/packages/cli/src/commands/store/update.ts index 6d67ca3..f81724c 100644 --- a/packages/cli/src/commands/store/update.ts +++ b/packages/cli/src/commands/store/update.ts @@ -1,4 +1,3 @@ -import { log, spinner } from "@clack/prompts"; import type { StoreUpdateParams } from "@mixedbread/sdk/resources/index"; import { Command } from "commander"; import { z } from "zod"; @@ -10,10 +9,9 @@ import { import { addGlobalOptions, extendGlobalOptions, - type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log, spinner } from "../../utils/logger"; import { validateMetadata } from "../../utils/metadata"; import { formatOutput } from "../../utils/output"; import { parsePublicFlag, resolveStore } from "../../utils/store"; @@ -31,14 +29,6 @@ const UpdateStoreSchema = extendGlobalOptions({ metadata: z.string().optional(), }); -interface UpdateOptions extends GlobalOptions { - name?: string; - description?: string; - public?: boolean | string; - expiresAfter?: number; - metadata?: string; -} - export function createUpdateCommand(): Command { const command = addGlobalOptions( new Command("update") @@ -57,11 +47,11 @@ export function createUpdateCommand(): Command { ) ); - command.action(async (nameOrId: string, options: UpdateOptions) => { + command.action(async (nameOrId: string) => { const updateSpinner = spinner(); try { - const mergedOptions = mergeCommandOptions(command, options); + const mergedOptions = command.optsWithGlobals(); const parsedOptions = parseOptions(UpdateStoreSchema, { ...mergedOptions, diff --git a/packages/cli/src/commands/store/upload.ts b/packages/cli/src/commands/store/upload.ts index c139dc5..b9d0daf 100644 --- a/packages/cli/src/commands/store/upload.ts +++ b/packages/cli/src/commands/store/upload.ts @@ -1,5 +1,4 @@ -import { statSync } from "node:fs"; -import { log, spinner } from "@clack/prompts"; +import { stat } from "node:fs/promises"; import type { FileCreateParams } from "@mixedbread/sdk/resources/stores"; import chalk from "chalk"; import { Command } from "commander"; @@ -12,13 +11,13 @@ import { addGlobalOptions, extendGlobalOptions, type GlobalOptions, - mergeCommandOptions, parseOptions, } from "../../utils/global-options"; +import { log, spinner } from "../../utils/logger"; import { uploadFromManifest } from "../../utils/manifest"; import { validateMetadata } from "../../utils/metadata"; import { formatBytes, formatCountWithSuffix } from "../../utils/output"; -import { getStoreFiles, resolveStore } from "../../utils/store"; +import { checkExistingFiles, resolveStore } from "../../utils/store"; import { type FileToUpload, uploadFilesInBatch } from "../../utils/upload"; const UploadStoreSchema = extendGlobalOptions({ @@ -45,7 +44,7 @@ const UploadStoreSchema = extendGlobalOptions({ }); export interface UploadOptions extends GlobalOptions { - strategy?: FileCreateParams.Experimental["parsing_strategy"]; + strategy?: FileCreateParams.Config["parsing_strategy"]; contextualization?: boolean; metadata?: string; dryRun?: boolean; @@ -79,161 +78,151 @@ export function createUploadCommand(): Command { .option("--manifest ", "Upload using manifest file") ); - command.action( - async (nameOrId: string, patterns: string[], options: UploadOptions) => { - try { - const mergedOptions = mergeCommandOptions(command, options); + command.action(async (nameOrId: string, patterns: string[]) => { + let activeSpinner: ReturnType | null = null; + try { + const mergedOptions = command.optsWithGlobals(); - const parsedOptions = parseOptions(UploadStoreSchema, { - ...mergedOptions, - nameOrId, - patterns, - }); - - if (parsedOptions.contextualization) { - warnContextualizationDeprecated("store upload"); - } + const parsedOptions = parseOptions(UploadStoreSchema, { + ...mergedOptions, + nameOrId, + patterns, + }); - const client = createClient(parsedOptions); - const initializeSpinner = spinner(); - initializeSpinner.start("Initializing upload..."); - const store = await resolveStore(client, parsedOptions.nameOrId); - const config = loadConfig(); + if (parsedOptions.contextualization) { + warnContextualizationDeprecated("store upload"); + } - initializeSpinner.stop("Upload initialized"); + const client = createClient(parsedOptions); + activeSpinner = spinner(); + activeSpinner.start("Initializing upload..."); + const store = await resolveStore(client, parsedOptions.nameOrId); + const config = loadConfig(); + + activeSpinner.stop("Upload initialized"); + activeSpinner = null; + + // Handle manifest file upload + if (parsedOptions.manifest) { + return await uploadFromManifest( + client, + store.id, + parsedOptions.manifest, + parsedOptions + ); + } - // Handle manifest file upload - if (parsedOptions.manifest) { - return await uploadFromManifest( - client, - store.id, - parsedOptions.manifest, - parsedOptions - ); - } + if (!parsedOptions.patterns || parsedOptions.patterns.length === 0) { + log.error( + "No file patterns provided. Use --manifest for manifest-based uploads." + ); + process.exit(1); + } - if (!parsedOptions.patterns || parsedOptions.patterns.length === 0) { - log.error( - "No file patterns provided. Use --manifest for manifest-based uploads." - ); - process.exit(1); - } + // Get configuration values with precedence: command-line > config defaults > built-in defaults + const strategy = + parsedOptions.strategy ?? config.defaults?.upload?.strategy ?? "fast"; + const parallel = + parsedOptions.parallel ?? config.defaults?.upload?.parallel ?? 100; - // Get configuration values with precedence: command-line > config defaults > built-in defaults - const strategy = - parsedOptions.strategy ?? config.defaults?.upload?.strategy ?? "fast"; - const parallel = - parsedOptions.parallel ?? config.defaults?.upload?.parallel ?? 100; - - const metadata = validateMetadata(parsedOptions.metadata); - - // Collect all files matching patterns - const files: string[] = []; - for (const pattern of parsedOptions.patterns) { - const matches = await glob(pattern, { - nodir: true, - absolute: false, - }); - files.push(...matches); - } - // Remove duplicates - const uniqueFiles = [...new Set(files)]; + const metadata = validateMetadata(parsedOptions.metadata); - if (parsedOptions.patterns) { - if (uniqueFiles.length === 0) { - log.warn("No files found matching the patterns."); - return; - } + // Collect all files matching patterns + const files: string[] = []; + for (const pattern of parsedOptions.patterns) { + const matches = await glob(pattern, { + nodir: true, + absolute: false, + }); + files.push(...matches); + } + // Remove duplicates + const uniqueFiles = [...new Set(files)]; - const totalSize = uniqueFiles.reduce((sum, file) => { - try { - return sum + statSync(file).size; - } catch { - return sum; - } - }, 0); + if (uniqueFiles.length === 0) { + log.warn("No files found matching the patterns."); + return; + } - console.log( - `Found ${formatCountWithSuffix(uniqueFiles.length, "file")} matching the ${ - patterns.length > 1 ? "patterns" : "pattern" - } (${formatBytes(totalSize)})` - ); + let totalSize = 0; + for (const file of uniqueFiles) { + try { + totalSize += (await stat(file)).size; + } catch { + // File may not exist } + } - if (parsedOptions.dryRun) { - console.log(chalk.blue("Dry run - files that would be uploaded:")); - uniqueFiles.forEach((file) => { - try { - const stats = statSync(file); - console.log(` \n${file} (${formatBytes(stats.size)})`); - console.log(` Strategy: ${strategy}`); - - if (metadata && Object.keys(metadata).length > 0) { - console.log(` Metadata: ${JSON.stringify(metadata)}`); - } - } catch (_error) { - console.log(` ${file} (${chalk.red("āœ— File not found")})`); - } - }); - return; - } + console.log( + `Found ${formatCountWithSuffix(uniqueFiles.length, "file")} matching the ${ + patterns.length > 1 ? "patterns" : "pattern" + } (${formatBytes(totalSize)})` + ); - // Handle --unique flag: check for existing files - let existingFiles: Map = new Map(); - if (parsedOptions.unique) { - const uniqueSpinner = spinner(); - uniqueSpinner.start("Checking for existing files..."); + if (parsedOptions.dryRun) { + console.log(chalk.blue("Dry run - files that would be uploaded:")); + for (const file of uniqueFiles) { try { - const storeFiles = await getStoreFiles(client, store.id); - existingFiles = new Map( - storeFiles - .filter((f) => { - const filePath = - typeof f.metadata === "object" && - f.metadata && - "file_path" in f.metadata && - (f.metadata.file_path as string); - - return filePath && files.includes(filePath); - }) - .map((f) => [ - (f.metadata as { file_path: string }).file_path, - f.id, - ]) - ); - uniqueSpinner.stop( - `Found ${formatCountWithSuffix(existingFiles.size, "existing file")}` - ); - } catch (error) { - uniqueSpinner.stop(); - log.error("Failed to check existing files"); - throw error; + const stats = await stat(file); + console.log(` \n${file} (${formatBytes(stats.size)})`); + console.log(` Strategy: ${strategy}`); + + if (metadata && Object.keys(metadata).length > 0) { + console.log(` Metadata: ${JSON.stringify(metadata)}`); + } + } catch (_error) { + console.log(` ${file} (${chalk.red("āœ— File not found")})`); } } + return; + } - // Transform files to shared format - const filesToUpload: FileToUpload[] = uniqueFiles.map((filePath) => ({ - path: filePath, - strategy, - metadata, - })); - - // Upload files with progress tracking - await uploadFilesInBatch(client, store.id, filesToUpload, { - unique: parsedOptions.unique || false, - existingFiles, - parallel, - }); - } catch (error) { - if (error instanceof Error) { - log.error(error.message); - } else { - log.error("Failed to upload files"); + // Handle --unique flag: check for existing files + let existingFiles: Map = new Map(); + if (parsedOptions.unique) { + activeSpinner = spinner(); + activeSpinner.start("Checking for existing files..."); + try { + existingFiles = await checkExistingFiles( + client, + store.id, + uniqueFiles + ); + activeSpinner.stop( + `Found ${formatCountWithSuffix(existingFiles.size, "existing file")}` + ); + activeSpinner = null; + } catch (error) { + activeSpinner.stop(); + activeSpinner = null; + log.error("Failed to check existing files"); + throw error; } - process.exit(1); } + + // Transform files to shared format + const filesToUpload: FileToUpload[] = uniqueFiles.map((filePath) => ({ + path: filePath, + strategy, + metadata, + })); + + // Upload files with progress tracking + await uploadFilesInBatch(client, store.id, filesToUpload, { + unique: parsedOptions.unique || false, + existingFiles, + parallel, + }); + } catch (error) { + activeSpinner?.stop(); + if (error instanceof Error) { + log.error(error.message); + } else { + log.error("Failed to upload files"); + } + process.exit(1); } - ); + }); return command; } diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 36706b8..e0c2203 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { homedir, platform } from "node:os"; import { join } from "node:path"; import chalk from "chalk"; @@ -99,7 +107,7 @@ const DEFAULT_CONFIG: CLIConfig = { top_k: 10, rerank: false, }, - api_key: null, + api_key: undefined, }, aliases: {}, }; @@ -142,7 +150,19 @@ export function saveConfig(config: CLIConfig): void { mkdirSync(CONFIG_DIR, { recursive: true }); } - writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + // Atomic write: write to temp file, then rename + const tempFile = `${CONFIG_FILE}.${randomBytes(6).toString("hex")}.tmp`; + try { + writeFileSync(tempFile, JSON.stringify(config, null, 2)); + renameSync(tempFile, CONFIG_FILE); + } catch (error) { + try { + unlinkSync(tempFile); + } catch { + // Ignore cleanup errors + } + throw error; + } } function formatAvailableKeys(config: CLIConfig): string { diff --git a/packages/cli/src/utils/global-options.ts b/packages/cli/src/utils/global-options.ts index 46a671b..25ec3fc 100644 --- a/packages/cli/src/utils/global-options.ts +++ b/packages/cli/src/utils/global-options.ts @@ -65,33 +65,6 @@ export function addGlobalOptions(command: Command): Command { .option("--format ", "Output format (table|json|csv)"); } -export function mergeCommandOptions(command: Command, options: T): T { - // Traverse up the command hierarchy to collect all options - const allOptions: T[] = []; - let currentCommand: Command | null = command; - - // Collect options from all parent commands up to the root - while (currentCommand) { - if (currentCommand.parent) { - allOptions.unshift(currentCommand.parent.opts()); - } - currentCommand = currentCommand.parent; - } - - // Add the current command's options last (highest priority) - allOptions.push(options); - - // Merge all options, with later options taking priority - const merged = Object.assign({}, ...allOptions); - - if (process.env.MXBAI_DEBUG === "true") { - console.log("\nCommand hierarchy options:", allOptions); - console.log("Merged options:", merged); - } - - return merged; -} - export function parseOptions( schema: z.ZodSchema, options: Record diff --git a/packages/cli/src/utils/logger.ts b/packages/cli/src/utils/logger.ts new file mode 100644 index 0000000..a776c87 --- /dev/null +++ b/packages/cli/src/utils/logger.ts @@ -0,0 +1,21 @@ +import { + log as clackLog, + spinner as clackSpinner, + type SpinnerOptions, +} from "@clack/prompts"; + +const stderrOpts = { output: process.stderr } as const; + +export const log = { + message: (message?: string, opts = {}) => + clackLog.message(message, { ...stderrOpts, ...opts }), + info: (message: string) => clackLog.info(message, stderrOpts), + success: (message: string) => clackLog.success(message, stderrOpts), + warn: (message: string) => clackLog.warn(message, stderrOpts), + error: (message: string) => clackLog.error(message, stderrOpts), + step: (message: string) => clackLog.step(message, stderrOpts), +}; + +export function spinner(opts: Omit = {}) { + return clackSpinner({ ...opts, ...stderrOpts }); +} diff --git a/packages/cli/src/utils/manifest.ts b/packages/cli/src/utils/manifest.ts index 7c1b474..c29c931 100644 --- a/packages/cli/src/utils/manifest.ts +++ b/packages/cli/src/utils/manifest.ts @@ -1,5 +1,5 @@ -import { readFileSync, statSync } from "node:fs"; -import { log, spinner } from "@clack/prompts"; +import { readFileSync } from "node:fs"; +import { stat } from "node:fs/promises"; import type { Mixedbread } from "@mixedbread/sdk"; import chalk from "chalk"; import { glob } from "glob"; @@ -8,9 +8,10 @@ import { z } from "zod"; import type { UploadOptions } from "../commands/store/upload"; import { loadConfig } from "./config"; import { warnContextualizationDeprecated } from "./deprecation"; +import { log, spinner } from "./logger"; import { validateMetadata } from "./metadata"; import { formatBytes, formatCountWithSuffix } from "./output"; -import { getStoreFiles } from "./store"; +import { checkExistingFiles } from "./store"; import { type FileToUpload, uploadFilesInBatch } from "./upload"; // Manifest file schema @@ -139,13 +140,14 @@ export async function uploadFromManifest( } const uniqueFiles = Array.from(uniqueFilesMap.values()); - const totalSize = uniqueFiles.reduce((sum, file) => { + let totalSize = 0; + for (const file of uniqueFiles) { try { - return sum + statSync(file.path).size; + totalSize += (await stat(file.path)).size; } catch { - return sum; + // File may not exist } - }, 0); + } console.log( `Found ${formatCountWithSuffix(uniqueFiles.length, "file")} matching the patterns (${formatBytes(totalSize)})` @@ -153,9 +155,9 @@ export async function uploadFromManifest( if (options.dryRun) { console.log(chalk.blue("\nDry run - files that would be uploaded:")); - uniqueFiles.forEach((file) => { + for (const file of uniqueFiles) { try { - const stats = statSync(file.path); + const stats = await stat(file.path); console.log(` \n${file.path} (${formatBytes(stats.size)})`); console.log(` Strategy: ${file.strategy}`); @@ -165,7 +167,7 @@ export async function uploadFromManifest( } catch (_error) { console.log(` ${file.path} (${chalk.red("āœ— File not found")})`); } - }); + } return; } @@ -175,21 +177,11 @@ export async function uploadFromManifest( const checkSpinner = spinner(); checkSpinner.start("Checking for existing files..."); try { - const storeFiles = await getStoreFiles(client, storeIdentifier); - existingFiles = new Map( - storeFiles - .filter((f) => - uniqueFiles.some((file) => { - const filePath = - typeof f.metadata === "object" && - f.metadata && - "file_path" in f.metadata && - f.metadata.file_path; - - return filePath && filePath === file.path; - }) - ) - .map((f) => [(f.metadata as { file_path: string }).file_path, f.id]) + const localPaths = uniqueFiles.map((file) => file.path); + existingFiles = await checkExistingFiles( + client, + storeIdentifier, + localPaths ); checkSpinner.stop( `Found ${formatCountWithSuffix(existingFiles.size, "existing file")}` @@ -206,6 +198,7 @@ export async function uploadFromManifest( unique: options.unique || false, existingFiles, parallel: options.parallel ?? config.defaults.upload.parallel ?? 100, + showStrategyPerFile: true, }); } catch (error) { if (error instanceof z.ZodError) { diff --git a/packages/cli/src/utils/output.ts b/packages/cli/src/utils/output.ts index 0a05971..22623fb 100644 --- a/packages/cli/src/utils/output.ts +++ b/packages/cli/src/utils/output.ts @@ -96,13 +96,8 @@ function escapeCsv(value: string): string { } export function formatBytes(bytes: number | undefined): string { - if ( - bytes === 0 || - bytes === undefined || - bytes === null || - Number.isNaN(bytes) - ) - return "0 B"; + if (bytes === undefined || bytes === null || Number.isNaN(bytes)) return "-"; + if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k)); diff --git a/packages/cli/src/utils/store.ts b/packages/cli/src/utils/store.ts index c39e982..dea1d8a 100644 --- a/packages/cli/src/utils/store.ts +++ b/packages/cli/src/utils/store.ts @@ -94,6 +94,27 @@ export function buildStoreConfig( return { contextualization: true }; } +export async function checkExistingFiles( + client: Mixedbread, + storeIdentifier: string, + localPaths: string[] +): Promise> { + const pathSet = new Set(localPaths); + const storeFiles = await getStoreFiles(client, storeIdentifier); + return new Map( + storeFiles + .filter((f) => { + const filePath = + typeof f.metadata === "object" && + f.metadata && + "file_path" in f.metadata && + (f.metadata.file_path as string); + return filePath && pathSet.has(filePath); + }) + .map((f) => [(f.metadata as { file_path: string }).file_path, f.id]) + ); +} + export async function getStoreFiles( client: Mixedbread, storeIdentifier: string diff --git a/packages/cli/src/utils/sync.ts b/packages/cli/src/utils/sync.ts index 1aedefb..a25e2c8 100644 --- a/packages/cli/src/utils/sync.ts +++ b/packages/cli/src/utils/sync.ts @@ -1,7 +1,5 @@ -import { statSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { log } from "@clack/prompts"; import type Mixedbread from "@mixedbread/sdk"; import type { FileCreateParams } from "@mixedbread/sdk/resources/stores"; import chalk from "chalk"; @@ -9,6 +7,7 @@ import { glob } from "glob"; import pLimit from "p-limit"; import { getChangedFiles, normalizeGitPatterns } from "./git"; import { calculateFileHash, hashesMatch } from "./hash"; +import { log } from "./logger"; import { formatBytes, formatCountWithSuffix } from "./output"; import { buildFileSyncMetadata, type SyncedFileByPath } from "./sync-state"; import { uploadFile } from "./upload"; @@ -355,7 +354,7 @@ export async function executeSyncChanges( }; // Check if file is empty - const stats = statSync(file.path); + const stats = await fs.stat(file.path); if (stats.size === 0) { completed++; log.warn( diff --git a/packages/cli/src/utils/update-checker.ts b/packages/cli/src/utils/update-checker.ts index d69d409..50152d5 100644 --- a/packages/cli/src/utils/update-checker.ts +++ b/packages/cli/src/utils/update-checker.ts @@ -1,14 +1,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import chalk from "chalk"; +import { getConfigDir } from "./config"; interface UpdateCheckCache { lastCheck: number; latestVersion: string; } -const CACHE_DIR = join(homedir(), ".config", "mxbai"); +const CACHE_DIR = getConfigDir(); const CACHE_FILE = join(CACHE_DIR, "update-check.json"); const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours const REGISTRY_URL = "https://registry.npmjs.org/@mixedbread/cli/latest"; @@ -104,13 +104,15 @@ function stripAnsi(str: string): string { } /** - * Check for updates and display notification if available - * This runs asynchronously and doesn't block command execution + * Check for updates and return a notification banner if available. + * Returns the banner string if an update is available, or null otherwise. */ -export async function checkForUpdates(currentVersion: string): Promise { +export async function checkForUpdates( + currentVersion: string +): Promise { // Skip in CI or non-TTY environments if (process.env.CI || !process.stdout.isTTY) { - return; + return null; } try { @@ -127,10 +129,11 @@ export async function checkForUpdates(currentVersion: string): Promise { } if (isVersionLessThan(currentVersion, latestVersion)) { - console.log(formatUpdateBanner(currentVersion, latestVersion)); - console.log(); // Add spacing after banner + return formatUpdateBanner(currentVersion, latestVersion); } } catch { // Silently fail - update checks should never break the CLI } + + return null; } diff --git a/packages/cli/src/utils/upload.ts b/packages/cli/src/utils/upload.ts index b3c8d5e..4c0270a 100644 --- a/packages/cli/src/utils/upload.ts +++ b/packages/cli/src/utils/upload.ts @@ -1,10 +1,11 @@ -import { readFileSync, statSync } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; import { basename, relative } from "node:path"; -import { log } from "@clack/prompts"; import type Mixedbread from "@mixedbread/sdk"; import type { FileCreateParams } from "@mixedbread/sdk/resources/stores"; import chalk from "chalk"; import { lookup } from "mime-types"; +import pLimit from "p-limit"; +import { log } from "./logger"; import { formatBytes, formatCountWithSuffix } from "./output"; export const UPLOAD_TIMEOUT = 1000 * 60 * 10; // 10 minutes @@ -72,7 +73,7 @@ export async function uploadFile( const { metadata = {}, strategy, externalId } = options; // Read file content - const fileContent = readFileSync(filePath); + const fileContent = await readFile(filePath); const fileName = basename(filePath); const mimeType = lookup(filePath) || "application/octet-stream"; const file = fixMimeTypes( @@ -104,14 +105,15 @@ export async function uploadFilesInBatch( unique: boolean; existingFiles: Map; parallel: number; + showStrategyPerFile?: boolean; } ): Promise { - const { unique, existingFiles, parallel } = options; - - // Detect if this is a manifest upload - const isManifestUpload = files.some( - (file) => file.metadata?.manifest_entry === true - ); + const { + unique, + existingFiles, + parallel, + showStrategyPerFile = false, + } = options; console.log( `\nUploading ${formatCountWithSuffix(files.length, "file")} to store...` @@ -125,89 +127,83 @@ export async function uploadFilesInBatch( successfulSize: 0, }; - const totalBatches = Math.ceil(files.length / parallel); - - console.log( - chalk.gray( - `Processing ${totalBatches} batch${totalBatches > 1 ? "es" : ""} (${formatCountWithSuffix(parallel, "file")} per batch)...` - ) - ); - - // Process files in batches - for (let i = 0; i < files.length; i += parallel) { - const batch = files.slice(i, i + parallel); - const promises = batch.map(async (file) => { - const relativePath = relative(process.cwd(), file.path); - - try { - // Delete existing file if using --unique - if (unique && existingFiles.has(relativePath)) { - const existingFileId = existingFiles.get(relativePath); - await client.stores.files.delete(existingFileId, { - store_identifier: storeIdentifier, - }); - } - - const fileMetadata = { - file_path: relativePath, - uploaded_at: new Date().toISOString(), - ...file.metadata, - }; - - // Check if file is empty - const stats = statSync(file.path); - if (stats.size === 0) { - log.warn(`${relativePath} - Empty file skipped`); - results.skipped++; - return; - } - - const fileContent = readFileSync(file.path); - const fileName = basename(file.path); - const mimeType = lookup(file.path) || "application/octet-stream"; - const fileToUpload = fixMimeTypes( - new File([fileContent], fileName, { - type: mimeType, - }) - ); - - await client.stores.files.upload( - storeIdentifier, - fileToUpload, - { - metadata: fileMetadata, - config: { - parsing_strategy: file.strategy, + console.log(chalk.gray(`Processing with concurrency ${parallel}...`)); + + // Process files with sliding-window concurrency + const limit = pLimit(parallel); + await Promise.allSettled( + files.map((file) => + limit(async () => { + const relativePath = relative(process.cwd(), file.path); + + try { + // Delete existing file if using --unique + if (unique && existingFiles.has(relativePath)) { + const existingFileId = existingFiles.get(relativePath); + await client.stores.files.delete(existingFileId, { + store_identifier: storeIdentifier, + }); + } + + const fileMetadata = { + file_path: relativePath, + uploaded_at: new Date().toISOString(), + ...file.metadata, + }; + + // Check if file is empty + const stats = await stat(file.path); + if (stats.size === 0) { + log.warn(`${relativePath} - Empty file skipped`); + results.skipped++; + return; + } + + const fileContent = await readFile(file.path); + const fileName = basename(file.path); + const mimeType = lookup(file.path) || "application/octet-stream"; + const fileToUpload = fixMimeTypes( + new File([fileContent], fileName, { + type: mimeType, + }) + ); + + await client.stores.files.upload( + storeIdentifier, + fileToUpload, + { + metadata: fileMetadata, + config: { + parsing_strategy: file.strategy, + }, }, - }, - { timeout: UPLOAD_TIMEOUT } - ); - - if (unique && existingFiles.has(relativePath)) { - results.updated++; - } else { - results.uploaded++; - } + { timeout: UPLOAD_TIMEOUT } + ); - results.successfulSize += stats.size; + if (unique && existingFiles.has(relativePath)) { + results.updated++; + } else { + results.uploaded++; + } - let successMessage = `${relativePath} (${formatBytes(stats.size)})`; + results.successfulSize += stats.size; - if (isManifestUpload) { - successMessage += ` [${file.strategy}]`; - } + let successMessage = `${relativePath} (${formatBytes(stats.size)})`; - log.success(successMessage); - } catch (error) { - results.failed++; - const errorMsg = - error instanceof Error ? error.message : "Unknown error"; - log.error(`${relativePath} - ${errorMsg}`); - } - }); + if (showStrategyPerFile) { + successMessage += ` [${file.strategy}]`; + } - await Promise.all(promises); - } + log.success(successMessage); + } catch (error) { + results.failed++; + const errorMsg = + error instanceof Error ? error.message : "Unknown error"; + log.error(`${relativePath} - ${errorMsg}`); + } + }) + ) + ); // Summary console.log(`\n${chalk.bold("Upload Summary:")}`); @@ -235,7 +231,7 @@ export async function uploadFilesInBatch( ); } - if (!isManifestUpload && files.length > 0) { + if (!showStrategyPerFile && files.length > 0) { const firstFile = files[0]; console.log(chalk.gray(`Strategy: ${firstFile.strategy}`)); } diff --git a/packages/cli/tests/commands/store/list.test.ts b/packages/cli/tests/commands/store/list.test.ts index e86b2f2..0666c84 100644 --- a/packages/cli/tests/commands/store/list.test.ts +++ b/packages/cli/tests/commands/store/list.test.ts @@ -493,7 +493,7 @@ describe("Store List Command", () => { name: "store1", status: "active", files: undefined, - usage: "0 B", + usage: "-", created: "1/1/2024", }); }); diff --git a/packages/cli/tests/commands/store/upload.test.ts b/packages/cli/tests/commands/store/upload.test.ts index 9e38b16..52464c1 100644 --- a/packages/cli/tests/commands/store/upload.test.ts +++ b/packages/cli/tests/commands/store/upload.test.ts @@ -37,9 +37,10 @@ const mockCreateClient = clientUtils.createClient as jest.MockedFunction< const mockResolveStore = storeUtils.resolveStore as jest.MockedFunction< typeof storeUtils.resolveStore >; -const mockGetStoreFiles = storeUtils.getStoreFiles as jest.MockedFunction< - typeof storeUtils.getStoreFiles ->; +const mockCheckExistingFiles = + storeUtils.checkExistingFiles as jest.MockedFunction< + typeof storeUtils.checkExistingFiles + >; const mockLoadConfig = configUtils.loadConfig as jest.MockedFunction< typeof configUtils.loadConfig >; @@ -352,14 +353,9 @@ describe("Store Upload Command", () => { "test.md", ]); - mockGetStoreFiles.mockResolvedValue([ - { - id: "existing_file_id", - store_id: "550e8400-e29b-41d4-a716-446655440130", - created_at: "2021-02-18T12:00:00Z", - metadata: { file_path: "test.md" }, - }, - ]); + mockCheckExistingFiles.mockResolvedValue( + new Map([["test.md", "existing_file_id"]]) + ); mockClient.stores.files.delete.mockResolvedValue({}); @@ -371,9 +367,10 @@ describe("Store Upload Command", () => { "--unique", ]); - expect(mockGetStoreFiles).toHaveBeenCalledWith( + expect(mockCheckExistingFiles).toHaveBeenCalledWith( expect.any(Object), - "550e8400-e29b-41d4-a716-446655440130" + "550e8400-e29b-41d4-a716-446655440130", + ["test.md"] ); expect(mockUploadFilesInBatch).toHaveBeenCalledWith( expect.any(Object), @@ -394,7 +391,7 @@ describe("Store Upload Command", () => { (glob as unknown as jest.MockedFunction).mockResolvedValue([ "test.md", ]); - mockGetStoreFiles.mockRejectedValue(new Error("List failed")); + mockCheckExistingFiles.mockRejectedValue(new Error("List failed")); await command.parseAsync([ "node", @@ -604,14 +601,9 @@ describe("Store Upload Command", () => { "empty.md", ]); - mockGetStoreFiles.mockResolvedValue([ - { - id: "existing_file_id", - store_id: "550e8400-e29b-41d4-a716-446655440130", - created_at: "2021-02-18T12:00:00Z", - metadata: { file_path: "existing.md" }, - }, - ]); + mockCheckExistingFiles.mockResolvedValue( + new Map([["existing.md", "existing_file_id"]]) + ); // Mock uploadFilesInBatch to return updated and skipped mockUploadFilesInBatch.mockResolvedValue({ diff --git a/packages/cli/tests/utils/global-options.test.ts b/packages/cli/tests/utils/global-options.test.ts index b8dac9b..a5766de 100644 --- a/packages/cli/tests/utils/global-options.test.ts +++ b/packages/cli/tests/utils/global-options.test.ts @@ -1,10 +1,9 @@ -import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { describe, expect, it } from "@jest/globals"; import { Command } from "commander"; import { z } from "zod"; import { BaseGlobalOptionsSchema, GlobalOptionsSchema, - mergeCommandOptions, parseOptions, setupGlobalOptions, } from "../../src/utils/global-options"; @@ -72,74 +71,6 @@ describe("Global Options", () => { }); }); - describe("mergeCommandOptions", () => { - let parentCommand: Command; - let childCommand: Command; - - beforeEach(() => { - parentCommand = new Command(); - parentCommand.opts = jest - .fn<() => Record>() - .mockReturnValue({ apiKey: "parent_key", format: "json" }) as any; - - childCommand = new Command(); - childCommand.parent = parentCommand; - }); - - it("should merge parent and child options", () => { - const options = { debug: true }; - const merged = mergeCommandOptions(childCommand, options); - - expect(merged).toEqual({ - apiKey: "parent_key", - format: "json", - debug: true, - }); - }); - - it("should prioritize child options over parent", () => { - const options = { apiKey: "child_key", format: "csv" }; - const merged = mergeCommandOptions(childCommand, options); - - expect(merged).toEqual({ - apiKey: "child_key", - format: "csv", - }); - }); - - it("should handle commands without parent", () => { - const command = new Command(); - const options = { apiKey: "test_key" }; - const merged = mergeCommandOptions(command, options); - - expect(merged).toEqual({ apiKey: "test_key" }); - }); - - it("should log options in debug mode", () => { - const originalDebug = process.env.MXBAI_DEBUG; - process.env.MXBAI_DEBUG = "true"; - - const options = { debug: true }; - mergeCommandOptions(childCommand, options); - - expect(console.log).toHaveBeenCalledWith( - "\nCommand hierarchy options:", - expect.any(Array) - ); - expect(console.log).toHaveBeenCalledWith( - "Merged options:", - expect.any(Object) - ); - - // Cleanup - if (originalDebug) { - process.env.MXBAI_DEBUG = originalDebug; - } else { - delete process.env.MXBAI_DEBUG; - } - }); - }); - describe("parseOptions", () => { it("should parse valid options", () => { const options = { diff --git a/packages/cli/tests/utils/output.test.ts b/packages/cli/tests/utils/output.test.ts index 6e979d8..edb8ecd 100644 --- a/packages/cli/tests/utils/output.test.ts +++ b/packages/cli/tests/utils/output.test.ts @@ -30,6 +30,11 @@ describe("Output Utils", () => { expect(formatBytes(-1048576)).toBe("-1 MB"); }); + it("should return dash for undefined, null, and NaN", () => { + expect(formatBytes(undefined)).toBe("-"); + expect(formatBytes(NaN)).toBe("-"); + }); + it("should round to two decimal places", () => { expect(formatBytes(1126)).toBe("1.1 KB"); expect(formatBytes(1229)).toBe("1.2 KB"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5810640..7d8435d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: commander: specifier: ^14.0.3 version: 14.0.3 - dotenv: - specifier: ^17.3.1 - version: 17.3.1 glob: specifier: ^13.0.3 version: 13.0.3 @@ -1718,10 +1715,6 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} - dotenv@17.3.1: - resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} - engines: {node: '>=12'} - eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -4958,8 +4951,6 @@ snapshots: dependencies: path-type: 4.0.0 - dotenv@17.3.1: {} - eastasianwidth@0.2.0: {} electron-to-chromium@1.5.286: {}