deploy-mcp
Version:
Universal deployment tracker for AI assistants
1,531 lines (1,520 loc) • 66.4 kB
JavaScript
// src/core/tools.ts
import { z } from "zod";
var SUPPORTED_PLATFORMS = ["vercel", "netlify"];
var checkDeploymentStatusSchema = z.object({
platform: z.enum(SUPPORTED_PLATFORMS).describe("The deployment platform"),
project: z.string().describe("The project name or ID"),
token: z.string().optional().describe("API token for authentication (optional if set in environment)")
});
var watchDeploymentSchema = z.object({
platform: z.enum(SUPPORTED_PLATFORMS).describe("The deployment platform"),
project: z.string().describe("The project name or ID"),
deploymentId: z.string().optional().describe("Specific deployment ID to watch (optional, defaults to latest)"),
token: z.string().optional().describe("API token for authentication (optional if set in environment)")
});
var compareDeploymentsSchema = z.object({
platform: z.enum(SUPPORTED_PLATFORMS).describe("The deployment platform"),
project: z.string().describe("The project name or ID"),
mode: z.enum([
"last_vs_previous",
// Default: current vs previous
"current_vs_success",
// Compare to last successful deploy
"current_vs_production",
// Compare to what's in production
"between_dates",
// Compare deployments from specific dates
"by_ids"
// Compare two specific deployment IDs
]).default("last_vs_previous").describe("Comparison mode to use"),
deploymentA: z.string().optional().describe("First deployment ID (for by_ids mode)"),
deploymentB: z.string().optional().describe("Second deployment ID (for by_ids mode)"),
dateFrom: z.string().optional().describe("Start date (for between_dates mode, ISO format)"),
dateTo: z.string().optional().describe("End date (for between_dates mode, ISO format)"),
token: z.string().optional().describe("API token for authentication (optional if set in environment)")
});
var getDeploymentLogsSchema = z.object({
platform: z.enum(SUPPORTED_PLATFORMS).describe("The deployment platform"),
deploymentId: z.string().describe("The deployment ID or 'latest' for most recent"),
project: z.string().optional().describe(
"Project/site name (required when using 'latest' as deploymentId)"
),
filter: z.enum(["error", "warning", "all"]).default("error").describe("Filter logs by type (default: error)"),
token: z.string().optional().describe("API token for authentication (optional if set in environment)")
});
var tools = [
{
name: "check_deployment_status",
description: "Check the latest deployment status for a project on a platform",
inputSchema: {
type: "object",
properties: {
platform: {
type: "string",
enum: ["vercel", "netlify"],
description: "The deployment platform"
},
project: {
type: "string",
description: "The project name or ID"
},
token: {
type: "string",
description: "API token for authentication (optional if set in environment)"
}
},
required: ["platform", "project"]
}
},
{
name: "watch_deployment",
description: "Stream real-time deployment progress with detailed status updates and error information",
inputSchema: {
type: "object",
properties: {
platform: {
type: "string",
enum: ["vercel", "netlify"],
description: "The deployment platform"
},
project: {
type: "string",
description: "The project name or ID"
},
deploymentId: {
type: "string",
description: "Specific deployment ID to watch (optional, defaults to latest)"
},
token: {
type: "string",
description: "API token for authentication (optional if set in environment)"
}
},
required: ["platform", "project"]
}
},
{
name: "compare_deployments",
description: "Compare deployments using smart comparison modes to identify changes, performance differences, and potential issues",
inputSchema: {
type: "object",
properties: {
platform: {
type: "string",
enum: ["vercel", "netlify"],
description: "The deployment platform"
},
project: {
type: "string",
description: "The project name or ID"
},
mode: {
type: "string",
enum: [
"last_vs_previous",
"current_vs_success",
"current_vs_production",
"between_dates",
"by_ids"
],
default: "last_vs_previous",
description: "Comparison mode: last_vs_previous (default), current_vs_success, current_vs_production, between_dates, or by_ids"
},
deploymentA: {
type: "string",
description: "First deployment ID (required for by_ids mode)"
},
deploymentB: {
type: "string",
description: "Second deployment ID (required for by_ids mode)"
},
dateFrom: {
type: "string",
description: "Start date in ISO format (required for between_dates mode)"
},
dateTo: {
type: "string",
description: "End date in ISO format (required for between_dates mode)"
},
token: {
type: "string",
description: "API token for authentication (optional if set in environment)"
}
},
required: ["platform", "project"]
}
},
{
name: "get_deployment_logs",
description: "Fetch detailed logs for a specific deployment, useful for debugging failed deployments",
inputSchema: {
type: "object",
properties: {
platform: {
type: "string",
enum: ["vercel", "netlify"],
description: "The deployment platform"
},
deploymentId: {
type: "string",
description: "The deployment ID or 'latest' for most recent"
},
project: {
type: "string",
description: "Project/site name (required when using 'latest' as deploymentId)"
},
filter: {
type: "string",
enum: ["error", "warning", "all"],
default: "error",
description: "Filter logs by type (default: error)"
},
token: {
type: "string",
description: "API token for authentication (optional if set in environment)"
}
},
required: ["platform", "deploymentId"]
}
}
];
// src/adapters/base/adapter.ts
var BaseAdapter = class {
formatTimestamp(date) {
return new Date(date).toISOString();
}
calculateDuration(start, end) {
const startTime = new Date(start).getTime();
const endTime = end ? new Date(end).getTime() : Date.now();
return Math.floor((endTime - startTime) / 1e3);
}
};
// src/adapters/base/types.ts
var AdapterException = class extends Error {
constructor(type, message, originalError) {
super(message);
this.type = type;
this.originalError = originalError;
this.name = "AdapterException";
}
};
// src/core/constants.ts
var MAX_DEPLOYMENT_WATCH_ATTEMPTS = 120;
var BUILD_TIME_SECONDS_DIVISOR = 1e3;
var MAX_WATCH_TIME_MS = 24e4;
var MAX_BACKOFF_DELAY_MS = 3e4;
var BACKOFF_JITTER_MS = 1e3;
var HIGH_RISK_THRESHOLD_PERCENT = 50;
var MEDIUM_RISK_THRESHOLD_PERCENT = 20;
var DEFAULT_COMPARISON_COUNT = 2;
var SINGLE_DEPLOYMENT_FETCH = 1;
var MAX_TOKENS_PER_MINUTE = 30;
var RATE_LIMITER_CLEANUP_AGE_MS = 36e5;
var DEPLOYMENT_STATES = {
INITIALIZING: "INITIALIZING",
BUILDING: "BUILDING",
UPLOADING: "UPLOADING",
DEPLOYING: "DEPLOYING",
READY: "READY",
ERROR: "ERROR",
CANCELED: "CANCELED"
};
var POLLING_INTERVALS_BY_STATE = {
INITIALIZING: 5e3,
// 5s - slower at start
BUILDING: 3e3,
// 3s - active building
UPLOADING: 2e3,
// 2s - final stages
DEPLOYING: 2e3,
// 2s - final stages
READY: 0,
// Stop polling
ERROR: 0,
// Stop polling
CANCELED: 0,
// Stop polling
UNKNOWN: 1e4
// 10s - unknown states
};
var LOG_FILTERS = {
ERROR: /error|fail|exception|critical/i,
WARNING: /warning|warn|deprecat/i
};
var DEFAULTS = {
DEPLOYMENT_ID_SLICE_LENGTH: 7,
ERROR_LINE_TRIM_INDEX: 0,
PERCENTAGE_MULTIPLIER: 100,
MAX_EVENT_BUFFER_SIZE: 100,
MAX_CACHE_SIZE: 10,
CACHE_TTL_MS: 30 * 60 * 1e3,
// 30 minutes
CACHE_CLEANUP_INTERVAL_MS: 5 * 60 * 1e3
// 5 minutes
};
var ERROR_MESSAGES = {
NO_TOKEN: "No token provided",
NO_DEPLOYMENT_FOUND: "No deployment found for this project",
DEPLOYMENT_TAKING_LONG: "\u26A0\uFE0F Deployment is taking longer than expected",
NO_LOGS_AVAILABLE: "No logs available",
NO_LOGS_MATCHING_FILTER: "No logs matching filter criteria"
};
var STATUS_ICONS = {
ROCKET: "\u{1F680}",
HOURGLASS: "\u23F3",
HAMMER: "\u{1F528}",
PACKAGE: "\u{1F4E6}",
GLOBE: "\u{1F30D}",
SUCCESS: "\u2705",
ERROR: "\u274C",
WARNING: "\u26A0\uFE0F",
CHART: "\u{1F4CA}",
LIGHTNING: "\u26A1",
TURTLE: "\u{1F422}",
LIGHTBULB: "\u{1F4A1}",
PIN: "\u{1F4CD}"
};
var STATUS_MESSAGES = {
STARTING_WATCH: (id) => `${STATUS_ICONS.ROCKET} Starting to watch deployment ${id}...`,
INITIALIZING: `${STATUS_ICONS.HOURGLASS} Initializing deployment...`,
BUILDING: `${STATUS_ICONS.HAMMER} Building application...`,
UPLOADING: `${STATUS_ICONS.PACKAGE} Uploading to edge network...`,
DEPLOYING: `${STATUS_ICONS.GLOBE} Deploying to production...`,
DEPLOYMENT_SUCCESS: `${STATUS_ICONS.SUCCESS} Deployment successful!`,
DEPLOYMENT_FAILED: (message) => `${STATUS_ICONS.ERROR} Deployment failed: ${message}`,
BUILD_TIME_SAME: (time) => `${STATUS_ICONS.CHART} Build time: ${time}s (same as previous)`,
BUILD_TIME_CHANGE: (current, delta, faster) => `${STATUS_ICONS.CHART} Build time: ${current}s (${Math.abs(delta)}s ${faster ? "faster" : "slower"} than previous) ${faster ? STATUS_ICONS.LIGHTNING : STATUS_ICONS.TURTLE}`
};
var ERROR_TEXT_PATTERNS = {
UNAUTHORIZED: ["401", "unauthorized"],
NOT_FOUND: ["404", "not found"],
RATE_LIMITED: ["429", "rate limit"],
TIMEOUT: ["timeout", "AbortError"]
};
var API_EVENT_TYPES = {
STDOUT: "stdout",
STDERR: "stderr"
};
var API_MESSAGES = {
NO_LOGS_AVAILABLE: "No logs available",
INVALID_TOKEN: "Invalid Vercel token",
PROJECT_NOT_FOUND: "Project not found",
RATE_LIMIT_EXCEEDED: "Rate limit exceeded",
REQUEST_TIMEOUT: "Request timeout",
FAILED_TO_VALIDATE_TOKEN: "Failed to validate Vercel token"
};
var API_PARAMS = {
BUILDS: 1,
LOGS: 1
};
var API_CONFIG = {
VERCEL_BASE_URL: "https://api.vercel.com",
DEFAULT_TIMEOUT_MS: 1e4,
DEFAULT_RETRY_ATTEMPTS: 3,
DEFAULT_DEPLOYMENT_LIMIT: 10,
SINGLE_DEPLOYMENT_LIMIT: 1
};
var PLATFORM_NAMES = {
VERCEL: "vercel",
NETLIFY: "netlify",
RAILWAY: "railway",
RENDER: "render"
};
var ENVIRONMENT_TYPES = {
PRODUCTION: "production",
PREVIEW: "preview",
DEVELOPMENT: "development"
};
var VERCEL_STATES = {
READY: "READY",
ERROR: "ERROR",
CANCELED: "CANCELED",
BUILDING: "BUILDING",
INITIALIZING: "INITIALIZING",
QUEUED: "QUEUED"
};
var ADAPTER_ERRORS = {
TOKEN_REQUIRED: "Vercel token required. Set VERCEL_TOKEN environment variable or pass token parameter.",
FETCH_DEPLOYMENT_FAILED: "Failed to fetch deployment status from Vercel",
UNKNOWN_STATUS: "unknown"
};
// src/adapters/base/api-client.ts
var RateLimitError = class extends Error {
constructor(retryAfter, endpoint) {
super(`Rate limit exceeded for ${endpoint}. Retry after ${retryAfter}ms`);
this.retryAfter = retryAfter;
this.endpoint = endpoint;
this.name = "RateLimitError";
}
};
var HTTPError = class _HTTPError extends Error {
constructor(statusCode, statusText, url, method) {
super(`${method} ${url} failed with ${statusCode}: ${statusText}`);
this.statusCode = statusCode;
this.statusText = statusText;
this.url = url;
this.method = method;
this.name = "HTTPError";
Error.captureStackTrace(this, _HTTPError);
}
};
var BaseAPIClient = class {
baseUrl;
defaultHeaders;
timeout;
maxRetries;
pendingRequests = /* @__PURE__ */ new Map();
rateLimiters = /* @__PURE__ */ new Map();
maxTokensPerMinute = MAX_TOKENS_PER_MINUTE;
constructor(config) {
this.baseUrl = new URL(config.baseUrl);
this.defaultHeaders = new Headers({
"Content-Type": "application/json",
"User-Agent": "deploy-mcp/1.0.0",
...config.headers
});
this.timeout = config.timeout ?? API_CONFIG.DEFAULT_TIMEOUT_MS;
this.maxRetries = config.retry ?? API_CONFIG.DEFAULT_RETRY_ATTEMPTS;
}
async request(endpoint, options) {
if (options?.token) {
await this.checkRateLimit(options.token, endpoint.path);
}
const cacheKey = this.getCacheKey(endpoint, options);
if (endpoint.method === "GET" && !options?.body) {
const pending = this.pendingRequests.get(cacheKey);
if (pending) {
return pending;
}
}
const requestPromise = this.executeRequest(endpoint, options);
if (endpoint.method === "GET" && !options?.body) {
this.pendingRequests.set(cacheKey, requestPromise);
requestPromise.then(() => {
this.pendingRequests.delete(cacheKey);
}).catch(() => {
this.pendingRequests.delete(cacheKey);
});
}
return requestPromise;
}
async executeRequest(endpoint, options) {
const url = this.buildUrl(endpoint.path, options?.searchParams);
const headers = this.mergeHeaders(options?.headers);
let lastError = new Error("No attempts made");
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
const response = await this.fetchWithTimeout(url, {
method: endpoint.method,
headers,
body: options?.body ? JSON.stringify(options.body) : void 0,
signal: options?.signal
});
if (!response.ok) {
throw new HTTPError(
response.status,
response.statusText,
url.toString(),
endpoint.method
);
}
const text = await response.text();
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch {
throw new Error(
`Invalid JSON response from ${endpoint.path}: ${text.slice(0, 100)}`
);
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (error instanceof HTTPError && error.statusCode >= 400 && error.statusCode < 500 || error instanceof Error && error.name === "AbortError") {
throw this.enhanceError(lastError, endpoint);
}
if (attempt === this.maxRetries) {
throw this.enhanceError(lastError, endpoint);
}
const baseDelay = Math.min(
1e3 * Math.pow(2, attempt),
MAX_BACKOFF_DELAY_MS
);
const jitter = Math.random() * BACKOFF_JITTER_MS;
const totalDelay = Math.min(baseDelay + jitter, MAX_BACKOFF_DELAY_MS);
await this.delay(totalDelay);
}
}
throw this.enhanceError(lastError, endpoint);
}
async fetchWithTimeout(url, init) {
const controller = new AbortController();
if (init.signal) {
init.signal.addEventListener("abort", () => controller.abort());
}
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await fetch(url, {
...init,
signal: controller.signal,
keepalive: true
});
} finally {
clearTimeout(timeoutId);
}
}
buildUrl(path, params) {
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
const baseUrlString = this.baseUrl.toString();
const baseWithSlash = baseUrlString.endsWith("/") ? baseUrlString : baseUrlString + "/";
const url = new URL(cleanPath, baseWithSlash);
if (params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== void 0 && value !== null) {
searchParams.set(key, String(value));
}
}
url.search = searchParams.toString();
}
return url;
}
mergeHeaders(headers) {
if (!headers) {
return this.defaultHeaders;
}
const merged = new Headers(this.defaultHeaders);
for (const [key, value] of Object.entries(headers)) {
merged.set(key, value);
}
return merged;
}
enhanceError(error, endpoint) {
const enhanced = new Error(
`API request failed for ${endpoint.path}: ${error.message}
See docs: ${endpoint.docsUrl}`
);
enhanced.stack = error.stack;
enhanced.cause = error;
return enhanced;
}
getCacheKey(endpoint, options) {
const params = options?.searchParams ? JSON.stringify(options.searchParams) : "";
return `${endpoint.method}:${endpoint.path}:${params}`;
}
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
getRateLimiter(token) {
if (!this.rateLimiters.has(token)) {
this.rateLimiters.set(token, {
tokens: this.maxTokensPerMinute,
lastRefill: Date.now(),
refillRate: this.maxTokensPerMinute
});
}
return this.rateLimiters.get(token);
}
async checkRateLimit(token, endpoint) {
const limiter = this.getRateLimiter(token);
const now = Date.now();
const timeSinceLastRefill = now - limiter.lastRefill;
const minutesElapsed = timeSinceLastRefill / 6e4;
const tokensToAdd = minutesElapsed * limiter.refillRate;
limiter.tokens = Math.min(
this.maxTokensPerMinute,
limiter.tokens + tokensToAdd
);
limiter.lastRefill = now;
if (limiter.tokens < 1) {
const waitTime = (1 - limiter.tokens) * (6e4 / limiter.refillRate);
throw new RateLimitError(Math.ceil(waitTime), endpoint);
}
limiter.tokens -= 1;
}
cleanupRateLimiters() {
const now = Date.now();
const maxAge = RATE_LIMITER_CLEANUP_AGE_MS;
for (const [token, limiter] of this.rateLimiters.entries()) {
if (now - limiter.lastRefill > maxAge) {
this.rateLimiters.delete(token);
}
}
}
};
// src/adapters/vercel/endpoints.ts
var VercelEndpoints = {
listDeployments: {
path: "/v6/deployments",
method: "GET",
docsUrl: "https://vercel.com/docs/rest-api/endpoints/deployments#list-deployments",
description: "List deployments for authenticated user or team"
},
getDeployment: {
path: "/v13/deployments",
method: "GET",
docsUrl: "https://vercel.com/docs/rest-api/endpoints/deployments#get-a-deployment-by-id-or-url",
description: "Get deployment by ID or URL"
},
getDeploymentEvents: {
path: "/v2/deployments",
method: "GET",
docsUrl: "https://vercel.com/docs/rest-api/endpoints/deployments#get-deployment-events",
description: "Get build logs and events for a deployment"
},
getUser: {
path: "/v2/user",
method: "GET",
docsUrl: "https://vercel.com/docs/rest-api/endpoints/user#get-the-authenticated-user",
description: "Get authenticated user information"
}
};
// src/adapters/vercel/api.ts
var VercelAPI = class extends BaseAPIClient {
endpoints = VercelEndpoints;
config;
constructor(config) {
super({
baseUrl: config.baseUrl,
timeout: config.timeout,
retry: config.retryAttempts
});
this.config = config;
}
async getDeployments(projectId, token, limit = 1) {
try {
return await this.request(
this.endpoints.listDeployments,
{
searchParams: { projectId, limit },
headers: {
Authorization: `Bearer ${token}`
},
token
// Pass token for rate limiting
}
);
} catch (error) {
throw this.handleApiError(
error,
`Failed to fetch deployments for project ${projectId}`
);
}
}
async getUser(token) {
try {
return await this.request(this.endpoints.getUser, {
headers: {
Authorization: `Bearer ${token}`
},
token
// Pass token for rate limiting
});
} catch (error) {
throw this.handleApiError(error, API_MESSAGES.FAILED_TO_VALIDATE_TOKEN);
}
}
async getDeploymentById(deploymentId, token) {
try {
const endpoint = {
...this.endpoints.getDeployment,
path: `${this.endpoints.getDeployment.path}/${deploymentId}`
};
return await this.request(endpoint, {
headers: {
Authorization: `Bearer ${token}`
},
token
// Pass token for rate limiting
});
} catch (error) {
throw this.handleApiError(
error,
`Failed to fetch deployment ${deploymentId}`
);
}
}
async getDeploymentLogs(deploymentId, token) {
try {
const endpoint = {
...this.endpoints.getDeploymentEvents,
path: `${this.endpoints.getDeploymentEvents.path}/${deploymentId}/events`
};
const response = await this.request(endpoint, {
searchParams: {
builds: API_PARAMS.BUILDS,
logs: API_PARAMS.LOGS
},
headers: {
Authorization: `Bearer ${token}`
},
token
// Pass token for rate limiting
});
if (!response || !Array.isArray(response)) {
return API_MESSAGES.NO_LOGS_AVAILABLE;
}
const logs = response.filter(
(event) => event.type === API_EVENT_TYPES.STDOUT || event.type === API_EVENT_TYPES.STDERR
).map((event) => event.payload?.text || event.text || "").join("\n");
const sanitizedLogs = logs.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'").replace(/\//g, "/");
return sanitizedLogs || API_MESSAGES.NO_LOGS_AVAILABLE;
} catch (error) {
throw this.handleApiError(
error,
`Failed to fetch logs for deployment ${deploymentId}`
);
}
}
handleApiError(error, context) {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (ERROR_TEXT_PATTERNS.UNAUTHORIZED.some(
(pattern) => message.includes(pattern)
)) {
return new AdapterException(
"UNAUTHORIZED",
API_MESSAGES.INVALID_TOKEN,
error
);
}
if (ERROR_TEXT_PATTERNS.NOT_FOUND.some((pattern) => message.includes(pattern))) {
return new AdapterException(
"NOT_FOUND",
API_MESSAGES.PROJECT_NOT_FOUND,
error
);
}
if (ERROR_TEXT_PATTERNS.RATE_LIMITED.some(
(pattern) => message.includes(pattern)
)) {
return new AdapterException(
"RATE_LIMITED",
API_MESSAGES.RATE_LIMIT_EXCEEDED,
error
);
}
if (ERROR_TEXT_PATTERNS.TIMEOUT.some(
(pattern) => message.includes(pattern) || error.name === pattern
)) {
return new AdapterException(
"NETWORK_ERROR",
API_MESSAGES.REQUEST_TIMEOUT,
error
);
}
return new AdapterException("NETWORK_ERROR", context, error);
}
return new AdapterException("UNKNOWN_ERROR", context);
}
};
// src/adapters/vercel/index.ts
var VercelAdapter = class extends BaseAdapter {
name = PLATFORM_NAMES.VERCEL;
api;
constructor(config) {
super();
const defaultConfig = {
baseUrl: API_CONFIG.VERCEL_BASE_URL,
timeout: API_CONFIG.DEFAULT_TIMEOUT_MS,
retryAttempts: API_CONFIG.DEFAULT_RETRY_ATTEMPTS
};
this.api = new VercelAPI({ ...defaultConfig, ...config });
}
async getLatestDeployment(project, token) {
const apiToken = token || process.env.VERCEL_TOKEN;
if (!apiToken) {
throw new Error(ADAPTER_ERRORS.TOKEN_REQUIRED);
}
try {
const data = await this.api.getDeployments(
project,
apiToken,
API_CONFIG.SINGLE_DEPLOYMENT_LIMIT
);
if (!data.deployments || data.deployments.length === 0) {
return {
status: ADAPTER_ERRORS.UNKNOWN_STATUS,
projectName: project,
platform: PLATFORM_NAMES.VERCEL
};
}
return this.transformDeployment(data.deployments[0]);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(ADAPTER_ERRORS.FETCH_DEPLOYMENT_FAILED);
}
}
async authenticate(token) {
try {
await this.api.getUser(token);
return true;
} catch {
return false;
}
}
transformDeployment(deployment) {
const status = this.mapState(deployment.state);
return {
id: deployment.uid,
status,
url: deployment.url ? `https://${deployment.url}` : void 0,
projectName: deployment.name,
platform: PLATFORM_NAMES.VERCEL,
timestamp: this.formatTimestamp(deployment.createdAt),
duration: deployment.ready ? this.calculateDuration(deployment.createdAt, deployment.ready) : void 0,
environment: deployment.target || ENVIRONMENT_TYPES.PRODUCTION,
commit: deployment.meta ? {
sha: deployment.meta.githubCommitSha,
message: deployment.meta.githubCommitMessage,
author: deployment.meta.githubCommitAuthorName
} : void 0
};
}
mapState(state) {
switch (state) {
case VERCEL_STATES.READY:
return "success";
case VERCEL_STATES.ERROR:
case VERCEL_STATES.CANCELED:
return "failed";
case VERCEL_STATES.BUILDING:
case VERCEL_STATES.INITIALIZING:
case VERCEL_STATES.QUEUED:
return "building";
default:
return ADAPTER_ERRORS.UNKNOWN_STATUS;
}
}
async getDeploymentById(deploymentId, token) {
return this.api.getDeploymentById(deploymentId, token);
}
async getRecentDeployments(project, token, limit = API_CONFIG.DEFAULT_DEPLOYMENT_LIMIT) {
const data = await this.api.getDeployments(project, token, limit);
return data.deployments || [];
}
async getDeploymentLogs(deploymentId, token) {
return this.api.getDeploymentLogs(deploymentId, token);
}
async getDeploymentStatus(project, token) {
return this.getLatestDeployment(project, token);
}
};
// src/adapters/netlify/endpoints.ts
var NetlifyEndpoints = {
listSites: {
path: "/sites",
method: "GET",
docsUrl: "https://docs.netlify.com/api/get-started/#sites",
description: "List all sites for the current user"
},
getSite: {
path: "/sites/{site_id}",
method: "GET",
docsUrl: "https://docs.netlify.com/api/get-started/#get-site",
description: "Get a specific site by ID"
},
listDeploys: {
path: "/sites/{site_id}/deploys",
method: "GET",
docsUrl: "https://docs.netlify.com/api/get-started/#list-site-deploys",
description: "List all deploys for a site"
},
getDeploy: {
path: "/deploys/{deploy_id}",
method: "GET",
docsUrl: "https://docs.netlify.com/api/get-started/#get-deploy",
description: "Get a specific deploy by ID"
},
getUser: {
path: "/user",
method: "GET",
docsUrl: "https://docs.netlify.com/api/get-started/#get-current-user",
description: "Get the current user"
}
};
// src/adapters/netlify/api.ts
var NetlifyAPI = class extends BaseAPIClient {
endpoints = NetlifyEndpoints;
config;
siteCache = /* @__PURE__ */ new Map();
// name -> id cache
constructor(config) {
const fullConfig = {
baseUrl: "https://api.netlify.com/api/v1",
timeout: config?.timeout ?? API_CONFIG.DEFAULT_TIMEOUT_MS,
retryAttempts: config?.retryAttempts ?? API_CONFIG.DEFAULT_RETRY_ATTEMPTS,
...config
};
super({
baseUrl: fullConfig.baseUrl,
timeout: fullConfig.timeout,
retry: fullConfig.retryAttempts
});
this.config = fullConfig;
}
/**
* Get site ID from site name or ID
* Netlify API requires site ID for most endpoints
*/
async getSiteId(siteNameOrId, token) {
if (this.siteCache.has(siteNameOrId)) {
return this.siteCache.get(siteNameOrId);
}
const sites = await this.listSites(token);
const site = sites.find(
(s) => s.name === siteNameOrId || s.id === siteNameOrId
);
if (!site) {
throw new Error(`Site not found: ${siteNameOrId}`);
}
this.siteCache.set(siteNameOrId, site.id);
return site.id;
}
async listSites(token) {
const options = {
headers: {
Authorization: `Bearer ${token}`
},
token
};
return this.request(this.endpoints.listSites, options);
}
async listDeploys(siteNameOrId, token, limit = 10) {
const siteId = await this.getSiteId(siteNameOrId, token);
const endpoint = {
...this.endpoints.listDeploys,
path: this.endpoints.listDeploys.path.replace("{site_id}", siteId)
};
const options = {
headers: {
Authorization: `Bearer ${token}`
},
searchParams: {
per_page: limit
},
token
};
return this.request(endpoint, options);
}
async getDeploy(deployId, token) {
const endpoint = {
...this.endpoints.getDeploy,
path: this.endpoints.getDeploy.path.replace("{deploy_id}", deployId)
};
const options = {
headers: {
Authorization: `Bearer ${token}`
},
token
};
return this.request(endpoint, options);
}
async getDeployLog(deployId, token) {
const deploy = await this.getDeploy(deployId, token);
if (!deploy.log_access_attributes?.url) {
throw new Error("Deploy logs not available");
}
const response = await fetch(deploy.log_access_attributes.url);
if (!response.ok) {
throw new Error(`Failed to fetch logs: ${response.statusText}`);
}
return response.text();
}
async getUser(token) {
const options = {
headers: {
Authorization: `Bearer ${token}`
},
token
};
return this.request(this.endpoints.getUser, options);
}
};
// src/adapters/netlify/index.ts
var NetlifyAdapter = class extends BaseAdapter {
name = "netlify";
api;
constructor() {
super();
this.api = new NetlifyAPI();
}
/**
* Map Netlify deploy states to our standard states
* Source: https://github.com/netlify/open-api/blob/master/swagger.yml
*/
mapState(state) {
switch (state) {
case "ready":
case "processed":
return "success";
case "error":
case "rejected":
return "failed";
case "new":
case "pending_review":
case "accepted":
case "enqueued":
case "building":
case "uploading":
case "uploaded":
case "preparing":
case "prepared":
case "processing":
case "retrying":
return "building";
default:
return "unknown";
}
}
/**
* Transform Netlify deploy to our standard format
*/
transformDeploy(deploy) {
const status = this.mapState(deploy.state);
let duration;
if (deploy.created_at && deploy.published_at) {
duration = this.calculateDuration(deploy.created_at, deploy.published_at);
} else if (deploy.deploy_time) {
duration = Math.round(deploy.deploy_time / 1e3);
}
return {
id: deploy.id,
status,
url: deploy.ssl_url || deploy.url || deploy.deploy_ssl_url || deploy.deploy_url,
projectName: deploy.name || deploy.site_id,
platform: "netlify",
timestamp: this.formatTimestamp(deploy.created_at),
duration,
environment: deploy.context || "production",
commit: deploy.commit_ref ? {
sha: deploy.commit_ref,
message: deploy.title
} : void 0
};
}
async getLatestDeployment(project, token) {
const apiToken = token || process.env.NETLIFY_TOKEN;
if (!apiToken) {
throw new AdapterException(
"UNAUTHORIZED",
"Netlify token required. Set NETLIFY_TOKEN environment variable or pass token parameter."
);
}
try {
const deploys = await this.api.listDeploys(project, apiToken, 1);
if (!deploys || deploys.length === 0) {
throw new AdapterException(
"NOT_FOUND",
`No deployments found for site: ${project}`
);
}
return this.transformDeploy(deploys[0]);
} catch (error) {
if (error instanceof AdapterException) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Site not found")) {
throw new AdapterException(
"NOT_FOUND",
`Site not found: ${project}. Make sure the site name is correct.`
);
}
if (message.includes("401") || message.includes("Unauthorized")) {
throw new AdapterException(
"UNAUTHORIZED",
"Invalid Netlify token. Check your NETLIFY_TOKEN."
);
}
throw new AdapterException("UNKNOWN", `Netlify API error: ${message}`);
}
}
async authenticate(token) {
try {
const user = await this.api.getUser(token);
return !!user.id;
} catch {
return false;
}
}
async getDeploymentById(deploymentId, token) {
try {
return await this.api.getDeploy(deploymentId, token);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new AdapterException(
"NOT_FOUND",
`Deployment not found: ${deploymentId}. ${message}`
);
}
}
async getRecentDeployments(project, token, limit = 10) {
try {
return await this.api.listDeploys(project, token, limit);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Site not found")) {
throw new AdapterException("NOT_FOUND", `Site not found: ${project}`);
}
throw new AdapterException(
"UNKNOWN",
`Failed to fetch deployments: ${message}`
);
}
}
async getDeploymentLogs(deploymentId, token) {
try {
return await this.api.getDeployLog(deploymentId, token);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("not available")) {
return "Deploy logs not available for this deployment.";
}
throw new AdapterException(
"UNKNOWN",
`Failed to fetch deployment logs: ${message}`
);
}
}
};
// src/core/deployment-intelligence.ts
var DeploymentIntelligence = class {
adapter;
platform;
constructor(platform) {
this.platform = platform;
this.adapter = this.createAdapter(platform);
}
getTokenForPlatform() {
switch (this.platform) {
case "vercel":
return process.env.VERCEL_TOKEN;
case "netlify":
return process.env.NETLIFY_TOKEN;
case "railway":
return process.env.RAILWAY_TOKEN;
case "render":
return process.env.RENDER_TOKEN;
default:
return void 0;
}
}
getPollingInterval(state) {
return POLLING_INTERVALS_BY_STATE[state] || POLLING_INTERVALS_BY_STATE.UNKNOWN;
}
getTimeAgo(timestamp) {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1e3);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
}
mapDeploymentState(state) {
switch (state) {
case "READY":
return "success";
case "ERROR":
case "CANCELED":
return "failed";
case "BUILDING":
case "INITIALIZING":
case "QUEUED":
return "building";
default:
return "unknown";
}
}
createAdapter(platform) {
switch (platform) {
case "vercel":
return new VercelAdapter();
case "netlify":
return new NetlifyAdapter();
// Ready for future platforms
// case "railway":
// return new RailwayAdapter();
// case "render":
// return new RenderAdapter();
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
async getDeployment(deploymentId, token) {
return this.adapter.getDeploymentById(deploymentId, token);
}
async *watchDeployment(args) {
const token = args.token || this.getTokenForPlatform();
if (!token) {
yield {
type: "error",
message: ERROR_MESSAGES.NO_TOKEN,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
return;
}
try {
let deploymentId = args.deploymentId;
if (!deploymentId) {
const deployments = await this.adapter.getRecentDeployments(
args.project,
token,
SINGLE_DEPLOYMENT_FETCH
);
if (deployments.length > 0) {
deploymentId = deployments[0].uid || deployments[0].id;
}
}
if (!deploymentId) {
yield {
type: "error",
message: ERROR_MESSAGES.NO_DEPLOYMENT_FOUND,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
return;
}
yield {
type: "progress",
message: STATUS_MESSAGES.STARTING_WATCH(
deploymentId.slice(0, DEFAULTS.DEPLOYMENT_ID_SLICE_LENGTH)
),
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
let lastState = "";
let attempts = 0;
const maxAttempts = MAX_DEPLOYMENT_WATCH_ATTEMPTS;
const startTime = Date.now();
const maxWatchTime = MAX_WATCH_TIME_MS;
while (attempts < maxAttempts && Date.now() - startTime < maxWatchTime) {
try {
const deployment = await this.adapter.getDeploymentById(
deploymentId,
token
);
if (deployment.readyState !== lastState) {
lastState = deployment.readyState;
switch (deployment.readyState) {
case DEPLOYMENT_STATES.INITIALIZING:
yield {
type: "progress",
message: STATUS_MESSAGES.INITIALIZING,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
break;
case DEPLOYMENT_STATES.BUILDING:
yield {
type: "progress",
message: STATUS_MESSAGES.BUILDING,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
break;
case DEPLOYMENT_STATES.UPLOADING:
yield {
type: "progress",
message: STATUS_MESSAGES.UPLOADING,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
break;
case DEPLOYMENT_STATES.DEPLOYING:
yield {
type: "progress",
message: STATUS_MESSAGES.DEPLOYING,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
break;
case DEPLOYMENT_STATES.READY: {
const duration = deployment.buildingAt && deployment.ready ? deployment.ready - deployment.buildingAt : void 0;
yield {
type: "success",
message: STATUS_MESSAGES.DEPLOYMENT_SUCCESS,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
details: {
url: deployment.url,
duration: duration ? Math.round(duration / BUILD_TIME_SECONDS_DIVISOR) : void 0
}
};
try {
const comparison = await this.compareWithPrevious(
args.project,
deploymentId,
token
);
if (comparison) {
yield {
type: "progress",
message: this.formatComparison(comparison),
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
}
} catch (e) {
console.error("Failed to compare deployments:", e);
}
return;
}
case DEPLOYMENT_STATES.ERROR:
case DEPLOYMENT_STATES.CANCELED: {
const errorDetails = await this.analyzeError(
deploymentId,
token
);
yield {
type: "error",
message: STATUS_MESSAGES.DEPLOYMENT_FAILED(
errorDetails.message
),
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
details: {
suggestion: errorDetails.suggestion,
file: errorDetails.location
}
};
return;
}
}
}
if (deployment.readyState === DEPLOYMENT_STATES.READY || deployment.readyState === DEPLOYMENT_STATES.ERROR || deployment.readyState === DEPLOYMENT_STATES.CANCELED) {
break;
}
const pollInterval = this.getPollingInterval(lastState || "UNKNOWN");
if (pollInterval === 0) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
attempts++;
} catch (error) {
console.error("Error checking deployment status:", error);
attempts++;
const pollInterval = this.getPollingInterval(lastState || "UNKNOWN");
if (pollInterval === 0) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
}
if (attempts >= maxAttempts || Date.now() - startTime >= MAX_WATCH_TIME_MS) {
const timeoutSeconds = Math.round((Date.now() - startTime) / 1e3);
yield {
type: "warning",
message: `Deployment watch timed out after ${timeoutSeconds} seconds. The deployment may still be running.`,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
details: {
suggestion: "Check the platform dashboard for the latest status"
}
};
}
} catch (error) {
yield {
type: "error",
message: `Error watching deployment: ${error instanceof Error ? error.message : "Unknown error"}`,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
}
}
async compareDeployments(args) {
const token = args.token || this.getTokenForPlatform();
if (!token) {
throw new Error(`No ${this.platform} token provided`);
}
try {
let current;
let previous;
switch (args.mode || "last_vs_previous") {
case "last_vs_previous": {
const deployments = await this.adapter.getRecentDeployments(
args.project,
token,
2
);
if (deployments.length < 2) return null;
[current, previous] = deployments;
break;
}
case "current_vs_success": {
const deployments = await this.adapter.getRecentDeployments(
args.project,
token,
20
// Look back further to find a success
);
if (deployments.length < 2) return null;
current = deployments[0];
previous = deployments.find(
(d, index) => index > 0 && (d.state === "READY" || d.readyState === "READY")
);
if (!previous) {
previous = deployments[1];
}
break;
}
case "current_vs_production": {
const deployments = await this.adapter.getRecentDeployments(
args.project,
token,
20
);
if (deployments.length < 2) return null;
current = deployments[0];
previous = deployments.find(
(d, index) => index > 0 && d.target === "production"
);
if (!previous) {
previous = deployments[1];
}
break;
}
case "between_dates": {
if (!args.dateFrom || !args.dateTo) {
throw new Error(
"dateFrom and dateTo are required for between_dates mode"
);
}
const deployments = await this.adapter.getRecentDeployments(
args.project,
token,
50
// Get more to cover date range
);
const fromTime = new Date(args.dateFrom).getTime();
const toTime = new Date(args.dateTo).getTime();
const inRange = deployments.filter((d) => {
const deployTime = new Date(d.createdAt).getTime();
return deployTime >= fromTime && deployTime <= toTime;
});
if (inRange.length < 2) {
throw new Error(
"Not enough deployments found in the specified date range"
);
}
current = inRange[0];
previous = inRange[inRange.length - 1];
break;
}
case "by_ids": {
if (!args.deploymentA || !args.deploymentB) {
throw new Error(
"deploymentA and deploymentB are required for by_ids mode"
);
}
[current, previous] = await Promise.all([
this.getDeployment(args.deploymentA, token),
this.getDeployment(args.deploymentB, token)
]);
break;
}
default: {
const deployments = await this.adapter.getRecentDeployments(
args.project,
token,
2
);
if (deployments.length < 2) return null;
[current, previous] = deployments;
}
}
const currentBuildTime = current.buildingAt && current.ready ? Math.round(
(current.ready - current.buildingAt) / BUILD_TIME_SECONDS_DIVISOR
) : 0;
const previousBuildTime = previous.buildingAt && previous.ready ? Math.round(
(previous.ready - previous.buildingAt) / BUILD_TIME_SECONDS_DIVISOR
) : 0;
const comparison = {
deployments: {
current: {
id: current.uid || current.id,
url: current.url ? `https://${current.url}` : void 0,
timestamp: new Date(current.createdAt).toISOString(),
commit: current.meta ? {
sha: current.meta.githubCommitSha,
message: current.meta.githubCommitMessage,
author: current.meta.githubCommitAuthorName
} : void 0,
buildTime: currentBuildTime,
status: this.mapDeploymentState(
current.state || current.readyState
),
timeAgo: this.getTimeAgo(current.createdAt)
},
previous: {
id: previous.uid || previous.id,
url: previous.url ? `https://${previous.url}` : void 0,
timestamp: new Date(previous.createdAt).toISOString(),
commit: previous.meta ? {
sha: previous.meta.githubCommitSha,
message: previous.meta.githubCommitMessage,
author: previous.meta.githubCommitAuthorName
} : void 0,
buildTime: previousBuildTime,
status: this.mapDeploymentState(
previous.state || previous.readyState
),
timeAgo: this.getTimeAgo(previous.createdAt)
}
},
performance: {
buildTime: {
current: currentBuildTime,
previous: previousBuildTime,
delta: 0,
percentage: 0
}
},
changes: {
filesChanged: 0
},
risk: "LOW"
};
comparison.performance.buildTime.delta = comparison.performance.buildTime.current - comparison.performance.buildTime.previous;
comparison.performance.buildTime.percentage = comparison.performance.buildTime.previous > 0 ? Math.round(
comparison.performance.buildTime.delta / comparison.performance.buildTime.previous * DEFAULTS.PERCENTAGE_MULTIPLIER
) : 0;
if (Math.abs(comparison.performance.buildTime.percentage) > HIGH_RISK_THRESHOLD_PERCENT) {
comparison.risk = "HIGH";
} else if (Math.abs(comparison.performance.buildTime.percentage) > MEDIUM_RISK_THRESHOLD_PERCENT) {
comparison.risk = "MEDIUM";
}
return comparison;
} catch (error) {
console.error("Error comparing deployments:", error);
return null;
}
}
async getDeploymentLogs(args) {
const token = args.token || this.getTokenForPlatform();
if (!token) {
throw new Error(`No ${this.platform} token provided`);
}
try {
let actualDeploymentId = args.deploymentId;
if (args.deploymentId === "latest" && args.project) {
const latestDeployment = await this.adapter.getLatestDeployment(
args.project,
token
);
if (!latestDeployment.id) {
throw new Error("Latest deployment has no ID");
}
actualDeploymentId = latestDeployment.id;
} else if (args.deploymentId === "latest" && !args.project) {
throw new Error("Project name required when using 'latest' deployment");
}
const logs = await this.adapter.getDeploymentLogs(
actualDeploymentId,
token
);
let filteredLogs = logs;
if (args.filter === "error") {
const lines = logs.split("\n");
filteredLogs = lines.filter((line) => LOG_FILTERS.ERROR.test(line)).join("\n");
} else if (args.filter === "warning") {
const lines = logs.split("\n");
filteredLogs = lines.filter((line) => LOG_FILTERS.WARNING.test(line)).join("\n");
}
const analysi