Skip to main content
Glama
cli-client.ts12.6 kB
/** * TiltCliClient - Safe CLI command execution * * Wraps Tilt CLI commands with: * - Safe execution (spawn with args[], no shell) * - Timeout handling (kill process, throw error) * - Buffer limits (10MB default, 50MB for logs) * - Error parsing (ENOENT, connection refused, not found) * - In-process log tailing (no shell pipes) */ import { spawn } from 'node:child_process'; import { resolveTiltTarget } from './config.js'; import { TiltCommandTimeoutError, TiltNotInstalledError, TiltNotRunningError, TiltOutputExceededError, TiltResourceNotFoundError, } from './errors.js'; export interface ExecOptions { timeout?: number; // Max execution time (ms) maxBuffer?: number; // Max stdout/stderr size (bytes) } export interface LogOptions { follow?: boolean; tailLines?: number; // Limit to N most recent lines // Level filters Tilt's internal log messages (warnings/errors about builds, resources) // NOT application log content. Application logs are passed through unfiltered. level?: 'warn' | 'error'; // Source filters by log origin: 'build' (build logs), 'runtime' (container logs), 'all' (both) source?: 'all' | 'build' | 'runtime'; } export interface TiltCliClientConfig { port?: number; host?: string; binaryPath?: string; } export interface Resource { metadata: { name: string; [key: string]: unknown; }; [key: string]: unknown; } export interface ResourceDetail { kind: string; metadata: { name: string; [key: string]: unknown; }; status?: { buildHistory?: unknown[]; [key: string]: unknown; }; [key: string]: unknown; } export class TiltCliClient { private readonly port: number; private readonly host: string; private readonly binaryPath: string; constructor(config: TiltCliClientConfig = {}) { const { port, host } = resolveTiltTarget({ port: config.port, host: config.host, }); this.port = port; this.host = host; this.binaryPath = config.binaryPath ?? 'tilt'; } /** * Get client configuration info */ getClientInfo(): { port: number; host: string; binaryPath: string; } { return { port: this.port, host: this.host, binaryPath: this.binaryPath, }; } /** * Execute tilt command safely with argument array * NO shell interpolation - prevents command injection * * @param args - Command arguments array * @param options - Execution options (timeout, maxBuffer) * @returns Command stdout * @throws TiltNotInstalledError if tilt binary not found * @throws TiltCommandTimeoutError if command exceeds timeout * @throws TiltOutputExceededError if output exceeds maxBuffer * @throws TiltNotRunningError if cannot connect to Tilt * @throws TiltResourceNotFoundError if resource not found */ async execTilt( args: readonly string[], options: ExecOptions = {}, ): Promise<string> { // Use Infinity to skip timeout (for follow mode), not 0 which kills immediately // Default to 30s if not provided const timeout = options.timeout ?? 30000; const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024; // 10MB return new Promise((resolve, reject) => { const proc = spawn(this.binaryPath, args as string[], { stdio: ['ignore', 'pipe', 'pipe'], // NO shell: true - prevents command injection }); let stdout = ''; let stderr = ''; let killed = false; // Only set timeout if not Infinity (skip for follow mode) const timer = timeout !== Infinity ? setTimeout(() => { killed = true; proc.kill('SIGTERM'); }, timeout) : undefined; proc.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString(); if (stdout.length > maxBuffer) { if (timer !== undefined) { clearTimeout(timer); } killed = true; proc.kill('SIGTERM'); reject(new TiltOutputExceededError(maxBuffer)); } }); proc.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); proc.on('error', (error: NodeJS.ErrnoException) => { if (timer !== undefined) { clearTimeout(timer); } if (error.code === 'ENOENT') { reject(new TiltNotInstalledError()); } else { reject(error); } }); proc.on('close', (code: number | null) => { if (timer !== undefined) { clearTimeout(timer); } if (killed) { reject(new TiltCommandTimeoutError(args.join(' '), timeout)); return; } if (code === 0) { resolve(stdout); } else { reject(this.parseCliError(stderr, code)); } }); }); } /** * Parse CLI error output to throw appropriate error type */ private parseCliError(stderr: string, code: number | null): Error { if (stderr.includes('connection refused') || stderr.includes('dial tcp')) { return new TiltNotRunningError(this.port, this.host); } const notFoundMatch = stderr.match(/resource ["']?([^"']+)["']? (not found|does not exist)/i) ?? stderr.match(/["']([^"']+)["'] not found/i) ?? stderr.match(/no such resource ["']?([^"']+)["']?/i); if (notFoundMatch) { return new TiltResourceNotFoundError(notFoundMatch[1]); } return new Error(`Tilt command failed (exit ${code}): ${stderr}`); } /** * Get list of resources with optional label filtering * * @param labels - Optional array of label selectors * @returns Array of resources */ async getResources(labels?: string[]): Promise<Resource[]> { const args = [ 'get', 'uiresources', '-o', 'json', '--port', this.port.toString(), '--host', this.host, ]; if (labels && labels.length > 0) { args.push('-l', labels.join(',')); } const output = await this.execTilt(args); const result = JSON.parse(output) as { items?: Resource[] }; return result.items || []; } /** * Get detailed information about a specific resource * * @param resourceName - Name of the resource * @returns Resource detail object */ async describeResource(resourceName: string): Promise<ResourceDetail> { const args = [ 'get', `uiresource/${resourceName}`, '-o', 'json', '--port', this.port.toString(), '--host', this.host, ]; const output = await this.execTilt(args); return JSON.parse(output) as ResourceDetail; } /** * Get logs for a resource with optional filtering and tailing * * IMPORTANT: The --level flag filters Tilt's internal log messages * (e.g., warnings/errors about builds, resource status), NOT application * log content. Application logs are passed through unfiltered by Tilt. * * The --source flag filters by log origin: * - 'build': Container build logs * - 'runtime': Running container logs * - 'all': Both build and runtime logs (default) * * @param resourceName - Name of the resource * @param options - Log options (level, source, tailLines, follow) * @returns Log output */ async getLogs( resourceName: string, options: LogOptions = {}, ): Promise<string> { const args = [ 'logs', resourceName, '--port', this.port.toString(), '--host', this.host, ]; if (options.level) args.push('--level', options.level); if (options.source) args.push('--source', options.source); // For follow mode, use Infinity timeout so process doesn't get killed // For non-follow mode, use default 30s timeout const output = await this.execTilt(args, { timeout: options.follow ? Infinity : 30000, maxBuffer: 50 * 1024 * 1024, // 50MB for logs }); // In-process tailing (safe, no shell) if (options.tailLines && !options.follow) { return this.tailLines(output, options.tailLines); } return output; } /** * Trigger a manual update for a resource * * @param resourceName - Name of the resource to trigger */ async trigger(resourceName: string): Promise<void> { const args = [ 'trigger', resourceName, '--port', this.port.toString(), '--host', this.host, ]; await this.execTilt(args); } /** * Enable a resource * * @param resourceName - Name of the resource to enable */ async enable(resourceName: string): Promise<void> { const args = [ 'enable', resourceName, '--port', this.port.toString(), '--host', this.host, ]; await this.execTilt(args); } /** * Disable a resource * * @param resourceName - Name of the resource to disable */ async disable(resourceName: string): Promise<void> { const args = [ 'disable', resourceName, '--port', this.port.toString(), '--host', this.host, ]; await this.execTilt(args); } /** * Get current Tiltfile args * * @returns Array of current Tiltfile args */ async getArgs(): Promise<string[]> { const args = [ 'get', 'tiltfile/(Tiltfile)', '-o', 'json', '--port', this.port.toString(), '--host', this.host, ]; const output = await this.execTilt(args); const tiltfile = JSON.parse(output) as { spec?: { args?: string[] }; }; return tiltfile.spec?.args ?? []; } /** * Set or clear Tiltfile args * * @param tiltfileArgs - Optional array of args to set * @param clear - If true, clear all args */ async setArgs(tiltfileArgs?: string[], clear?: boolean): Promise<void> { // CRITICAL: Running `tilt args` without arguments opens an interactive editor // which would hang the process. We must have either --clear or args to set. if (!clear && (!tiltfileArgs || tiltfileArgs.length === 0)) { throw new Error( 'setArgs requires either clear=true or at least one argument. ' + 'Running tilt args without arguments opens an interactive editor.', ); } const args = ['args', '--port', this.port.toString(), '--host', this.host]; if (clear) { args.push('--clear'); } else if (tiltfileArgs && tiltfileArgs.length > 0) { args.push('--', ...tiltfileArgs); } await this.execTilt(args); } /** * Wait for resources to reach ready state * * @param resources - Optional array of resource names to wait for * @param timeout - Timeout in seconds * @param condition - Condition to wait for (default: Ready) * @returns Command output */ async wait( resources?: string[], timeout?: number, condition: string = 'Ready', ): Promise<string> { const args = [ 'wait', '--for', `condition=${condition}`, '--port', this.port.toString(), '--host', this.host, ]; if (timeout !== undefined) { args.push('--timeout', `${timeout}s`); } if (resources && resources.length > 0) { // Tilt wait requires uiresource/name format args.push(...resources.map((r) => `uiresource/${r}`)); } else { args.push('--all'); } // Use longer timeout for wait command const execTimeout = timeout ? (timeout + 5) * 1000 : 120000; return this.execTilt(args, { timeout: execTimeout }); } /** * Dump Tilt engine state * * @returns Engine state as string (JSON) */ async dumpEngine(): Promise<string> { const args = [ 'dump', 'engine', '--port', this.port.toString(), '--host', this.host, ]; return this.execTilt(args); } /** * Tail lines in-process (NO shell pipes) * Cross-platform, deterministic, secure * * @param text - Full text to tail * @param count - Number of lines to keep from end * @returns Tailed text */ tailLines(text: string, count: number): string { if (count <= 0) return ''; const lines = text.split('\n'); // Handle trailing newline: if text ends with \n, split creates an extra empty element // We need to preserve this for correct behavior const hasTrailingNewline = text.endsWith('\n'); const effectiveLines = hasTrailingNewline ? lines.slice(0, -1) : lines; const startIndex = Math.max(0, effectiveLines.length - count); const result = effectiveLines.slice(startIndex).join('\n'); return hasTrailingNewline ? `${result}\n` : result; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/0xBigBoss/tilt-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server