diff --git a/src/commands/__tests__/deploy.test.ts b/src/commands/__tests__/deploy.test.ts index c0a8696..0d710bf 100644 --- a/src/commands/__tests__/deploy.test.ts +++ b/src/commands/__tests__/deploy.test.ts @@ -126,6 +126,17 @@ vi.mock("node:fs", () => ({ return stream }), existsSync: vi.fn().mockReturnValue(true), + writeFileSync: vi.fn(), +})) + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(() => ({ + unref: vi.fn(), + })), +})) + +vi.mock("../../utils/output", () => ({ + isJsonMode: vi.fn().mockReturnValue(false), })) import { NotFoundError, PermissionDeniedError, Smithery } from "@smithery/api" @@ -592,6 +603,65 @@ describe("deploy command", () => { expect(buildBundle).not.toHaveBeenCalled() }) + test("non-TTY mode: spawns background watcher and outputs JSON", async () => { + const { isJsonMode } = await import("../../utils/output") + const { spawn } = await import("node:child_process") + const { writeFileSync } = await import("node:fs") + + vi.mocked(isJsonMode).mockReturnValue(true) + + const consoleSpy = vi.spyOn(console, "log") + + await deploy({ name: "myorg/myserver" }) + + // Should write empty log file + expect(writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("smithery-deploy-test-deployment-id.log"), + "", + ) + + // Should spawn background watcher + expect(spawn).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining([ + "_watch-deploy", + "test-deployment-id", + "myorg/myserver", + ]), + expect.objectContaining({ + detached: true, + stdio: "ignore", + }), + ) + + // Should output JSON with deployment info + const jsonOutput = consoleSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(call[0]) + return parsed.deploymentId === "test-deployment-id" + } catch { + return false + } + }) + expect(jsonOutput).toBeDefined() + const parsed = JSON.parse(jsonOutput![0]) + expect(parsed).toMatchObject({ + deploymentId: "test-deployment-id", + qualifiedName: "myorg/myserver", + status: "PENDING", + logFile: expect.stringContaining( + "smithery-deploy-test-deployment-id.log", + ), + statusUrl: "https://smithery.ai/servers/myorg/myserver/releases", + }) + + // Should NOT have polled deployment status + expect(mockRegistry.servers.releases.get).not.toHaveBeenCalled() + + // Restore + vi.mocked(isJsonMode).mockReturnValue(false) + }) + test("404 error: auto-creates server and retries deploy", async () => { const error = new NotFoundError( 404, diff --git a/src/commands/mcp/deploy.ts b/src/commands/mcp/deploy.ts index 56f6ace..7367a17 100644 --- a/src/commands/mcp/deploy.ts +++ b/src/commands/mcp/deploy.ts @@ -1,4 +1,7 @@ -import { createReadStream } from "node:fs" +import { spawn } from "node:child_process" +import { createReadStream, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" import { NotFoundError, type Smithery } from "@smithery/api" import type { DeployPayload, @@ -6,7 +9,6 @@ import type { ReleaseGetResponse, } from "@smithery/api/resources/servers/releases" import pc from "picocolors" -import yoctoSpinner from "yocto-spinner" import { buildBundle, loadBuildManifest } from "../../lib/bundle/index.js" import { fatal } from "../../lib/cli-error" import { loadProjectConfig } from "../../lib/config-loader.js" @@ -14,7 +16,9 @@ import { resolveNamespace } from "../../lib/namespace.js" import { createSmitheryClientSync } from "../../lib/smithery-client" import { parseConfigSchema } from "../../utils/cli-utils.js" import { promptForServerNameInput } from "../../utils/command-prompts.js" +import { isJsonMode } from "../../utils/output.js" import { ensureApiKey } from "../../utils/runtime.js" +import { createSpinner } from "../../utils/spinner" interface DeployOptions { entryFile?: string @@ -211,10 +215,9 @@ async function deployToServer( sourcemapFile?: ReturnType, bundleFile?: ReturnType, ) { - const uploadSpinner = yoctoSpinner({ - text: "Uploading release...", + const uploadSpinner = createSpinner("Uploading release...", { color: "yellow", - }).start() + }) const deployParams: ReleaseDeployParams = { payload: JSON.stringify(payload), @@ -233,6 +236,40 @@ async function deployToServer( uploadSpinner.stop() console.log(pc.dim(`✓ Release ${result.deploymentId} accepted`)) + // Non-TTY / --json: spawn a background watcher to a tmp file and return immediately + if (isJsonMode()) { + const logFile = join(tmpdir(), `smithery-deploy-${result.deploymentId}.log`) + writeFileSync(logFile, "") // create empty log file + + const child = spawn( + process.execPath, + [ + process.argv[1], + "_watch-deploy", + result.deploymentId, + qualifiedName, + logFile, + ], + { + detached: true, + stdio: "ignore", + env: process.env, + }, + ) + child.unref() + + console.log( + JSON.stringify({ + deploymentId: result.deploymentId, + qualifiedName, + status: "PENDING", + logFile, + statusUrl: `https://smithery.ai/servers/${qualifiedName}/releases`, + }), + ) + return + } + console.log(pc.dim("> Waiting for completion...")) console.log( pc.dim( @@ -326,6 +363,105 @@ async function deployWithAutoCreate( } } +/** + * Background watcher: polls deployment status and writes logs to a file. + * Invoked via hidden `_watch-deploy` command in a detached process. + * + * Safety: exits after 10 minutes or 3 consecutive poll errors to prevent + * orphaned processes from running indefinitely. + */ +export async function watchDeploy( + deploymentId: string, + qualifiedName: string, + logFile: string, +) { + const { createWriteStream } = await import("node:fs") + const apiKey = await ensureApiKey() + const registry = createSmitheryClientSync(apiKey) + let lastLoggedIndex = 0 + let consecutiveErrors = 0 + + const MAX_DURATION_MS = 10 * 60 * 1000 // 10 minutes + const MAX_CONSECUTIVE_ERRORS = 3 + const startTime = Date.now() + + const stream = createWriteStream(logFile, { flags: "a" }) + const log = (line: string) => { + stream.write(`${line}\n`) + } + + while (true) { + if (Date.now() - startTime > MAX_DURATION_MS) { + log(`\n⚠ Watcher timed out after 10 minutes. Check status at:`) + log(`https://smithery.ai/servers/${qualifiedName}/releases`) + stream.end() + process.exit(0) + } + + let data: ReleaseGetResponse + try { + data = await registry.servers.releases.get(deploymentId, { + qualifiedName, + }) + consecutiveErrors = 0 + } catch (error) { + consecutiveErrors++ + log( + `[error] Failed to poll deployment (attempt ${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}): ${error}`, + ) + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + log(`\n✗ Giving up after ${MAX_CONSECUTIVE_ERRORS} consecutive errors.`) + stream.end() + process.exit(1) + } + await sleep(2000) + continue + } + + if (data.logs && data.logs.length > lastLoggedIndex) { + for (let i = lastLoggedIndex; i < data.logs.length; i++) { + const entry = data.logs[i] + if (entry.message === "auth_required") continue + log(`[${entry.stage}] ${entry.message}`) + } + lastLoggedIndex = data.logs.length + } + + if (data.status === "SUCCESS") { + log(`\n✓ Release successful!`) + log(`Release ID: ${deploymentId}`) + log(`MCP URL: ${data.mcpUrl}`) + log(`Server Page: https://smithery.ai/servers/${qualifiedName}`) + stream.end() + process.exit(0) + } + + if (data.status === "AUTH_REQUIRED") { + log(`\n⚠ OAuth authorization required.`) + log( + `Authorize at: https://smithery.ai/servers/${qualifiedName}/releases/`, + ) + stream.end() + process.exit(0) + } + + if ( + ["FAILURE", "FAILURE_SCAN", "INTERNAL_ERROR", "CANCELLED"].includes( + data.status, + ) + ) { + const errorLog = data.logs?.find( + (l: ReleaseGetResponse.Log) => l.level === "error", + ) + log(`\n✗ Release failed: ${errorLog?.message || "Release failed"}`) + stream.end() + process.exit(1) + } + + await sleep(2000) + } +} + async function pollDeployment( registry: Smithery, qualifiedName: string, diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 467035f..17f9189 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -1,6 +1,5 @@ import "../../utils/suppress-punycode-warning" import pc from "picocolors" -import yoctoSpinner from "yocto-spinner" import type { ValidClient } from "../../config/clients" import { getClientConfiguration } from "../../config/clients" import { fatal } from "../../lib/cli-error" @@ -19,11 +18,13 @@ import { parseQualifiedName } from "../../utils/cli-utils" import { promptForRestart, showPostInstallHint } from "../../utils/client" import { resolveTransport } from "../../utils/install/transport" import { resolveUserConfig } from "../../utils/install/user-config" +import { isJsonMode } from "../../utils/output" import { checkAndNotifyRemoteServer, ensureBunInstalled, ensureUVInstalled, } from "../../utils/runtime" +import { createSpinner } from "../../utils/spinner" /** * Installs and configures a Smithery server for a specified client. @@ -49,14 +50,13 @@ export async function installServer( const clientConfig = getClientConfiguration(client) /* resolve server */ - const spinner = yoctoSpinner({ - text: `Resolving ${qualifiedName}...`, - }).start() + const json = isJsonMode() + const spinner = createSpinner(`Resolving ${qualifiedName}...`) try { const { server, connection } = await resolveServer( parseQualifiedName(qualifiedName), ) - spinner.success(pc.dim(`Successfully resolved ${pc.cyan(qualifiedName)}`)) + spinner?.success(pc.dim(`Successfully resolved ${pc.cyan(qualifiedName)}`)) // Resolve transport type (single source of truth) const transport = resolveTransport(connection, client) @@ -68,7 +68,9 @@ export async function installServer( } // Notify user if remote server - checkAndNotifyRemoteServer(server) + if (!json) { + checkAndNotifyRemoteServer(server) + } /* resolve server configuration - only for STDIO since HTTP uses OAuth (handled by client or mcp-remote) */ let finalConfig: ServerConfig = {} @@ -116,15 +118,27 @@ export async function installServer( writeConfig(config, client) } - console.log() - console.log( - pc.green(`✓ ${qualifiedName} successfully installed for ${client}`), - ) - showPostInstallHint(client) - await promptForRestart(client) + if (json) { + console.log( + JSON.stringify({ + success: true, + qualifiedName, + client, + transport: transport.type, + hint: `Restart ${client} to apply changes.`, + }), + ) + } else { + console.log() + console.log( + pc.green(`✓ ${qualifiedName} successfully installed for ${client}`), + ) + showPostInstallHint(client) + await promptForRestart(client) + } process.exit(0) } catch (error) { - spinner.error(`Failed to install ${qualifiedName}`) + spinner?.error(`Failed to install ${qualifiedName}`) verbose( `Installation error: ${error instanceof Error ? error.stack : JSON.stringify(error)}`, ) diff --git a/src/commands/mcp/uninstall.ts b/src/commands/mcp/uninstall.ts index 09e4f34..a9af38f 100644 --- a/src/commands/mcp/uninstall.ts +++ b/src/commands/mcp/uninstall.ts @@ -6,19 +6,30 @@ import { fatal } from "../../lib/cli-error" import { readConfig, writeConfig } from "../../lib/client-config-io" import { deleteConfig } from "../../lib/keychain.js" import { promptForRestart } from "../../utils/client" +import { isJsonMode } from "../../utils/output" /* uninstalls server for given client */ export async function uninstallServer( qualifiedName: string, client: ValidClient, ): Promise { + const json = isJsonMode() try { /* check if client is command-type */ const clientConfig = getClientConfiguration(client) if (clientConfig.install.method === "command") { - console.log( - pc.yellow(`Uninstallation is currently not supported for ${client}`), - ) + if (json) { + console.log( + JSON.stringify({ + success: false, + error: `Uninstallation is currently not supported for ${client}`, + }), + ) + } else { + console.log( + pc.yellow(`Uninstallation is currently not supported for ${client}`), + ) + } return } @@ -27,7 +38,16 @@ export async function uninstallServer( /* check if server exists in config */ if (!config.mcpServers[qualifiedName]) { - console.log(pc.red(`${qualifiedName} is not installed for ${client}`)) + if (json) { + console.log( + JSON.stringify({ + success: false, + error: `${qualifiedName} is not installed for ${client}`, + }), + ) + } else { + console.log(pc.red(`${qualifiedName} is not installed for ${client}`)) + } return } @@ -38,11 +58,21 @@ export async function uninstallServer( /* remove server config from keychain */ await deleteConfig(qualifiedName) - console.log( - pc.green(`✓ ${qualifiedName} successfully uninstalled from ${client}`), - ) - - await promptForRestart(client) + if (json) { + console.log( + JSON.stringify({ + success: true, + qualifiedName, + client, + hint: `Restart ${client} to apply changes.`, + }), + ) + } else { + console.log( + pc.green(`✓ ${qualifiedName} successfully uninstalled from ${client}`), + ) + await promptForRestart(client) + } } catch (error) { fatal("Failed to uninstall server", error) } diff --git a/src/index.ts b/src/index.ts index e92a845..687302c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,12 +102,13 @@ async function handleSearch(term: string | undefined, options: CliOptions) { const searchTerm = term ?? "" const json = isJsonMode() - if (json && !searchTerm) { - fatal("Search term is required when using --json") + if (json && !searchTerm && !options.namespace) { + fatal("Search term or --namespace is required when using --json") } const results = await searchServers(searchTerm, apiKey, { verified: options.verified, + namespace: options.namespace, pageSize: parseInt(options.limit ?? "10", 10), page: parseInt(options.page ?? "1", 10), }) @@ -425,6 +426,7 @@ function withSearchOptions(cmd: InstanceType) { return cmd .option("-i, --interactive", "Interactive search mode") .option("--verified", "Only show verified servers") + .option("--namespace ", "Filter by namespace") .option("--limit ", "Max results per page", "10") .option("--page ", "Page number", "1") } @@ -1340,6 +1342,22 @@ program.hook("preAction", async (_thisCommand, actionCommand) => { }) }) +// ═══════════════════════════════════════════════════════════════════════════════ +// Hidden commands (internal use only) +// ═══════════════════════════════════════════════════════════════════════════════ + +program + .command("_watch-deploy", { hidden: true }) + .argument("") + .argument("") + .argument("") + .action( + async (deploymentId: string, qualifiedName: string, logFile: string) => { + const { watchDeploy } = await import("./commands/mcp/deploy") + await watchDeploy(deploymentId, qualifiedName, logFile) + }, + ) + // ═══════════════════════════════════════════════════════════════════════════════ // Entry point // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 2c55c88..4efee12 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -129,7 +129,12 @@ export const resolveServer = async ( export const searchServers = async ( searchTerm: string, apiKey?: string, - filters?: { verified?: boolean; pageSize?: number; page?: number }, + filters?: { + verified?: boolean + namespace?: string + pageSize?: number + page?: number + }, ): Promise< Array<{ qualifiedName: string @@ -149,6 +154,7 @@ export const searchServers = async ( pageSize: filters?.pageSize ?? 10, page: filters?.page, ...(filters?.verified && { verified: "true" }), + ...(filters?.namespace && { namespace: filters.namespace }), }) const servers = (response.servers || []).map((server) => ({ diff --git a/src/utils/client.ts b/src/utils/client.ts index abce62b..3778d07 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -78,6 +78,14 @@ export async function promptForRestart(client?: string): Promise { return false } + // Non-TTY: skip interactive prompt to avoid hanging in CI/agent environments + if (!process.stdin.isTTY) { + console.log( + `The ${client} app is running. Restart it manually to apply changes.`, + ) + return false + } + const { shouldRestart } = await inquirer.prompt<{ shouldRestart: boolean }>([ { type: "confirm", diff --git a/src/utils/install/user-config.ts b/src/utils/install/user-config.ts index 0eaa4c1..9e154a3 100644 --- a/src/utils/install/user-config.ts +++ b/src/utils/install/user-config.ts @@ -4,7 +4,6 @@ type Connection = | ServerGetResponse.StdioConnection | ServerGetResponse.HTTPConnection -import type yoctoSpinner from "yocto-spinner" import { getConfig } from "../../lib/keychain" import { verbose } from "../../lib/logger" import { @@ -13,10 +12,10 @@ import { } from "../../lib/mcpb" import type { JSONSchema, ServerConfig } from "../../types/registry" import { promptForExistingConfig } from "../command-prompts" +// Re-export Spinner type from shared utility +import type { Spinner as OraSpinner } from "../spinner" import { collectConfigValues } from "./prompt-user-config.js" - -// Type for yocto-spinner instance -export type OraSpinner = ReturnType +export type { OraSpinner } /** * Converts a value to the specified type diff --git a/src/utils/spinner.ts b/src/utils/spinner.ts new file mode 100644 index 0000000..963901f --- /dev/null +++ b/src/utils/spinner.ts @@ -0,0 +1,39 @@ +import yoctoSpinner from "yocto-spinner" +import { isJsonMode } from "./output" + +/** + * Minimal spinner interface used throughout the CLI. + * Both real yocto-spinner instances and the no-op implement this. + */ +export interface Spinner { + start(): Spinner + stop(): Spinner + success(text?: string): Spinner + error(text?: string): Spinner +} + +const noopSpinner: Spinner = { + start: () => noopSpinner, + stop: () => noopSpinner, + success: () => noopSpinner, + error: () => noopSpinner, +} + +/** + * Create a spinner that is suppressed in JSON / non-TTY mode. + * Returns a fluent no-op when output should be machine-readable, + * avoiding timers, intervals, and noisy stderr writes. + */ +export function createSpinner( + text: string, + options?: { color?: string }, +): Spinner { + if (isJsonMode()) { + return noopSpinner + } + + return yoctoSpinner({ + text, + ...(options?.color ? { color: options.color } : {}), + } as Parameters[0]).start() +}