Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/commands/__tests__/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
146 changes: 141 additions & 5 deletions src/commands/mcp/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
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,
ReleaseDeployParams,
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"
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
Expand Down Expand Up @@ -211,10 +215,9 @@ async function deployToServer(
sourcemapFile?: ReturnType<typeof createReadStream>,
bundleFile?: ReturnType<typeof createReadStream>,
) {
const uploadSpinner = yoctoSpinner({
text: "Uploading release...",
const uploadSpinner = createSpinner("Uploading release...", {
color: "yellow",
}).start()
})

const deployParams: ReleaseDeployParams = {
payload: JSON.stringify(payload),
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 27 additions & 13 deletions src/commands/mcp/install.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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)}`,
)
Expand Down
Loading
Loading