diff --git a/package.json b/package.json index 98c7358..45e7a66 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "engines": { "node": ">=20.0.0" }, - "packageManager": "pnpm@10.20.0", + "packageManager": "pnpm@10.23.0", "publishConfig": { "access": "public" }, diff --git a/src/config.ts b/src/config.ts index a09762c..ac169de 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { logger } from "@iterable/api"; import { getKeyManager } from "./key-manager.js"; +import { COMMAND_NAME } from "./utils/command-info.js"; import { sanitizeString } from "./utils/sanitize.js"; /** @@ -117,7 +118,7 @@ export async function loadMcpServerConfig(): Promise { if (!apiKey) { throw new Error( - "No API key found. Please run 'iterable-mcp setup' or set ITERABLE_API_KEY environment variable." + `No API key found. Please run '${COMMAND_NAME} setup' or set ITERABLE_API_KEY environment variable.` ); } diff --git a/src/install.ts b/src/install.ts index 8966609..cdfb89e 100644 --- a/src/install.ts +++ b/src/install.ts @@ -8,17 +8,19 @@ import path from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; -import { getKeyManager } from "./key-manager.js"; +import { type ApiKeyMetadata, getKeyManager } from "./key-manager.js"; +import { displayKeyDetails, saveKeyInteractive } from "./keys-cli.js"; +import { + COMMAND_NAME, + KEYS_COMMAND_TABLE, + NPX_PACKAGE_NAME, +} from "./utils/command-info.js"; import { getKeyStorageMessage } from "./utils/formatting.js"; const { dirname, join } = path; // IMPORTANT: UI imports are loaded lazily inside functions to avoid ESM issues in Jest/CommonJS. -// Executable/package names -const LOCAL_BINARY_NAME = "iterable-mcp"; -const NPX_PACKAGE_NAME = "@iterable/mcp"; - // Get package version const packageJson = JSON.parse( readFileSync( @@ -205,12 +207,6 @@ export const setupMcpServer = async (): Promise => { ...(args.includes("--manual") ? ["manual" as const] : []), ]; - // Detect how the command was invoked - const isNpx = - process.argv[1]?.includes("npx") || - process.env.npm_execpath?.includes("npx"); - const commandName = isNpx ? `npx ${NPX_PACKAGE_NAME}` : LOCAL_BINARY_NAME; - if (showHelp) { const { createTable, icons, showBox, showIterableLogo, showSection } = await import("./utils/ui.js"); @@ -228,18 +224,21 @@ export const setupMcpServer = async (): Promise => { }); setupTable.push( - [`${commandName} setup --claude-desktop`, "Configure for Claude Desktop"], - [`${commandName} setup --cursor`, "Configure for Cursor"], - [`${commandName} setup --claude-code`, "Configure for Claude Code"], - [`${commandName} setup --gemini-cli`, "Configure for Gemini CLI"], - [`${commandName} setup --manual`, "Show manual config instructions"], [ - `${commandName} setup --cursor --claude-desktop`, + `${COMMAND_NAME} setup --claude-desktop`, + "Configure for Claude Desktop", + ], + [`${COMMAND_NAME} setup --cursor`, "Configure for Cursor"], + [`${COMMAND_NAME} setup --claude-code`, "Configure for Claude Code"], + [`${COMMAND_NAME} setup --gemini-cli`, "Configure for Gemini CLI"], + [`${COMMAND_NAME} setup --manual`, "Show manual config instructions"], + [ + `${COMMAND_NAME} setup --cursor --claude-desktop`, "Configure multiple tools", ], - [`${commandName} setup --cursor --debug`, "Enable debug logging"], + [`${COMMAND_NAME} setup --cursor --debug`, "Enable debug logging"], [ - `${commandName} setup --cursor --auto-update`, + `${COMMAND_NAME} setup --cursor --auto-update`, "Always use latest version", ] ); @@ -257,12 +256,7 @@ export const setupMcpServer = async (): Promise => { style: "normal", }); - keysTable.push( - [`${commandName} keys list`, "View all stored API keys"], - [`${commandName} keys add`, "Add a new API key"], - [`${commandName} keys activate `, "Switch to a different key"], - [`${commandName} keys delete `, "Remove a key by ID"] - ); + keysTable.push(...KEYS_COMMAND_TABLE); console.log(keysTable.toString()); console.log(); @@ -306,7 +300,7 @@ export const setupMcpServer = async (): Promise => { "Quick Start", [ chalk.white.bold("1. Run setup for your AI tool:"), - chalk.cyan(` ${commandName} setup --cursor`), + chalk.cyan(` ${COMMAND_NAME} setup --cursor`), "", chalk.white.bold("2. Restart your AI tool"), "", @@ -329,37 +323,7 @@ export const setupMcpServer = async (): Promise => { return; } - // New behavior: if no tools were provided as flags, prompt user to select one or more - if (tools.length === 0) { - // Show logo first when no flags provided - const { showIterableLogo } = await import("./utils/ui.js"); - console.clear(); - showIterableLogo(packageJson.version); - - const { selectedTools } = await inquirer.prompt<{ - selectedTools: ToolName[]; - }>([ - { - type: "checkbox", - name: "selectedTools", - message: "Select your AI tools to configure:", - choices: [ - { name: "Cursor", value: "cursor" }, - { name: "Claude Desktop", value: "claude-desktop" }, - { name: "Claude Code (CLI)", value: "claude-code" }, - { name: "Gemini CLI", value: "gemini-cli" }, - { name: "Other / Manual Setup", value: "manual" }, - ], - validate: (arr: any) => - Array.isArray(arr) && arr.length > 0 - ? true - : "Please select at least one tool", - } as any, - ]); - tools = selectedTools; - } - - // Start the setup flow + // Start the setup flow - show logo and load UI console.clear(); const { formatKeyValue, @@ -372,16 +336,9 @@ export const setupMcpServer = async (): Promise => { showSuccess, showWarning, linkColor, - valueColor, } = await import("./utils/ui.js"); const chalk = (await import("chalk")).default; showIterableLogo(packageJson.version); - // Succinct overview of what will be configured - console.log( - chalk.gray( - `Running setup to configure: ${tools.map((t) => TOOL_NAMES[t]).join(", ")}` - ) - ); console.log(); const ora = (await import("ora")).default; const spinner = ora(); @@ -393,12 +350,8 @@ export const setupMcpServer = async (): Promise => { console.log(); let apiKey: string | undefined; - let keyName: string; - let baseUrl: string | undefined; - - let usedKeyName: string | undefined; let usedExistingKey = false; - let selectedExistingMeta: any | undefined; + let selectedExistingMeta: ApiKeyMetadata | undefined; // 1) Offer using API key from environment if (process.env.ITERABLE_API_KEY) { @@ -420,7 +373,7 @@ export const setupMcpServer = async (): Promise => { // 2) If not using env key, offer using an existing stored key if (!apiKey) { const km = getKeyManager(); - const keys = await km.listKeys().catch(() => [] as any[]); + const keys = await km.listKeys().catch(() => [] as ApiKeyMetadata[]); if (Array.isArray(keys) && keys.length > 0) { const { useExisting } = await inquirer.prompt([ { @@ -437,7 +390,7 @@ export const setupMcpServer = async (): Promise => { type: "list", name: "chosenId", message: "Select a stored key:", - choices: keys.map((k: any) => ({ + choices: keys.map((k) => ({ name: formatKeychainChoiceLabel( k.name, k.baseUrl, @@ -452,13 +405,10 @@ export const setupMcpServer = async (): Promise => { ]); try { - const meta = keys.find((k: any) => k.id === chosenId); + const meta = keys.find((k) => k.id === chosenId); if (!meta) throw new Error("Selected key metadata not found"); - baseUrl = meta.baseUrl; - usedKeyName = meta.name; usedExistingKey = true; selectedExistingMeta = meta; - showSuccess(`Using existing key "${meta.name}"`); } catch (e) { showError( e instanceof Error @@ -467,329 +417,161 @@ export const setupMcpServer = async (): Promise => { ); process.exit(1); } + } else { + console.log(); } } } - // 3) If neither env nor existing key chosen, prompt for a new key if (!apiKey && !usedExistingKey) { - const { newApiKey } = await inquirer.prompt([ - { - type: "password", - name: "newApiKey", - message: "Enter your Iterable API key:", - validate: (input: string) => { - if (!input) return "API key is required"; - if (!/^[a-f0-9]{32}$/.test(input)) - return "API key must be a 32-character lowercase hexadecimal string"; - return true; - }, - mask: "*", - }, - ]); - apiKey = newApiKey; - } + const keyManager = getKeyManager(); + await keyManager.initialize(); - // Step 2: API Endpoint Selection - console.log(); - showSection("API Endpoint Configuration", icons.globe); - console.log(); + const { getSpinner } = await import("./utils/cli-env.js"); + const keySpinner = await getSpinner(); - if (!baseUrl) { - if (process.env.ITERABLE_BASE_URL) { - baseUrl = process.env.ITERABLE_BASE_URL; - showSuccess(`Using API endpoint from environment: ${baseUrl}`); - } else { - const { promptForIterableBaseUrl } = await import( - "./utils/endpoint-prompt.js" - ); - try { - baseUrl = await promptForIterableBaseUrl({ - inquirer, - icons, + try { + const newKeyId = await saveKeyInteractive( + keyManager, + null, + { chalk, + icons, showError, - }); - } catch { - process.exit(1); - } - } - } else { - showSuccess(`Using API endpoint from selected key: ${baseUrl}`); - } - - console.log(); - console.log( - formatKeyValue("Selected endpoint", baseUrl as string, linkColor()) - ); - console.log(); - - // Step 3: Store the key securely - let existingKeyWithValue: any = null; - const keyManager = getKeyManager(); - - spinner.start("Initializing secure key storage..."); - await keyManager.initialize(); - spinner.succeed("Key storage ready"); + showSuccess, + showInfo, + formatKeyValue, + linkColor, + showBox: (await import("./utils/ui.js")).showBox, + }, + keySpinner, + { + skipRestartNotice: true, + advanced, + autoActivate: true, + } + ); - // Check if key already exists - if (!usedExistingKey && apiKey) { - spinner.start("Checking for existing keys..."); - existingKeyWithValue = await keyManager.findKeyByValue(apiKey as string); - spinner.stop(); - } + selectedExistingMeta = + (await keyManager.getKeyMetadata(newKeyId)) ?? undefined; + } catch (error) { + showError(error instanceof Error ? error.message : "Failed to add key"); + process.exit(1); + } + } else if (usedExistingKey) { + const keyManager = getKeyManager(); + await keyManager.initialize(); - if (usedExistingKey) { - // Offer to activate the selected existing key if not already active + console.log(); if (!selectedExistingMeta?.isActive) { - const { activateExisting } = await inquirer.prompt([ - { - type: "confirm", - name: "activateExisting", - message: `Set "${selectedExistingMeta?.name}" as your active API key?`, - default: true, - }, - ]); - if (activateExisting) { - await keyManager.setActiveKey(selectedExistingMeta!.id); - showSuccess(`"${selectedExistingMeta?.name}" is now your active key`); - } else { - showInfo("Keeping your current active key"); - } + const { getSpinner } = await import("./utils/cli-env.js"); + const activateSpinner = await getSpinner(); + activateSpinner.start("Activating key..."); + await keyManager.setActiveKey(selectedExistingMeta!.id); + activateSpinner.succeed("Key activated successfully"); + showSuccess( + `"${selectedExistingMeta?.name}" is now your active API key` + ); } else { showSuccess( `"${selectedExistingMeta?.name}" is already your active key` ); } - } else if (existingKeyWithValue) { + + console.log(); + displayKeyDetails(selectedExistingMeta!, { + chalk, + formatKeyValue, + linkColor, + }); + console.log(); showInfo( - `This API key is already stored as "${existingKeyWithValue.name}"` + `To modify permissions, run: ${chalk.cyan(`${COMMAND_NAME} keys update "${selectedExistingMeta?.name}"`)}` ); - usedKeyName = existingKeyWithValue.name; - - if (!existingKeyWithValue.isActive) { - const { activateExisting } = await inquirer.prompt([ - { - type: "confirm", - name: "activateExisting", - message: `Set "${existingKeyWithValue.name}" as your active API key?`, - default: true, - }, - ]); - - if (activateExisting) { - await keyManager.setActiveKey(existingKeyWithValue.id); - showSuccess(`"${existingKeyWithValue.name}" is now your active key`); - } else { - showInfo("Keeping your current active key"); - } - } else { - showSuccess( - `"${existingKeyWithValue.name}" is already your active key` - ); - } } else { - // New key - prompt for name - const { newKeyName } = await inquirer.prompt([ - { - type: "input", - name: "newKeyName", - message: "Enter a name for this API key:", - default: "default", - validate: (input: string) => { - if (input && input.length > 50) - return "Key name must be 50 characters or less"; - return true; - }, - }, - ]); + const keyManager = getKeyManager(); + await keyManager.initialize(); - keyName = newKeyName || "default"; + const { getSpinner } = await import("./utils/cli-env.js"); + const keySpinner = await getSpinner(); - spinner.start("Storing API key securely..."); try { - const newKeyId = await keyManager.addKey( - keyName, - apiKey as string, - baseUrl as string + const newKeyId = await saveKeyInteractive( + keyManager, + null, + { + chalk, + icons, + showError, + showSuccess, + showInfo, + formatKeyValue, + linkColor, + showBox: (await import("./utils/ui.js")).showBox, + }, + keySpinner, + { + skipRestartNotice: true, + advanced, + prefilledApiKey: apiKey as string, + autoActivate: true, + } ); - spinner.succeed("API key stored successfully!"); - - console.log(); - console.log(formatKeyValue("Name", keyName, chalk.white.bold)); - console.log(formatKeyValue("ID", newKeyId, chalk.gray)); - console.log(formatKeyValue("Endpoint", baseUrl as string, chalk.cyan)); - console.log(); - usedKeyName = keyName; - // Check if we should activate this key - const allKeys = await keyManager.listKeys(); - if (allKeys.length > 1) { - const { activateNew } = await inquirer.prompt([ - { - type: "confirm", - name: "activateNew", - message: `Set "${keyName}" as your active API key?`, - default: true, - }, - ]); - - if (activateNew) { - await keyManager.setActiveKey(newKeyId); - showSuccess(`"${keyName}" is now your active key`); - } else { - showInfo( - `Keeping your current active key. Run 'iterable-mcp keys activate "${keyName}"' to switch later.` - ); - } - } + selectedExistingMeta = + (await keyManager.getKeyMetadata(newKeyId)) ?? undefined; } catch (error) { - const { sanitizeString } = await import("./utils/sanitize.js"); - spinner.fail("Failed to store API key"); - console.log(); - const msg = - error instanceof Error - ? sanitizeString(error.message) - : "Unknown error"; - showError(msg); - console.log(); - showInfo( - "Your API key was not stored. You can re-run 'iterable-mcp setup' to try again." - ); - showInfo( - "If the problem persists, verify storage access and disk permissions." - ); + showError(error instanceof Error ? error.message : "Failed to add key"); process.exit(1); } } - // Step 4: Privacy & Security Settings (Basic by default; Advanced via --advanced) - console.log(); - showSection("Privacy & Security Settings", icons.lock); - console.log(); - - // Default conservative env flags - let selectedEnv: { - ITERABLE_USER_PII: "true" | "false"; - ITERABLE_ENABLE_WRITES: "true" | "false"; - ITERABLE_ENABLE_SENDS: "true" | "false"; - } = { - ITERABLE_USER_PII: "false", - ITERABLE_ENABLE_WRITES: "false", - ITERABLE_ENABLE_SENDS: "false", - }; - - if (advanced) { - const { permFlags } = await inquirer.prompt([ - { - type: "checkbox", - name: "permFlags", - message: "Select permissions to enable (default: none):", - choices: [ - { name: "View PII (access user data)", value: "pii" }, - { - name: "Write operations (create/update/delete)", - value: "writes", - }, - { name: "Sends (campaigns/journeys/events)", value: "sends" }, - ], - pageSize: 6, - default: [], - }, - ] as any); - selectedEnv = { - ITERABLE_USER_PII: (permFlags as string[]).includes("pii") - ? "true" - : "false", - ITERABLE_ENABLE_WRITES: (permFlags as string[]).includes("writes") - ? "true" - : "false", - ITERABLE_ENABLE_SENDS: (permFlags as string[]).includes("sends") - ? "true" - : "false", - }; + if (!selectedExistingMeta) { + showError("Failed to get key metadata after setup"); + process.exit(1); } - let mcpEnv: Record = { - ITERABLE_USER_PII: selectedEnv.ITERABLE_USER_PII, - ITERABLE_ENABLE_WRITES: selectedEnv.ITERABLE_ENABLE_WRITES, - ITERABLE_ENABLE_SENDS: selectedEnv.ITERABLE_ENABLE_SENDS, - }; + let mcpEnv: Record = pickPersistablePermissionEnv( + selectedExistingMeta.env || {} + ); + if (args.includes("--debug")) { mcpEnv.ITERABLE_DEBUG = "true"; mcpEnv.LOG_LEVEL = "debug"; } - // Enforce permission invariants before displaying + mcpEnv = enforceSendsRequiresWrites(mcpEnv, (msg) => showWarning(msg)); + // Step 5: Configure AI Tools console.log(); - console.log( - formatKeyValue( - "User PII Access", - mcpEnv.ITERABLE_USER_PII === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled") - ) - ); - console.log( - formatKeyValue( - "Write Operations", - mcpEnv.ITERABLE_ENABLE_WRITES === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled") - ) - ); - console.log( - formatKeyValue( - "Sends", - mcpEnv.ITERABLE_ENABLE_SENDS === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled") - ) - ); - // Only show the default note when all flags are disabled - if ( - mcpEnv.ITERABLE_USER_PII !== "true" && - mcpEnv.ITERABLE_ENABLE_WRITES !== "true" && - mcpEnv.ITERABLE_ENABLE_SENDS !== "true" - ) { - console.log(); - console.log( - chalk.gray( - "Note: These are disabled by default. If you absolutely need to enable these, run 'iterable-mcp setup --advanced'" - ) - ); - } else { - console.log(); - showWarning( - "Warning: Elevated capabilities enabled. Sends, writes, and/or PII access may now occur." - ); - } + showSection("Configure AI Tools", icons.zap); console.log(); - // If we used an existing key, persist the chosen settings to it - if (usedKeyName) { - try { - await keyManager.updateKeyEnv( - usedKeyName, - pickPersistablePermissionEnv(mcpEnv) - ); - } catch (err) { - if (process.env.ITERABLE_DEBUG === "true") { - console.warn( - "Non-fatal: failed to persist per-key env settings", - err - ); - } - } + if (tools.length === 0) { + const { selectedTools } = await inquirer.prompt<{ + selectedTools: ToolName[]; + }>([ + { + type: "checkbox", + name: "selectedTools", + message: "Select your AI tools to configure:", + choices: [ + { name: "Cursor", value: "cursor" }, + { name: "Claude Desktop", value: "claude-desktop" }, + { name: "Claude Code (CLI)", value: "claude-code" }, + { name: "Gemini CLI", value: "gemini-cli" }, + { name: "Other / Manual Setup", value: "manual" }, + ], + validate: (arr) => + Array.isArray(arr) && arr.length > 0 + ? true + : "Please select at least one tool", + }, + ]); + tools = selectedTools; + console.log(); } - // Step 5: Configure AI Tools - console.log(); - showSection("Configuring AI Tools", icons.zap); - console.log(); - - // Ask about auto-update unless flag was provided let useAutoUpdate = autoUpdate; if (!autoUpdate) { const { enableAutoUpdate } = await inquirer.prompt([ @@ -801,6 +583,7 @@ export const setupMcpServer = async (): Promise => { default: false, }, ]); + console.log(); useAutoUpdate = enableAutoUpdate; } @@ -810,57 +593,6 @@ export const setupMcpServer = async (): Promise => { autoUpdate: useAutoUpdate, }); - // Preflight confirmation summary before applying changes - console.log(chalk.gray("Summary:")); - console.log( - formatKeyValue( - "Tools", - tools.map((t) => TOOL_NAMES[t]).join(", "), - valueColor() - ) - ); - if (usedKeyName) { - console.log(formatKeyValue("API Key", usedKeyName, valueColor())); - } - console.log(formatKeyValue("Endpoint", baseUrl as string, valueColor())); - console.log( - formatKeyValue( - "User PII", - mcpEnv.ITERABLE_USER_PII === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled") - ) - ); - console.log( - formatKeyValue( - "Writes", - mcpEnv.ITERABLE_ENABLE_WRITES === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled") - ) - ); - console.log( - formatKeyValue( - "Sends", - mcpEnv.ITERABLE_ENABLE_SENDS === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled") - ) - ); - console.log(); - const { proceed } = await inquirer.prompt<{ proceed: boolean }>([ - { - type: "confirm", - name: "proceed", - message: "Proceed with configuration?", - default: true, - }, - ]); - if (!proceed) { - showInfo("Setup cancelled by user. No changes were made."); - return; - } - const fileBasedTools = tools.filter( (tool): tool is FileBasedToolName => tool in TOOL_CONFIGS ); @@ -895,13 +627,14 @@ export const setupMcpServer = async (): Promise => { "Please install Claude Code first: https://docs.claude.com/en/docs/claude-code/overview" ); console.log(); - showInfo("After installing, re-run: iterable-mcp setup --claude-code"); + showInfo( + `After installing, re-run: ${COMMAND_NAME} setup --claude-code` + ); process.exit(1); } const configJson = JSON.stringify(iterableMcpConfig); - // Remove existing config (ignore errors) await execFileAsync("claude", ["mcp", "remove", "iterable"]).catch( () => {} ); @@ -936,13 +669,11 @@ export const setupMcpServer = async (): Promise => { } if (needsManual) { - console.log(); showSection("Manual Configuration", icons.rocket); console.log(); const { type, command, args, env } = iterableMcpConfig; - showInfo("Your API key has been stored."); showInfo("Add the MCP server to your AI tool with these settings:"); console.log(); console.log(chalk.white.bold(" Type:") + ` ${type}`); @@ -962,7 +693,6 @@ export const setupMcpServer = async (): Promise => { ); } - // Build configured tools list const configuredTools: string[] = []; if (fileBasedTools.includes("cursor")) configuredTools.push("Cursor"); if (fileBasedTools.includes("claude-desktop")) @@ -981,7 +711,6 @@ export const setupMcpServer = async (): Promise => { ", and " + configuredTools[configuredTools.length - 1]; - // Success! console.log(); const nextSteps = [ ...(needsManual ? ["Configure your AI tool as described above"] : []), @@ -994,8 +723,8 @@ export const setupMcpServer = async (): Promise => { const tips = [ `Try: 'list my Iterable campaigns' in ${toolsList}`, - "Manage keys with 'iterable-mcp keys list'", - "Switch keys with 'iterable-mcp keys activate '", + `Manage keys with '${COMMAND_NAME} keys list'`, + `Switch keys with '${COMMAND_NAME} keys activate '`, ]; showCompletion("Setup Complete!", nextSteps, tips); diff --git a/src/key-manager.ts b/src/key-manager.ts index 33890d6..a7a1193 100644 --- a/src/key-manager.ts +++ b/src/key-manager.ts @@ -25,6 +25,7 @@ import os from "os"; import path from "path"; import { promisify } from "util"; +import { COMMAND_NAME } from "./utils/command-info.js"; import { isHttpsOrLocalhost, isLocalhostHost } from "./utils/url.js"; // Service name for keychain entries @@ -73,6 +74,8 @@ export interface ApiKeyMetadata { baseUrl: string; /** ISO timestamp when key was created */ created: string; + /** ISO timestamp when key was last updated */ + updated?: string; /** Whether this is the currently active key */ isActive: boolean; /** Optional per-key environment overrides (extensible for future vars) */ @@ -188,14 +191,14 @@ export class KeyManager { for (const { id, name } of summary) { console.warn(` • ${name} (ID: ${id})`); console.warn( - ` Delete from metadata: iterable-mcp keys delete "${id}"` + ` Delete from metadata: ${COMMAND_NAME} keys delete "${id}"` ); console.warn( ` macOS manual cleanup: security delete-generic-password -a "${id}" -s "${SERVICE_NAME}"` ); } console.warn("\nNext steps:"); - console.warn(" 1) List keys: iterable-mcp keys list"); + console.warn(` 1) List keys: ${COMMAND_NAME} keys list`); console.warn(" 2) Delete any orphaned keys using the ID shown above"); console.warn(" 3) Re-run your command after cleanup\n"); @@ -578,21 +581,24 @@ export class KeyManager { } /** - * Add a new API key + * Save (add or update) an API key * - * Stores an API key securely (Keychain on macOS, file on other platforms). + * Private implementation that handles both add and update operations. + * If idOrName is provided, updates an existing key. Otherwise, adds a new key. * - * @param name - User-friendly name for the key (must be unique) - * @param apiKey - 32-character lowercase hexadecimal Iterable API key - * @param baseUrl - Iterable API base URL (must be HTTPS) - * @returns The unique ID generated for this key - * @throws {Error} If the key name already exists, validation fails, or storage fails + * @param name - User-friendly name for the key + * @param apiKey - The Iterable API key value + * @param baseUrl - Iterable API base URL + * @param envOverrides - Environment variable overrides. If undefined, keeps existing env (update) or no env (add). If provided (even empty {}), replaces/clears env. + * @param idOrName - If provided, updates the existing key with this ID or name + * @returns The ID of the saved key */ - async addKey( + private async saveKey( name: string, apiKey: string, baseUrl: string, - envOverrides?: Record + envOverrides?: Record, + idOrName?: string ): Promise { if (!this.store) { await this.initialize(); @@ -610,37 +616,55 @@ export class KeyManager { this.validateApiKey(apiKey); this.validateBaseUrl(baseUrl); + const isUpdate = !!idOrName; + let existingIndex = -1; + let existingKey: ApiKeyMetadata | undefined; + + if (isUpdate) { + // UPDATE mode: Find existing key + existingIndex = this.store.keys.findIndex( + (k) => k.id === idOrName || k.name === idOrName + ); + + if (existingIndex === -1) { + throw new Error(`Key not found: ${idOrName}`); + } + + existingKey = this.store.keys[existingIndex]!; + } + // Check for duplicate names - if (this.store.keys.some((k) => k.name === name)) { - throw new Error(`Key with name "${name}" already exists`); + if (existingKey?.name !== name) { + if (this.store.keys.some((k) => k.name === name)) { + throw new Error(`Key with name "${name}" already exists`); + } } - // Generate unique ID - const id = this.generateId(); - - // If this is the first key, make it active - const isActive = this.store.keys.length === 0; - - // Create metadata - const metadata: ApiKeyMetadata = { - id, - name, - baseUrl, - created: new Date().toISOString(), - isActive, - ...(envOverrides && Object.keys(envOverrides).length > 0 - ? { env: envOverrides } - : {}), - }; - - // Store API key + const metadata: ApiKeyMetadata = isUpdate + ? { + ...existingKey!, + name, + baseUrl, + updated: new Date().toISOString(), + ...(envOverrides !== undefined ? { env: envOverrides } : {}), + } + : { + id: this.generateId(), + name, + baseUrl, + created: new Date().toISOString(), + isActive: this.store.keys.length === 0, + ...(envOverrides !== undefined ? { env: envOverrides } : {}), + }; + + // Store API key in secure storage switch (this.storageMethod) { case StorageMethod.KEYCHAIN: try { await this.execSecurity([ "add-generic-password", "-a", - id, + metadata.id, "-s", SERVICE_NAME, "-w", @@ -648,7 +672,10 @@ export class KeyManager { "-U", ]); } catch (error) { - logger.error("Failed to store key in keychain", { error, id }); + logger.error("Failed to store key in keychain", { + error, + id: metadata.id, + }); throw new Error( `Failed to store key in macOS Keychain: ${error instanceof Error ? error.message : String(error)}` ); @@ -659,7 +686,10 @@ export class KeyManager { try { metadata.encryptedApiKey = await this.encryptWindows(apiKey); } catch (error) { - logger.error("Failed to encrypt key with DPAPI", { error, id }); + logger.error("Failed to encrypt key with DPAPI", { + error, + id: metadata.id, + }); throw new Error( `Failed to encrypt key with Windows DPAPI: ${error instanceof Error ? error.message : String(error)}` ); @@ -672,34 +702,65 @@ export class KeyManager { break; } - // Store metadata - this.store.keys.push(metadata); + // Store or update metadata + if (isUpdate) { + this.store.keys[existingIndex] = metadata; + } else { + this.store.keys.push(metadata); + } + await this.saveMetadata(); - return id; + logger.debug(isUpdate ? "API key updated" : "API key added", { + id: metadata.id, + name: metadata.name, + }); + + return metadata.id; + } + + /** + * Add a new API key + * + * Stores an API key securely (Keychain on macOS, encrypted on Windows, file on Linux). + * + * @param name - User-friendly name for the key (must be unique) + * @param apiKey - 32-character lowercase hexadecimal Iterable API key + * @param baseUrl - Iterable API base URL (must be HTTPS) + * @param envOverrides - Optional environment variable overrides for this key + * @returns The unique ID generated for this key + * @throws {Error} If the key name already exists, validation fails, or storage fails + */ + async addKey( + name: string, + apiKey: string, + baseUrl: string, + envOverrides?: Record + ): Promise { + return this.saveKey(name, apiKey, baseUrl, envOverrides); } /** - * Update per-key environment overrides for an existing key + * Update an existing API key + * + * Updates all properties of an existing key including name, API key value, base URL, and environment overrides. + * + * @param idOrName - The unique ID or name of the key to update + * @param name - New name for the key (must be unique unless unchanged) + * @param apiKey - New API key value (can be the same as existing) + * @param baseUrl - New Iterable API base URL + * @param envOverrides - New environment variable overrides (undefined = keep existing, {} = clear) + * @returns The ID of the updated key + * @throws {Error} If the key is not found, name conflict, validation fails, or storage fails */ - async updateKeyEnv( + async updateKey( idOrName: string, - envOverrides: Record - ): Promise { - if (!this.store) { - await this.initialize(); - } - if (!this.store) { - throw new Error("Key store not initialized"); - } - const keyMeta = this.store.keys.find( - (k) => k.id === idOrName || k.name === idOrName - ); - if (!keyMeta) { - throw new Error(`Key not found: ${idOrName}`); - } - keyMeta.env = { ...(keyMeta.env || {}), ...envOverrides }; - await this.saveMetadata(); + name: string, + apiKey: string, + baseUrl: string, + envOverrides?: Record + ): Promise { + return this.saveKey(name, apiKey, baseUrl, envOverrides, idOrName); } /** @@ -723,6 +784,17 @@ export class KeyManager { return [...this.store.keys]; } + /** + * Get key metadata by ID + * + * @param idOrName - The unique ID or user-friendly name of the key + * @returns The key metadata, or null if not found + */ + async getKeyMetadata(idOrName: string): Promise { + const keys = await this.listKeys(); + return keys.find((k) => k.id === idOrName || k.name === idOrName) ?? null; + } + /** * Get a key by ID or name * @@ -977,13 +1049,13 @@ export class KeyManager { ` To manually remove: security delete-generic-password -a "${keyMeta.id}" -s "${SERVICE_NAME}"` ); } else { - logger.info("API key deleted", { id: keyMeta.id, name: keyMeta.name }); + logger.debug("API key deleted", { id: keyMeta.id, name: keyMeta.name }); } } else { // Windows/Linux: Just remove from JSON this.store.keys.splice(index, 1); await this.saveMetadata(); - logger.info("API key deleted", { id: keyMeta.id, name: keyMeta.name }); + logger.debug("API key deleted", { id: keyMeta.id, name: keyMeta.name }); } } @@ -1102,12 +1174,12 @@ export class KeyManager { const existing = this.store.keys.find((k) => k.name === name); if (existing) { - logger.info("Legacy key already migrated", { name }); + logger.debug("Legacy key already migrated", { name }); return existing.id; } // Add the legacy key - logger.info("Migrating legacy API key", { name }); + logger.debug("Migrating legacy API key", { name }); return this.addKey(name, apiKey, baseUrl); } } diff --git a/src/keys-cli.ts b/src/keys-cli.ts index 80833e0..acafa4d 100644 --- a/src/keys-cli.ts +++ b/src/keys-cli.ts @@ -8,11 +8,16 @@ import inquirer from "inquirer"; import path from "path"; import { fileURLToPath } from "url"; +import { COMMAND_NAME, KEYS_COMMAND_TABLE } from "./utils/command-info.js"; + const { dirname, join } = path; +import type { ApiKeyMetadata, KeyManager } from "./key-manager.js"; import { getSpinner, loadUi } from "./utils/cli-env.js"; +import { promptForIterableBaseUrl } from "./utils/endpoint-prompt.js"; import { getKeyStorageMessage } from "./utils/formatting.js"; import { promptForApiKey } from "./utils/password-prompt.js"; +import { sanitizeString } from "./utils/sanitize.js"; // Get package version const packageJson = JSON.parse( @@ -22,6 +27,496 @@ const packageJson = JSON.parse( ) ) as { version: string }; +/** + * Display key details including permissions + * + * @param meta - The key metadata to display + * @param ui - UI utilities (chalk, formatKeyValue, linkColor) + */ +export function displayKeyDetails( + meta: ApiKeyMetadata, + ui: { + chalk: any; + formatKeyValue: (key: string, value: string, color?: any) => string; + linkColor: () => (s: string) => string; + } +): void { + const { chalk, formatKeyValue, linkColor } = ui; + + console.log(formatKeyValue("Name", meta.name, chalk.white.bold)); + console.log(formatKeyValue("ID", meta.id, chalk.gray)); + console.log(formatKeyValue("Endpoint", meta.baseUrl, linkColor())); + + const pii = + meta.env?.ITERABLE_USER_PII === "true" + ? chalk.green("Enabled") + : chalk.gray("Disabled"); + const writes = + meta.env?.ITERABLE_ENABLE_WRITES === "true" + ? chalk.green("Enabled") + : chalk.gray("Disabled"); + const sends = + meta.env?.ITERABLE_ENABLE_SENDS === "true" + ? chalk.green("Enabled") + : chalk.gray("Disabled"); + + console.log(formatKeyValue("User PII", pii)); + console.log(formatKeyValue("Writes", writes)); + console.log(formatKeyValue("Sends", sends)); +} + +/** + * Find a key by ID or name, with helpful error messages and suggestions + * + * @param keyManager - The key manager instance + * @param idOrName - The key ID or name to find (if undefined/empty, shows usage and exits) + * @param commandName - The command name for usage messages (e.g., "update", "delete") + * @param ui - UI utilities (chalk, showError, showInfo) + * @returns The found key metadata + * @throws Exits process if key not found or missing + */ +async function findKeyOrExit( + keyManager: KeyManager, + idOrName: string | undefined, + commandName: string, + ui: { chalk: any; showError: any; showInfo: any } +): Promise { + const { chalk, showError, showInfo } = ui; + + if (!idOrName) { + console.log(); + showError("Missing key name or ID"); + console.log(); + console.log(chalk.white.bold(" USAGE")); + console.log( + chalk.white(` ${COMMAND_NAME} keys ${commandName} `) + ); + console.log(); + console.log(chalk.white.bold(" EXAMPLE")); + console.log( + chalk.cyan(` ${COMMAND_NAME} keys ${commandName} production`) + ); + console.log(); + showInfo("If your key name has spaces, wrap it in quotes"); + console.log( + chalk.gray( + ` Example: ${COMMAND_NAME} keys ${commandName} "My Prod Key"` + ) + ); + console.log(); + process.exit(1); + } + + const keys = await keyManager.listKeys(); + const key = keys.find( + (k: ApiKeyMetadata) => k.id === idOrName || k.name === idOrName + ); + + if (!key) { + showError(`Key not found: ${idOrName}`); + showInfo(`Run '${COMMAND_NAME} keys list' to view all keys`); + process.exit(1); + } + + return key; +} + +/** + * Interactive flow to save (add or update) an API key + * + * Handles the complete flow including: + * - Prompting for key details + * - Saving the key + * - Optionally activating new keys + * - Showing restart notice for active keys + * + * @param keyManager - The key manager instance + * @param existingKey - If provided, updates this key; otherwise adds a new key + * @param ui - UI utilities (chalk, icons, etc.) + * @param spinner - Spinner for loading states + * @param options - Optional configuration + * - advanced: if true, shows permission checkboxes; if false/undefined, uses secure defaults + * - skipRestartNotice: skip showing restart notice at the end + * - prefilledApiKey: if provided, skip API key prompt and use this value + * - autoActivate: if true, automatically activates new keys (for installer); if false, prompts user + * @returns The saved key's ID + */ +export async function saveKeyInteractive( + keyManager: KeyManager, + existingKeyArg: ApiKeyMetadata | null, + ui: any, + spinner: any, + options?: { + advanced?: boolean; + skipRestartNotice?: boolean; + prefilledApiKey?: string; + autoActivate?: boolean; + } +): Promise { + const { + chalk, + icons, + showError, + showSuccess, + showInfo, + formatKeyValue, + linkColor, + showBox, + } = ui; + + // Use local variables so we can reassign if duplicate detected + let isUpdate = !!existingKeyArg; + let existingKey = existingKeyArg; + + // Helper to activate a key after selecting or updating a key + const maybeActivateKey = async ( + key: ApiKeyMetadata + ): Promise => { + if (key.isActive) { + console.log(); + showSuccess(`"${key.name}" is already your active key`); + return key; + } + + let shouldActivate = true; + + if (!options?.autoActivate) { + const { activateNow } = await inquirer.prompt([ + { + type: "confirm", + name: "activateNow", + message: `Set "${key.name}" as your active API key now?`, + default: !isUpdate, // Default to yes for new keys, no for updates + }, + ]); + shouldActivate = activateNow; + } + + if (shouldActivate) { + spinner.start("Activating key..."); + await keyManager.setActiveKey(key.id); + spinner.succeed("Key activated successfully"); + console.log(); + showSuccess(`"${key.name}" is now your active API key`); + + const updatedKey = (await keyManager.getKeyMetadata(key.id))!; + + if (!options?.skipRestartNotice) { + console.log(); + showBox( + "Action Required", + [ + chalk.yellow("Restart your AI tools to use this key"), + "", + chalk.gray( + "The MCP server will automatically load the active key when it starts" + ), + ], + { icon: icons.zap, theme: "warning" } + ); + } + + return updatedKey; + } else { + console.log(); + showInfo( + `Keeping your current active key. Run '${COMMAND_NAME} keys activate "${key.name}"' to switch later.` + ); + return key; + } + }; + + // Step 1: Get API key value (prompt, use prefilled, or retrieve existing) + let apiKey: string; + if (isUpdate) { + const { updateApiKey } = await inquirer.prompt([ + { + type: "confirm", + name: "updateApiKey", + message: "Update the API key value?", + default: false, + }, + ]); + + if (updateApiKey) { + console.log(); + apiKey = await promptForApiKey( + icons.lock + " Enter your new Iterable API key: " + ); + } else { + spinner.start("Retrieving existing API key..."); + try { + const existingApiKey = await keyManager.getKey(existingKey!.id); + if (!existingApiKey) { + spinner.fail("Failed to retrieve existing API key"); + showError("Could not access the existing API key value"); + console.log(); + showInfo("You'll need to enter a new API key value"); + apiKey = await promptForApiKey( + icons.lock + " Enter your Iterable API key: " + ); + } else { + spinner.succeed("Using existing API key"); + apiKey = existingApiKey; + } + } catch (error) { + spinner.fail("Failed to retrieve existing API key"); + showError( + error instanceof Error ? error.message : "Could not access key" + ); + console.log(); + showInfo("You'll need to enter a new API key value"); + apiKey = await promptForApiKey( + icons.lock + " Enter your Iterable API key: " + ); + } + } + } else { + if (options?.prefilledApiKey) { + apiKey = options.prefilledApiKey; + showSuccess("Using API key from environment variable"); + } else { + apiKey = await promptForApiKey( + icons.lock + " Enter your Iterable API key: " + ); + } + } + + // Step 2: For new keys, check if this API key value already exists + if (!isUpdate) { + spinner.start("Checking for duplicate keys..."); + const existingKeyWithValue = await keyManager.findKeyByValue(apiKey); + spinner.stop(); + + if (existingKeyWithValue) { + console.log(); + showInfo( + `This API key is already stored as "${existingKeyWithValue.name}"` + ); + console.log(); + + displayKeyDetails(existingKeyWithValue, { + chalk, + formatKeyValue, + linkColor, + }); + console.log(); + + const { updateExisting } = await inquirer.prompt([ + { + type: "confirm", + name: "updateExisting", + message: `Update this key with new settings?`, + default: true, + }, + ]); + + if (!updateExisting) { + console.log(); + await maybeActivateKey(existingKeyWithValue); + console.log(); + return existingKeyWithValue.id; + } + + // User wants to update - convert this to an update operation + console.log(); + showInfo(`Updating existing key: "${existingKeyWithValue.name}"`); + console.log(); + + isUpdate = true; + existingKey = existingKeyWithValue; + } + } + + // Step 3: Prompt for endpoint + let baseUrl: string; + try { + baseUrl = await promptForIterableBaseUrl({ + inquirer, + icons, + chalk, + showError, + ...(existingKey?.baseUrl ? { defaultBaseUrl: existingKey.baseUrl } : {}), + }); + } catch { + process.exit(1); + return ""; + } + + if (isUpdate && existingKey!.baseUrl !== baseUrl) { + console.log(); + showInfo(`Endpoint changing from ${existingKey!.baseUrl} to ${baseUrl}`); + } + + // Step 4: Prompt for name + const validateKeyName = async (input: string): Promise => { + if (!input) return "Name is required"; + if (input.length > 50) return "Name must be 50 characters or less"; + + // Check for duplicate names (skip if updating and keeping the same name) + if (input !== existingKey?.name) { + const keys = await keyManager.listKeys(); + if (keys.some((k) => k.name === input)) { + return `A key named "${input}" already exists. Please choose a different name.`; + } + } + return true; + }; + + const { name } = await inquirer.prompt([ + { + type: "input", + name: "name", + message: isUpdate + ? "Enter a new name, or press Enter to keep current name:" + : "Enter a name for this API key:", + default: existingKey?.name || "default", + validate: validateKeyName, + }, + ]); + + // Step 5: Configure permissions + const currentEnv = existingKey?.env || {}; + let selectedEnv: { + ITERABLE_USER_PII: "true" | "false"; + ITERABLE_ENABLE_WRITES: "true" | "false"; + ITERABLE_ENABLE_SENDS: "true" | "false"; + } = { + ITERABLE_USER_PII: + (currentEnv.ITERABLE_USER_PII as "true" | "false") || "false", + ITERABLE_ENABLE_WRITES: + (currentEnv.ITERABLE_ENABLE_WRITES as "true" | "false") || "false", + ITERABLE_ENABLE_SENDS: + (currentEnv.ITERABLE_ENABLE_SENDS as "true" | "false") || "false", + }; + + if (options?.advanced) { + const currentPerms: string[] = []; + if (selectedEnv.ITERABLE_USER_PII === "true") currentPerms.push("pii"); + if (selectedEnv.ITERABLE_ENABLE_WRITES === "true") + currentPerms.push("writes"); + if (selectedEnv.ITERABLE_ENABLE_SENDS === "true") + currentPerms.push("sends"); + + const { permFlags } = await inquirer.prompt([ + { + type: "checkbox", + name: "permFlags", + message: "Select permissions to enable (default: none):", + choices: [ + { name: "View PII (access user data)", value: "pii" }, + { + name: "Write operations (create/update/delete)", + value: "writes", + }, + { name: "Sends (campaigns/journeys/events)", value: "sends" }, + ], + pageSize: 6, + default: currentPerms, + }, + ]); + + selectedEnv = { + ITERABLE_USER_PII: (permFlags as string[]).includes("pii") + ? "true" + : "false", + ITERABLE_ENABLE_WRITES: (permFlags as string[]).includes("writes") + ? "true" + : "false", + ITERABLE_ENABLE_SENDS: (permFlags as string[]).includes("sends") + ? "true" + : "false", + }; + } else { + console.log(); + if (isUpdate) { + showInfo("Keeping existing permissions"); + } else { + showInfo( + "Using secure default permissions (PII, Writes, and Sends disabled)" + ); + } + showInfo(`Run with --advanced to configure advanced permissions`); + console.log(); + } + + // Step 6: Save the key + spinner.start( + isUpdate ? "Updating API key..." : "Storing API key securely..." + ); + try { + const id = isUpdate + ? await keyManager.updateKey( + existingKey!.id, + name, + apiKey, + baseUrl, + selectedEnv + ) + : await keyManager.addKey(name, apiKey, baseUrl, selectedEnv); + + spinner.succeed( + isUpdate + ? "API key updated successfully!" + : "API key stored successfully!" + ); + + console.log(); + console.log(formatKeyValue("Name", name, chalk.white.bold)); + console.log(formatKeyValue("ID", id, chalk.gray)); + console.log(formatKeyValue("Endpoint", baseUrl, linkColor())); + console.log( + formatKeyValue( + "User PII", + selectedEnv.ITERABLE_USER_PII === "true" + ? chalk.green("Enabled") + : chalk.gray("Disabled") + ) + ); + console.log( + formatKeyValue( + "Writes", + selectedEnv.ITERABLE_ENABLE_WRITES === "true" + ? chalk.green("Enabled") + : chalk.gray("Disabled") + ) + ); + console.log( + formatKeyValue( + "Sends", + selectedEnv.ITERABLE_ENABLE_SENDS === "true" + ? chalk.green("Enabled") + : chalk.gray("Disabled") + ) + ); + console.log(); + + showSuccess( + isUpdate + ? "Your API key has been updated (encrypted at rest)" + : "Your API key is now securely stored (encrypted at rest)" + ); + + const savedKey = await keyManager.getKeyMetadata(id); + if (savedKey) { + await maybeActivateKey(savedKey); + } else { + showError("Failed to get key metadata after saving"); + process.exit(1); + } + + return id; + } catch (error) { + spinner.fail( + isUpdate ? "Failed to update API key" : "Failed to add API key" + ); + const msg = + error instanceof Error ? sanitizeString(error.message) : "Unknown error"; + showError(msg); + process.exit(1); + } +} + /** * Handle the 'keys' command and its subcommands */ @@ -29,6 +524,10 @@ export async function handleKeysCommand(): Promise { const args = process.argv.slice(2); const subCommand = args[1]; + // Parse common flags and get positional arguments + const hasAdvancedFlag = args.includes("--advanced"); + const positionalArgs = args.filter((arg) => !arg.startsWith("--")); + const chalk = (await import("chalk")).default; const { createTable, @@ -44,6 +543,7 @@ export async function handleKeysCommand(): Promise { } = await loadUi(); const spinner = await getSpinner(); + // Dynamic import for testability (Jest mocks need runtime imports) const { getKeyManager } = await import("./key-manager.js"); const keyManager = getKeyManager(); @@ -74,7 +574,7 @@ export async function handleKeysCommand(): Promise { chalk.gray("You haven't added any API keys yet."), "", chalk.cyan("Get started by running:"), - chalk.bold.white(" iterable-mcp setup"), + chalk.bold.white(` ${COMMAND_NAME} setup`), ], { icon: icons.key, theme: "info" } ); @@ -90,7 +590,7 @@ export async function handleKeysCommand(): Promise { "View PII?", "Writes?", "Sends?", - "Created", + "Modified", "Status", ], style: "normal", @@ -101,7 +601,9 @@ export async function handleKeysCommand(): Promise { ? chalk.bgGreen.black(" ACTIVE ") : chalk.gray("INACTIVE"); - const createdDate = new Date(key.created).toLocaleDateString( + // Show updated date if available, otherwise created date + const dateToShow = key.updated || key.created; + const formattedDate = new Date(dateToShow).toLocaleDateString( "en-US", { year: "numeric", @@ -131,7 +633,7 @@ export async function handleKeysCommand(): Promise { pii, writes, sends, - chalk.gray(createdDate), + chalk.gray(formattedDate), statusBadge, ]); } @@ -139,20 +641,22 @@ export async function handleKeysCommand(): Promise { console.log(table.toString()); console.log(); - // Show key management tips - const tips = [ - "Use " + - chalk.cyan("keys activate ") + - " to switch between keys", - "Use " + chalk.cyan("keys add") + " to add a new API key", - getKeyStorageMessage(), - ]; - - showBox("Quick Tips", tips, { - icon: icons.bulb, - theme: "info", - padding: 1, - }); + // Show key management commands + showBox( + "Key Management", + [ + ...KEYS_COMMAND_TABLE.map( + ([cmd, desc]) => `${chalk.cyan(cmd)} - ${chalk.gray(desc)}` + ), + "", + getKeyStorageMessage(), + ], + { + icon: icons.key, + theme: "info", + padding: 1, + } + ); } break; } @@ -161,308 +665,118 @@ export async function handleKeysCommand(): Promise { console.clear(); showIterableLogo(packageJson.version); - // Interactive add flow (no flags; all prompts) - const { name } = await inquirer.prompt([ + await saveKeyInteractive( + keyManager, + null, { - type: "input", - name: "name", - message: "Enter a name for this API key:", - default: "default", - validate: (input: string) => { - if (!input) return "Name is required"; - if (input.length > 50) return "Name must be 50 characters or less"; - return true; - }, + chalk, + icons, + showError, + showSuccess, + showInfo, + formatKeyValue, + linkColor, + showBox, }, - ]); - - const { promptForIterableBaseUrl } = await import( - "./utils/endpoint-prompt.js" + spinner, + hasAdvancedFlag ? { advanced: true } : undefined ); - let baseUrl: string; - try { - baseUrl = await promptForIterableBaseUrl({ - inquirer, - icons, + break; + } + + case "update": { + console.clear(); + showIterableLogo(packageJson.version); + + const existingKey = await findKeyOrExit( + keyManager, + positionalArgs[2], + "update", + { chalk, showError, - }); - } catch { - process.exit(1); - return; - } - - const apiKey = await promptForApiKey( - "\n " + icons.lock + " Enter your Iterable API key: " + showInfo, + } ); - // Basic by default: conservative env flags; optional advanced profile - let selectedEnv: { - ITERABLE_USER_PII: "true" | "false"; - ITERABLE_ENABLE_WRITES: "true" | "false"; - ITERABLE_ENABLE_SENDS: "true" | "false"; - } = { - ITERABLE_USER_PII: "false", - ITERABLE_ENABLE_WRITES: "false", - ITERABLE_ENABLE_SENDS: "false", - }; - const { doAdvanced } = await inquirer.prompt([ + console.log(); + showInfo(`Updating key: "${existingKey.name}"`); + console.log(); + + await saveKeyInteractive( + keyManager, + existingKey, { - type: "confirm", - name: "doAdvanced", - message: "Configure advanced permissions now?", - default: false, + chalk, + icons, + showError, + showSuccess, + showInfo, + formatKeyValue, + linkColor, + showBox, }, - ]); - if (doAdvanced) { - const { permFlags } = await inquirer.prompt([ - { - type: "checkbox", - name: "permFlags", - message: "Select permissions to enable (default: none):", - choices: [ - { name: "View PII (access user data)", value: "pii" }, - { - name: "Write operations (create/update/delete)", - value: "writes", - }, - { name: "Sends (campaigns/journeys/events)", value: "sends" }, - ], - pageSize: 6, - default: [], - }, - ] as any); - selectedEnv = { - ITERABLE_USER_PII: (permFlags as string[]).includes("pii") - ? "true" - : "false", - ITERABLE_ENABLE_WRITES: (permFlags as string[]).includes("writes") - ? "true" - : "false", - ITERABLE_ENABLE_SENDS: (permFlags as string[]).includes("sends") - ? "true" - : "false", - } as any; - } + spinner, + { advanced: hasAdvancedFlag } + ); + break; + } - // Check if this API key value already exists - spinner.start("Checking for duplicate keys..."); - const existingKeyWithValue = await keyManager.findKeyByValue(apiKey); - spinner.stop(); + case "activate": { + console.clear(); + showIterableLogo(packageJson.version); - if (existingKeyWithValue) { - console.log(); - showInfo( - `This API key is already stored as "${existingKeyWithValue.name}"` - ); + // Find the key first to get better error messages + const keyToActivate = await findKeyOrExit( + keyManager, + positionalArgs[2], + "activate", + { chalk, showError, showInfo } + ); - if (!existingKeyWithValue.isActive) { - console.log(); - console.log(chalk.gray(" To activate this key, run:")); - console.log( - chalk.cyan( - ` iterable-mcp keys activate "${existingKeyWithValue.name}"` - ) - ); - } else { - console.log(); - showSuccess( - `"${existingKeyWithValue.name}" is already your active key` - ); - } - console.log(); - break; - } + spinner.start(`Activating key "${keyToActivate.name}"...`); - // Add the key - spinner.start("Storing API key securely..."); try { - const id = await keyManager.addKey(name, apiKey, baseUrl, { - ...selectedEnv, - }); - spinner.succeed("API key stored successfully!"); - - console.log(); - console.log(formatKeyValue("Name", name, chalk.white.bold)); - console.log(formatKeyValue("ID", id, chalk.gray)); - console.log(formatKeyValue("Endpoint", baseUrl, linkColor())); - console.log( - formatKeyValue( - "User PII", - selectedEnv.ITERABLE_USER_PII === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled") - ) - ); - console.log( - formatKeyValue( - "Writes", - selectedEnv.ITERABLE_ENABLE_WRITES === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled") - ) - ); - console.log( - formatKeyValue( - "Sends", - selectedEnv.ITERABLE_ENABLE_SENDS === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled") - ) + await keyManager.getKey(keyToActivate.id); + } catch (error) { + spinner.fail("Failed to activate key"); + showError( + error instanceof Error ? error.message : "Failed to access key" ); console.log(); - - showSuccess("Your API key is now securely stored (encrypted at rest)"); - - // Offer to set newly added key as active - const { activateNow } = await inquirer.prompt([ - { - type: "confirm", - name: "activateNow", - message: `Set "${name}" as your active API key now?`, - default: true, - }, - ]); - - if (activateNow) { - spinner.start("Activating key..."); - await keyManager.setActiveKey(id); - spinner.succeed("Key activated"); - showSuccess(`"${name}" is now your active key`); - } else { - showInfo( - `Keeping your current active key. Run 'iterable-mcp keys activate "${name}"' to switch later.` - ); - } - } catch (error) { - const { sanitizeString } = await import("./utils/sanitize.js"); - spinner.fail("Failed to add API key"); - const msg = - error instanceof Error - ? sanitizeString(error.message) - : "Unknown error"; - showError(msg); + showInfo( + `This key's value is not accessible. Update it with: ${COMMAND_NAME} keys update ` + ); process.exit(1); } - break; - } - case "activate": { - console.clear(); - showIterableLogo(packageJson.version); + await keyManager.setActiveKey(keyToActivate.id); + spinner.stop(); + + const meta = await keyManager.getActiveKeyMetadata(); - const idOrName = args.slice(2).join(" ").trim(); - if (!idOrName) { + if (meta) { console.log(); - showError("Missing key name or ID"); + showSuccess(`Switched to "${meta.name}"`); console.log(); - console.log(chalk.white.bold(" USAGE")); - console.log(chalk.white(" iterable-mcp keys activate ")); + displayKeyDetails(meta, { chalk, formatKeyValue, linkColor }); console.log(); - console.log(chalk.white.bold(" EXAMPLE")); - console.log(chalk.cyan(" iterable-mcp keys activate production")); + } else { console.log(); - process.exit(1); + showSuccess(`"${keyToActivate.name}" is now your active API key`); } - try { - spinner.start(`Activating key "${idOrName}"...`); - - // First check if the key value is accessible - try { - await keyManager.getKey(idOrName); - } catch (error) { - spinner.fail("Failed to activate key"); - showError( - error instanceof Error ? error.message : "Failed to access key" - ); - console.log(); - showInfo( - "This key's value is not accessible. Delete and re-add it with: iterable-mcp keys delete && iterable-mcp keys add" - ); - process.exit(1); - } - - await keyManager.setActiveKey(idOrName); - spinner.stop(); - - const meta = await keyManager.getActiveKeyMetadata(); - - if (meta) { - console.log(); - showSuccess(`Switched to "${meta.name}"`); - console.log(); - console.log(formatKeyValue("Name", meta.name, chalk.white.bold)); - console.log(formatKeyValue("ID", meta.id, chalk.gray)); - console.log(formatKeyValue("Endpoint", meta.baseUrl, linkColor())); - const pii = - meta.env?.ITERABLE_USER_PII === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled"); - const writes = - meta.env?.ITERABLE_ENABLE_WRITES === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled"); - const sends = - meta.env?.ITERABLE_ENABLE_SENDS === "true" - ? chalk.green("Enabled") - : chalk.gray("Disabled"); - console.log(formatKeyValue("User PII", pii)); - console.log(formatKeyValue("Writes", writes)); - console.log(formatKeyValue("Sends", sends)); - console.log(); - } else { - console.log(); - showSuccess(`"${idOrName}" is now your active API key`); - } - - showBox( - "Action Required", - [ - chalk.yellow("Restart your AI tools to use this key"), - "", - chalk.gray( - "The MCP server will automatically load the active key when it starts" - ), - ], - { icon: icons.zap, theme: "warning" } - ); - } catch (_error) { - // This only catches "key not found" errors from setActiveKey - // (API key inaccessibility is handled in the inner try-catch above) - spinner.fail("Failed to activate key"); - - const keys = await keyManager.listKeys(); - const suggestions = keys - .filter( - (k) => - k.name.toLowerCase().includes(idOrName.toLowerCase()) || - k.id.toLowerCase().includes(idOrName.toLowerCase()) - ) - .slice(0, 5); - - showError(`Key not found: ${idOrName}`); - - if (suggestions.length) { - console.log(); - console.log(chalk.white.bold(" Did you mean one of these?")); - console.log(); - for (const s of suggestions) { - console.log( - chalk.cyan(` • "${s.name}"`) + chalk.gray(` (ID: ${s.id})`) - ); - } - console.log(); - } - - showInfo("If your key name has spaces, wrap it in quotes"); - console.log( - chalk.gray(' Example: iterable-mcp keys activate "My Prod Key"') - ); - console.log(); - - process.exit(1); - } + showBox( + "Action Required", + [ + chalk.yellow("Restart your AI tools to use this key"), + "", + chalk.gray( + "The MCP server will automatically load the active key when it starts" + ), + ], + { icon: icons.zap, theme: "warning" } + ); break; } @@ -470,58 +784,16 @@ export async function handleKeysCommand(): Promise { console.clear(); showIterableLogo(packageJson.version); - const idOrName = args[2]; - if (!idOrName) { - console.log(); - showError("Missing key name or ID"); - console.log(); - console.log(chalk.white.bold(" USAGE")); - console.log(chalk.white(" iterable-mcp keys delete ")); - console.log(); - showInfo("Use the key ID (not name) for deletion to ensure uniqueness"); - console.log( - chalk.gray(" Run 'iterable-mcp keys list' to see key IDs") - ); - console.log(); - process.exit(1); - } - - // Resolve id or name to an ID for deletion - let resolved = idOrName; - let resolvedMeta: { id: string; name: string } | null = null; - try { - const keys = await keyManager.listKeys(); - const meta = keys.find((k) => k.id === idOrName || k.name === idOrName); - if (!meta) { - // Provide suggestions - const suggestions = keys - .filter( - (k) => - k.name.toLowerCase().includes(idOrName.toLowerCase()) || - k.id.toLowerCase().includes(idOrName.toLowerCase()) - ) - .slice(0, 5); - showError(`Key not found: ${idOrName}`); - if (suggestions.length) { - console.log(); - console.log(chalk.white.bold(" Did you mean one of these?")); - console.log(); - for (const s of suggestions) { - console.log( - chalk.cyan(` • "${s.name}"`) + chalk.gray(` (ID: ${s.id})`) - ); - } - console.log(); - } - showInfo("Run 'iterable-mcp keys list' to view all keys"); - process.exit(1); + const keyToDelete = await findKeyOrExit( + keyManager, + positionalArgs[2], + "delete", + { + chalk, + showError, + showInfo, } - resolved = meta.id; - resolvedMeta = { id: meta.id, name: meta.name }; - } catch (_error) { - showError("Unable to resolve key by name or ID"); - process.exit(1); - } + ); // Confirm deletion (non-interactive in tests) let confirmDelete = false; @@ -530,7 +802,7 @@ export async function handleKeysCommand(): Promise { { type: "confirm", name: "confirmDelete", - message: `Permanently delete key "${resolvedMeta?.name ?? idOrName}" (ID: ${resolved})?`, + message: `Permanently delete key "${keyToDelete.name}" (ID: ${keyToDelete.id})?`, default: false, }, ])); @@ -543,11 +815,11 @@ export async function handleKeysCommand(): Promise { spinner.start("Deleting API key..."); try { - await keyManager.deleteKey(resolved); + await keyManager.deleteKey(keyToDelete.id); spinner.succeed("API key deleted successfully"); console.log(); - console.log(formatKeyValue("ID", resolved, chalk.gray)); + console.log(formatKeyValue("ID", keyToDelete.id, chalk.gray)); console.log(); showSuccess("Key securely removed"); @@ -568,15 +840,13 @@ export async function handleKeysCommand(): Promise { const commandsTable = createTable({ head: ["Command", "Description"], - colWidths: [30, 50], + colWidths: [45, 40], style: "normal", }); + // Apply chalk gray to descriptions for styling commandsTable.push( - ["list", chalk.gray("View all your stored API keys")], - ["add", chalk.gray("Add a new API key (interactive)")], - ["activate ", chalk.gray("Switch to a different key")], - ["delete ", chalk.gray("Remove a key by ID or name")] + ...KEYS_COMMAND_TABLE.map(([cmd, desc]) => [cmd, chalk.gray(desc)]) ); console.log(commandsTable.toString()); @@ -593,34 +863,36 @@ export async function handleKeysCommand(): Promise { ) ); console.log(); - console.log(chalk.cyan(" iterable-mcp keys add")); + console.log(chalk.cyan(` ${COMMAND_NAME} keys add`)); console.log(); console.log(); console.log(chalk.white.bold(" Manage your keys")); console.log(); - console.log(chalk.cyan(" iterable-mcp keys list")); - console.log(chalk.cyan(" iterable-mcp keys activate production")); + console.log(chalk.cyan(` ${COMMAND_NAME} keys list`)); + console.log(chalk.cyan(` ${COMMAND_NAME} keys add`)); + console.log(chalk.cyan(` ${COMMAND_NAME} keys update production`)); + console.log(chalk.cyan(` ${COMMAND_NAME} keys activate production`)); console.log( chalk.cyan( - " iterable-mcp keys delete 3f5d2b07-5b1c-4e86-8f3c-9a1b2c3d4e5f" + ` ${COMMAND_NAME} keys delete 3f5d2b07-5b1c-4e86-8f3c-9a1b2c3d4e5f` ) ); console.log(); - const tips = [ - "API keys are prompted interactively - never stored in shell history", - "Each API key is tightly coupled to its endpoint (US/EU/custom)", - getKeyStorageMessage(), - "Use 'keys list' to see all your keys and their details", - "The active key (● ACTIVE) is what your AI tools will use", - "To update a key: delete the old one and add a new one", - ]; - - showBox("Tips & Best Practices", tips, { - icon: icons.bulb, - theme: "info", - padding: 1, - }); + showBox( + "Important Notes", + [ + "API keys are prompted interactively - never stored in shell history", + "Each API key is tightly coupled to its endpoint (US/EU/custom)", + getKeyStorageMessage(), + "The active key (● ACTIVE) is what your AI tools will use", + ], + { + icon: icons.bulb, + theme: "info", + padding: 1, + } + ); break; } } diff --git a/src/tools/templates.ts b/src/tools/templates.ts index a0dbdf2..e4896bd 100644 --- a/src/tools/templates.ts +++ b/src/tools/templates.ts @@ -36,7 +36,7 @@ interface TemplateTypeConfig { proofMethodName: keyof IterableClient; previewMethodName?: keyof IterableClient; // Only email and inapp support preview // Special handling for parameter differences - getParamsTransform?: (params: any) => any; + getParamsTransform?: (params: z.infer) => any; } const TEMPLATE_TYPES: TemplateTypeConfig[] = [ diff --git a/src/utils/cli-env.ts b/src/utils/cli-env.ts index e538bd1..50107f8 100644 --- a/src/utils/cli-env.ts +++ b/src/utils/cli-env.ts @@ -36,13 +36,14 @@ export async function loadUi(): Promise { return { createTable: () => ({ push: () => {}, toString: () => "" }), formatKeyValue: (_k: string, v: string, _c?: any) => v, - icons: { key: "", globe: "", zap: "" }, + icons: { key: "", globe: "", zap: "", lock: "", bulb: "", fire: "" }, showBox: () => {}, showError: () => {}, showInfo: () => {}, showIterableLogo: () => {}, showSection: () => {}, showSuccess: () => {}, + linkColor: () => (s: string) => s, }; } return await import("./ui.js"); diff --git a/src/utils/command-info.ts b/src/utils/command-info.ts new file mode 100644 index 0000000..ffacd4c --- /dev/null +++ b/src/utils/command-info.ts @@ -0,0 +1,29 @@ +/** + * Shared utilities for command names and help text + */ + +// Executable/package names +export const LOCAL_BINARY_NAME = "iterable-mcp"; +export const NPX_PACKAGE_NAME = "@iterable/mcp"; + +/** + * The command name based on how the CLI was invoked + * (e.g., "iterable-mcp" or "npx @iterable/mcp") + */ +const isNpx = + process.argv[1]?.includes("npx") || process.env.npm_execpath?.includes("npx"); + +export const COMMAND_NAME = isNpx + ? `npx ${NPX_PACKAGE_NAME}` + : LOCAL_BINARY_NAME; + +/** + * Keys command help table rows + */ +export const KEYS_COMMAND_TABLE: Array<[string, string]> = [ + [`${COMMAND_NAME} keys list`, "View all stored API keys"], + [`${COMMAND_NAME} keys add`, "Add a new API key"], + [`${COMMAND_NAME} keys update `, "Update an existing key"], + [`${COMMAND_NAME} keys activate `, "Switch to a different key"], + [`${COMMAND_NAME} keys delete `, "Remove a key by ID or name"], +]; diff --git a/src/utils/endpoint-prompt.ts b/src/utils/endpoint-prompt.ts index 0c6bef4..0dda9ae 100644 --- a/src/utils/endpoint-prompt.ts +++ b/src/utils/endpoint-prompt.ts @@ -1,10 +1,14 @@ import { isHttpsOrLocalhost, isLocalhostHost } from "./url.js"; +const US_ENDPOINT = "https://api.iterable.com"; +const EU_ENDPOINT = "https://api.eu.iterable.com"; + export interface EndpointPromptDeps { inquirer: any; // inquirer module (real or shim) icons: { globe?: string }; chalk: any; // chalk instance showError: (message: string) => void; + defaultBaseUrl?: string; } /** @@ -14,13 +18,25 @@ export interface EndpointPromptDeps { export async function promptForIterableBaseUrl( deps: EndpointPromptDeps ): Promise { - const { inquirer, icons, chalk, showError } = deps; + const { inquirer, icons, chalk, showError, defaultBaseUrl } = deps; // On Windows consoles, flag/globe emojis often render poorly; hide them. const allowFlagEmoji = process.platform !== "win32"; const globePrefix = process.platform !== "win32" ? `${icons.globe || "🌍"} ` : ""; + // Determine default based on existing baseUrl + let defaultChoice = "us"; + if (defaultBaseUrl) { + if (defaultBaseUrl === US_ENDPOINT) { + defaultChoice = "us"; + } else if (defaultBaseUrl === EU_ENDPOINT) { + defaultChoice = "eu"; + } else { + defaultChoice = "custom"; + } + } + const { endpointChoice } = await inquirer.prompt([ { type: "list", @@ -43,12 +59,12 @@ export async function promptForIterableBaseUrl( short: "Custom", }, ], - default: "us", + default: defaultChoice, }, ]); - if (endpointChoice === "us") return "https://api.iterable.com"; - if (endpointChoice === "eu") return "https://api.eu.iterable.com"; + if (endpointChoice === "us") return US_ENDPOINT; + if (endpointChoice === "eu") return EU_ENDPOINT; // Custom endpoint const { customUrl } = await inquirer.prompt([ @@ -56,6 +72,7 @@ export async function promptForIterableBaseUrl( type: "input", name: "customUrl", message: "Enter custom API endpoint URL:", + default: defaultChoice === "custom" ? defaultBaseUrl : undefined, validate: (input: string) => { if (!input) return "URL is required"; try { diff --git a/tests/unit/install-existing-key-skip-api-prompt.test.ts b/tests/unit/install-existing-key-skip-api-prompt.test.ts index be5a5da..b588926 100644 --- a/tests/unit/install-existing-key-skip-api-prompt.test.ts +++ b/tests/unit/install-existing-key-skip-api-prompt.test.ts @@ -12,20 +12,12 @@ describe("setup skips API key prompt when existing key selected", () => { // Mock inquirer to provide a deterministic series of answers const answers = [ - // When no flags: select tools - { selectedTools: ["cursor"] }, // If ITERABLE_API_KEY env var is set, decline to use it (so we test existing key flow) ...(process.env.ITERABLE_API_KEY ? [{ useEnvKey: false }] : []), - // macOS: ask to use existing { useExisting: true }, - // choose stored key { chosenId: "id-1" }, - // offer to activate selected existing key - { activateExisting: false }, - // auto-update prompt + { selectedTools: ["cursor"] }, { enableAutoUpdate: false }, - // summary proceed confirm - { proceed: false }, ]; const promptMock = jest.fn(async (..._args: any[]) => answers.shift()); @@ -56,7 +48,7 @@ describe("setup skips API key prompt when existing key selected", () => { getKey: jest.fn(async () => "a1b2c3d4e5f6789012345678901234ab"), getActiveKeyMetadata: jest.fn(async () => testKey), setActiveKey: jest.fn(async () => {}), - updateKeyEnv: jest.fn(async () => {}), + updateKey: jest.fn(async () => testKey.id), findKeyByValue: jest.fn(async () => null), }), })); diff --git a/tests/unit/key-manager-settings.test.ts b/tests/unit/key-manager-settings.test.ts index b5704aa..f0df52e 100644 --- a/tests/unit/key-manager-settings.test.ts +++ b/tests/unit/key-manager-settings.test.ts @@ -13,23 +13,27 @@ function makeTempDir(): string { const mockExec = async () => "ok"; describe("KeyManager per-key env settings", () => { - it("stores env overrides on addKey and updates with updateKeyEnv", async () => { + it("stores env overrides on addKey and updates with updateKey", async () => { const km = new KeyManager(makeTempDir(), mockExec); await km.initialize(); - const id = await km.addKey( - "prod", - "abcdefabcdefabcdefabcdefabcdefab", - "https://api.iterable.com", - { ITERABLE_USER_PII: "false", ITERABLE_ENABLE_WRITES: "false" } - ); + const apiKey = "abcdefabcdefabcdefabcdefabcdefab"; + const id = await km.addKey("prod", apiKey, "https://api.iterable.com", { + ITERABLE_USER_PII: "false", + ITERABLE_ENABLE_WRITES: "false", + }); let list = await km.listKeys(); const meta = list.find((k) => k.id === id)!; expect(meta.env?.ITERABLE_USER_PII).toBe("false"); expect(meta.env?.ITERABLE_ENABLE_WRITES).toBe("false"); - await km.updateKeyEnv(id, { ITERABLE_ENABLE_WRITES: "true" }); + // Update using updateKey with merged env + await km.updateKey(id, meta.name, apiKey, meta.baseUrl, { + ...meta.env, + ITERABLE_ENABLE_WRITES: "true", + }); + list = await km.listKeys(); const updated = list.find((k) => k.id === id)!; expect(updated.env?.ITERABLE_USER_PII).toBe("false"); diff --git a/tests/unit/keys-cli-add-duplicate-detection.test.ts b/tests/unit/keys-cli-add-duplicate-detection.test.ts new file mode 100644 index 0000000..d617b90 --- /dev/null +++ b/tests/unit/keys-cli-add-duplicate-detection.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +import type { ApiKeyMetadata } from "../../src/key-manager.js"; + +const addKeyMock = + jest.fn< + ( + name: string, + apiKey: string, + baseUrl: string, + env: Record + ) => Promise + >(); +const updateKeyMock = + jest.fn< + ( + id: string, + name: string, + apiKey: string, + baseUrl: string, + env: Record + ) => Promise + >(); +const findKeyByValueMock = + jest.fn<(apiKey: string) => Promise>(); +const listKeysMock = jest.fn<() => Promise>(); + +jest.mock("../../src/key-manager.js", () => { + const mock = { + async initialize() {}, + async listKeys() { + return listKeysMock(); + }, + async addKey(name: string, apiKey: string, baseUrl: string, env: any) { + return addKeyMock(name, apiKey, baseUrl, env); + }, + async updateKey( + id: string, + name: string, + apiKey: string, + baseUrl: string, + env: any + ) { + return updateKeyMock(id, name, apiKey, baseUrl, env); + }, + async findKeyByValue(apiKey: string) { + return findKeyByValueMock(apiKey); + }, + async getKeyMetadata(idOrName: string) { + const keys = await listKeysMock(); + return keys.find((k) => k.id === idOrName || k.name === idOrName) ?? null; + }, + async setActiveKey() {}, + }; + return { + __esModule: true, + getKeyManager: () => mock, + }; +}); + +const answers: any[] = []; +const promptMock = jest.fn(async (..._args: any[]) => { + const answer = answers.shift(); + if (!answer) throw new Error("No more answers in mock queue"); + return answer; +}); + +jest.mock("inquirer", () => ({ + __esModule: true, + default: { prompt: promptMock }, +})); + +// Mock UI module +jest.mock("../../src/utils/ui.js", () => ({ + __esModule: true, + createTable: () => ({ push: () => {}, toString: () => "" }), + formatKeyValue: (_k: string, v: string) => v, + icons: { key: "", globe: "", lock: "", zap: "", bulb: "", fire: "" }, + showBox: () => {}, + showError: () => {}, + showInfo: () => {}, + showIterableLogo: () => {}, + showSection: () => {}, + showSuccess: () => {}, + linkColor: () => (s: string) => s, +})); + +jest.mock("chalk", () => ({ + __esModule: true, + default: new Proxy(() => "", { + get: () => + new Proxy(() => "", { + get: () => () => (s: any) => s, + apply: (_t, _this, args) => args[0], + }), + apply: (_t, _this, args) => args[0], + }), +})); + +jest.mock("ora", () => ({ + __esModule: true, + default: () => ({ + start: () => ({ succeed: () => {}, fail: () => {}, stop: () => {} }), + stop: () => {}, + succeed: () => {}, + fail: () => {}, + }), +})); + +// Mock password prompt +jest.mock("../../src/utils/password-prompt.js", () => ({ + promptForApiKey: jest.fn(async () => "abcdefabcdefabcdefabcdefabcdefab"), +})); + +// Mock endpoint prompt +jest.mock("../../src/utils/endpoint-prompt.js", () => ({ + promptForIterableBaseUrl: jest.fn(async () => "https://api.iterable.com"), +})); + +// Silence output +jest.spyOn(console, "log").mockImplementation(() => {}); +jest.spyOn(console, "clear").mockImplementation(() => {}); + +describe("keys add with duplicate detection", () => { + const existingKey = { + id: "existing-id-1234", + name: "existing-key", + baseUrl: "https://api.iterable.com", + created: new Date().toISOString(), + isActive: false, + env: {}, + }; + + beforeEach(() => { + addKeyMock.mockClear(); + updateKeyMock.mockClear(); + findKeyByValueMock.mockClear(); + listKeysMock.mockClear(); + promptMock.mockClear(); + answers.length = 0; // Clear answers queue + + listKeysMock.mockResolvedValue([existingKey]); + addKeyMock.mockResolvedValue("new-key-id"); + updateKeyMock.mockResolvedValue(existingKey.id); + }); + + it("converts add to update when duplicate API key detected and user confirms", async () => { + // Simulate finding an existing key with the same API key value + findKeyByValueMock.mockResolvedValue(existingKey); + + // After updating, the updated key should be in the list + const updatedKey = { ...existingKey, name: "new-name" }; + listKeysMock.mockResolvedValue([updatedKey]); + updateKeyMock.mockResolvedValue(updatedKey.id); + + answers.push( + { updateExisting: true }, + { name: "new-name" }, + { activateNow: false } + ); + + const originalArgv = process.argv; + process.argv = ["node", "cli", "keys", "add"]; + + const { handleKeysCommand } = await import("../../src/keys-cli.js"); + await handleKeysCommand(); + + // Should NOT call addKey + expect(addKeyMock).not.toHaveBeenCalled(); + + // Should call updateKey instead + expect(updateKeyMock).toHaveBeenCalledWith( + existingKey.id, + "new-name", + "abcdefabcdefabcdefabcdefabcdefab", + "https://api.iterable.com", + expect.any(Object) + ); + + process.argv = originalArgv; + }); + + it("cancels and returns existing key ID when user declines update", async () => { + findKeyByValueMock.mockResolvedValue(existingKey); + + answers.push({ updateExisting: false }, { activateNow: false }); + + const originalArgv = process.argv; + process.argv = ["node", "cli", "keys", "add"]; + + const { handleKeysCommand } = await import("../../src/keys-cli.js"); + await handleKeysCommand(); + + // Should NOT call addKey or updateKey + expect(addKeyMock).not.toHaveBeenCalled(); + expect(updateKeyMock).not.toHaveBeenCalled(); + + process.argv = originalArgv; + }); + + it("proceeds with add when no duplicate API key found", async () => { + // No existing key found with this API key value + findKeyByValueMock.mockResolvedValue(null); + + // After adding, the new key should be in the list + const newKey = { + id: "new-key-id", + name: "brand-new-key", + baseUrl: "https://api.iterable.com", + created: new Date().toISOString(), + isActive: false, + env: {}, + }; + listKeysMock.mockResolvedValue([existingKey, newKey]); + addKeyMock.mockResolvedValue(newKey.id); + + answers.push({ name: "brand-new-key" }, { activateNow: false }); + + const originalArgv = process.argv; + process.argv = ["node", "cli", "keys", "add"]; + + const { handleKeysCommand } = await import("../../src/keys-cli.js"); + await handleKeysCommand(); + + // Should call addKey + expect(addKeyMock).toHaveBeenCalledWith( + "brand-new-key", + "abcdefabcdefabcdefabcdefabcdefab", + "https://api.iterable.com", + expect.any(Object) + ); + + // Should NOT call updateKey + expect(updateKeyMock).not.toHaveBeenCalled(); + + process.argv = originalArgv; + }); +}); diff --git a/tests/unit/keys-cli-find-key-or-exit.test.ts b/tests/unit/keys-cli-find-key-or-exit.test.ts new file mode 100644 index 0000000..39fa380 --- /dev/null +++ b/tests/unit/keys-cli-find-key-or-exit.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +import type { ApiKeyMetadata } from "../../src/key-manager.js"; + +const listKeysMock = jest.fn<() => Promise>(); + +jest.mock("../../src/key-manager.js", () => { + const mock = { + async initialize() {}, + async listKeys() { + return listKeysMock(); + }, + }; + return { + __esModule: true, + getKeyManager: () => mock, + }; +}); + +jest.mock("inquirer", () => ({ + __esModule: true, + default: { prompt: jest.fn() }, +})); + +// Mock UI module +jest.mock("../../src/utils/ui.js", () => ({ + __esModule: true, + createTable: () => ({ push: () => {}, toString: () => "" }), + formatKeyValue: (_k: string, v: string) => v, + icons: { key: "", globe: "", lock: "", zap: "", bulb: "", fire: "" }, + showBox: () => {}, + showError: () => {}, + showInfo: () => {}, + showIterableLogo: () => {}, + showSection: () => {}, + showSuccess: () => {}, + linkColor: () => (s: string) => s, +})); + +jest.mock("chalk", () => ({ + __esModule: true, + default: new Proxy(() => "", { + get: () => + new Proxy(() => "", { + get: () => () => (s: any) => s, + apply: (_t, _this, args) => args[0], + }), + apply: (_t, _this, args) => args[0], + }), +})); + +// Silence output +const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {}); +jest.spyOn(console, "clear").mockImplementation(() => {}); + +// Mock process.exit to prevent test from actually exiting, but throw to stop execution +const mockExit = jest.spyOn(process, "exit").mockImplementation((( + code: any +) => { + throw new Error(`process.exit(${code})`); +}) as any); + +describe("findKeyOrExit helper", () => { + const testKeys = [ + { + id: "11111111-2222-3333-4444-555555555555", + name: "production", + baseUrl: "https://api.iterable.com", + created: new Date().toISOString(), + isActive: true, + }, + { + id: "22222222-3333-4444-5555-666666666666", + name: "staging", + baseUrl: "https://api.iterable.com", + created: new Date().toISOString(), + isActive: false, + }, + { + id: "33333333-4444-5555-6666-777777777777", + name: "My Production Key", + baseUrl: "https://api.iterable.com", + created: new Date().toISOString(), + isActive: false, + }, + ]; + + beforeEach(() => { + listKeysMock.mockClear(); + consoleLogSpy.mockClear(); + mockExit.mockClear(); + listKeysMock.mockResolvedValue(testKeys); + }); + + it("exits with error when key name/id is not provided", async () => { + const originalArgv = process.argv; + process.argv = ["node", "cli", "keys", "activate"]; // missing key argument + + const { handleKeysCommand } = await import("../../src/keys-cli.js"); + + try { + await handleKeysCommand(); + } catch (error: any) { + expect(error.message).toBe("process.exit(1)"); + } + + // The main assertion: process.exit should be called with error code + expect(mockExit).toHaveBeenCalledWith(1); + + process.argv = originalArgv; + }); + + it("exits with error when key not found", async () => { + const originalArgv = process.argv; + process.argv = ["node", "cli", "keys", "activate", "nonexistent"]; + + const { handleKeysCommand } = await import("../../src/keys-cli.js"); + + try { + await handleKeysCommand(); + } catch (error: any) { + expect(error.message).toBe("process.exit(1)"); + } + + // The main assertion: process.exit should be called when key not found + expect(mockExit).toHaveBeenCalledWith(1); + + process.argv = originalArgv; + }); + + it("exits with error for partial match (not exact)", async () => { + const originalArgv = process.argv; + process.argv = ["node", "cli", "keys", "activate", "prod"]; // partial match + + const { handleKeysCommand } = await import("../../src/keys-cli.js"); + + try { + await handleKeysCommand(); + } catch (error: any) { + expect(error.message).toBe("process.exit(1)"); + } + + // Should exit even with partial matches (findKeyOrExit requires exact match) + expect(mockExit).toHaveBeenCalledWith(1); + + process.argv = originalArgv; + }); +}); diff --git a/tests/unit/keys-cli-update.test.ts b/tests/unit/keys-cli-update.test.ts new file mode 100644 index 0000000..c0989e2 --- /dev/null +++ b/tests/unit/keys-cli-update.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +import type { ApiKeyMetadata } from "../../src/key-manager.js"; + +const updateKeyMock = + jest.fn< + ( + id: string, + name: string, + apiKey: string, + baseUrl: string, + env: Record + ) => Promise + >(); +const getKeyMock = jest.fn<(id: string) => Promise>(); +const listKeysMock = jest.fn<() => Promise>(); + +jest.mock("../../src/key-manager.js", () => { + const mock = { + async initialize() {}, + async listKeys() { + return listKeysMock(); + }, + async updateKey( + id: string, + name: string, + apiKey: string, + baseUrl: string, + env: any + ) { + return updateKeyMock(id, name, apiKey, baseUrl, env); + }, + async getKey(id: string) { + return getKeyMock(id); + }, + async getKeyMetadata(idOrName: string) { + const keys = await listKeysMock(); + return keys.find((k) => k.id === idOrName || k.name === idOrName) ?? null; + }, + }; + return { + __esModule: true, + getKeyManager: () => mock, + }; +}); + +const answers: any[] = []; +const promptMock = jest.fn(async (..._args: any[]) => { + const answer = answers.shift(); + if (!answer) throw new Error("No more answers in mock queue"); + return answer; +}); + +jest.mock("inquirer", () => ({ + __esModule: true, + default: { prompt: promptMock }, +})); + +// Mock UI module +jest.mock("../../src/utils/ui.js", () => ({ + __esModule: true, + createTable: () => ({ push: () => {}, toString: () => "" }), + formatKeyValue: (_k: string, v: string) => v, + icons: { key: "", globe: "", lock: "", zap: "", bulb: "", fire: "" }, + showBox: () => {}, + showError: () => {}, + showInfo: () => {}, + showIterableLogo: () => {}, + showSection: () => {}, + showSuccess: () => {}, + linkColor: () => (s: string) => s, +})); + +jest.mock("chalk", () => ({ + __esModule: true, + default: new Proxy(() => "", { + get: () => + new Proxy(() => "", { + get: () => () => (s: any) => s, + apply: (_t, _this, args) => args[0], + }), + apply: (_t, _this, args) => args[0], + }), +})); + +jest.mock("ora", () => ({ + __esModule: true, + default: () => ({ + start: () => ({ succeed: () => {}, fail: () => {}, stop: () => {} }), + stop: () => {}, + succeed: () => {}, + fail: () => {}, + }), +})); + +// Mock password prompt +jest.mock("../../src/utils/password-prompt.js", () => ({ + promptForApiKey: jest.fn(async () => "abcdefabcdefabcdefabcdefabcdefab"), +})); + +// Mock endpoint prompt +jest.mock("../../src/utils/endpoint-prompt.js", () => ({ + promptForIterableBaseUrl: jest.fn(async () => "https://api.iterable.com"), +})); + +// Silence output +jest.spyOn(console, "log").mockImplementation(() => {}); +jest.spyOn(console, "clear").mockImplementation(() => {}); + +describe("keys update command", () => { + const testKey = { + id: "11111111-2222-3333-4444-555555555555", + name: "production", + baseUrl: "https://api.iterable.com", + created: new Date().toISOString(), + isActive: true, + env: { + ITERABLE_USER_PII: "false", + ITERABLE_ENABLE_WRITES: "true", + ITERABLE_ENABLE_SENDS: "false", + }, + }; + + beforeEach(() => { + updateKeyMock.mockClear(); + getKeyMock.mockClear(); + listKeysMock.mockClear(); + promptMock.mockClear(); + answers.length = 0; // Clear answers queue + + listKeysMock.mockResolvedValue([testKey]); + updateKeyMock.mockResolvedValue(testKey.id); + getKeyMock.mockResolvedValue("abcdefabcdefabcdefabcdefabcdefab"); + }); + + it("updates existing key without changing API key value", async () => { + answers.push( + { updateApiKey: false }, + { name: "production" }, + { activateNow: false } + ); + + const originalArgv = process.argv; + process.argv = ["node", "cli", "keys", "update", "production"]; + + const { handleKeysCommand } = await import("../../src/keys-cli.js"); + await handleKeysCommand(); + + expect(updateKeyMock).toHaveBeenCalledWith( + testKey.id, + "production", + "abcdefabcdefabcdefabcdefabcdefab", // existing API key + "https://api.iterable.com", + expect.objectContaining({ + ITERABLE_USER_PII: "false", + ITERABLE_ENABLE_WRITES: "true", + ITERABLE_ENABLE_SENDS: "false", + }) + ); + + process.argv = originalArgv; + }); + + it("updates key with new API key value", async () => { + const { promptForApiKey } = await import( + "../../src/utils/password-prompt.js" + ); + (promptForApiKey as any).mockResolvedValueOnce( + "fedcbafedcbafedcbafedcbafedcbafe" + ); + + answers.push( + { updateApiKey: true }, + { name: "production-updated" }, + { activateNow: false } + ); + + const originalArgv = process.argv; + process.argv = ["node", "cli", "keys", "update", "production"]; + + const { handleKeysCommand } = await import("../../src/keys-cli.js"); + await handleKeysCommand(); + + expect(updateKeyMock).toHaveBeenCalledWith( + testKey.id, + "production-updated", + "fedcbafedcbafedcbafedcbafedcbafe", // new API key + "https://api.iterable.com", + expect.any(Object) + ); + + process.argv = originalArgv; + }); + + it("updates permissions when user selects advanced", async () => { + answers.push( + { updateApiKey: false }, + { name: "production" }, + { permFlags: ["pii", "writes", "sends"] }, + { activateNow: false } + ); + + const originalArgv = process.argv; + process.argv = [ + "node", + "cli", + "keys", + "update", + "production", + "--advanced", + ]; + + const { handleKeysCommand } = await import("../../src/keys-cli.js"); + await handleKeysCommand(); + + expect(updateKeyMock).toHaveBeenCalledWith( + testKey.id, + "production", + expect.any(String), + "https://api.iterable.com", + expect.objectContaining({ + ITERABLE_USER_PII: "true", + ITERABLE_ENABLE_WRITES: "true", + ITERABLE_ENABLE_SENDS: "true", + }) + ); + + process.argv = originalArgv; + }); +});