/** * Shell Command * * Interactive SQL REPL for postgres.do databases * * @module cli/commands/shell */ import * as readline from 'node:readline' import { requireToken } from '../cli-auth.js' import { getApiUrl, addRecentDatabase } from '../config.js' import { printResult, printInfo, formatDuration, type OutputOptions } from '../output.js' import { formatTable } from '../formatting.js' /** * Shell command options */ export interface ShellOptions extends OutputOptions { /** Database name or ID */ name: string /** Execute a single command and exit */ command?: string | undefined /** Read commands from file */ file?: string | undefined /** Enable timing output */ timing?: boolean | undefined /** Output format for query results */ outputFormat?: 'table' | 'csv' | 'json' | 'aligned' | undefined /** API URL override */ apiUrl?: string | undefined } /** * Query result from API */ interface QueryResult { rows: Record[] fields: { name: string; dataTypeID: number }[] rowCount: number command: string duration?: number } /** * Shell state */ interface ShellState { token: string apiUrl: string database: string multiLineBuffer: string[] isMultiLine: boolean timing: boolean outputFormat: 'table' | 'csv' | 'json' | 'aligned' history: string[] } /** * Run shell command */ export async function runShell(options: ShellOptions): Promise { const { name, command, file, timing = false, outputFormat = 'table' } = options if (!name) { printResult({ success: false, error: 'Database name is required', }, options) process.exit(1) } try { const token = requireToken(options.apiUrl) const apiUrl = getApiUrl(options.apiUrl) // Add to recent databases addRecentDatabase(name) // Execute single command if provided if (command) { await executeSingleCommand(name, command, token, apiUrl, options) return } // Execute file if provided if (file) { await executeFile(name, file, token, apiUrl, options) return } // Start interactive REPL await startRepl(name, token, apiUrl, { ...options, timing: timing ?? false, outputFormat: outputFormat ?? 'table', }) } catch (error) { printResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error starting shell', }, options) process.exit(1) } } /** * Execute a single SQL command */ async function executeSingleCommand( database: string, sql: string, token: string, apiUrl: string, options: ShellOptions ): Promise { const result = await executeQuery(database, sql, token, apiUrl) if ('error' in result) { printResult({ success: false, error: result.error, }, options) process.exit(1) } printQueryResult(result, { ...(options.format !== undefined ? { format: options.format } : {}) }) } /** * Execute SQL commands from a file */ async function executeFile( database: string, filePath: string, token: string, apiUrl: string, options: ShellOptions ): Promise { const { readFileSync, existsSync } = await import('node:fs') const { resolve } = await import('node:path') const fullPath = resolve(process.cwd(), filePath) if (!existsSync(fullPath)) { printResult({ success: false, error: `File not found: ${fullPath}`, }, options) process.exit(1) } const content = readFileSync(fullPath, 'utf-8') // Split into statements (simple split on semicolons) const statements = content .split(';') .map((s) => s.trim()) .filter((s) => s.length > 0 && !s.startsWith('--')) printInfo(`Executing ${statements.length} statement(s)...`, options) let successCount = 0 let errorCount = 0 for (const statement of statements) { const result = await executeQuery(database, statement, token, apiUrl) if ('error' in result) { console.error(`Error: ${result.error}`) console.error(` Statement: ${statement.substring(0, 50)}...`) errorCount++ } else { if (options.verbose) { printQueryResult(result, { ...(options.format !== undefined ? { format: options.format } : {}) }) } successCount++ } } console.log('') printInfo(`Completed: ${successCount} succeeded, ${errorCount} failed`, options) if (errorCount > 0) { process.exit(1) } } /** * Start interactive REPL */ async function startRepl( database: string, token: string, apiUrl: string, options: ShellOptions & { timing: boolean; outputFormat: 'table' | 'csv' | 'json' | 'aligned' } ): Promise { const state: ShellState = { token, apiUrl, database, multiLineBuffer: [], isMultiLine: false, timing: options.timing, outputFormat: options.outputFormat, history: [], } console.log(`postgres.do shell - connected to '${database}'`) console.log('Type "\\?" for help, "\\q" to quit') console.log('') const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: getPrompt(state), terminal: true, historySize: 100, }) rl.on('line', async (line) => { await handleLine(line, state, rl) }) rl.on('close', () => { console.log('\nGoodbye!') process.exit(0) }) rl.prompt() } /** * Handle a line of input */ async function handleLine( line: string, state: ShellState, rl: readline.Interface ): Promise { const trimmedLine = line.trim() // Handle backslash commands if (trimmedLine.startsWith('\\') && !state.isMultiLine) { await handleBackslashCommand(trimmedLine, state, rl) rl.setPrompt(getPrompt(state)) rl.prompt() return } // Handle multi-line input if (state.isMultiLine || !trimmedLine.endsWith(';')) { state.multiLineBuffer.push(line) state.isMultiLine = !trimmedLine.endsWith(';') if (state.isMultiLine) { rl.setPrompt(getPrompt(state)) rl.prompt() return } } // Execute the query const sql = state.isMultiLine || state.multiLineBuffer.length > 0 ? [...state.multiLineBuffer, line].join('\n') : line state.multiLineBuffer = [] state.isMultiLine = false if (sql.trim().length === 0) { rl.setPrompt(getPrompt(state)) rl.prompt() return } // Add to history state.history.push(sql) const startTime = Date.now() const result = await executeQuery(state.database, sql, state.token, state.apiUrl) const duration = Date.now() - startTime if ('error' in result) { console.error(`ERROR: ${result.error}`) } else { const printOptions: { format?: string } = {} if (state.outputFormat === 'json') printOptions.format = 'json' printQueryResult(result, printOptions) if (state.timing) { console.log(`Time: ${formatDuration(duration)}`) } } console.log('') rl.setPrompt(getPrompt(state)) rl.prompt() } /** * Handle backslash commands */ async function handleBackslashCommand( command: string, state: ShellState, rl: readline.Interface ): Promise { const [cmd, ...args] = command.split(/\s+/) switch (cmd) { case '\\q': case '\\quit': rl.close() break case '\\?': case '\\help': printHelp() break case '\\d': case '\\dt': { // List tables const result = await executeQuery( state.database, `SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name`, state.token, state.apiUrl ) if ('error' in result) { console.error(`ERROR: ${result.error}`) } else { printQueryResult(result, { format: 'table' }) } break } case '\\d+': { // Describe table const tableName = args[0] if (!tableName) { console.error('Usage: \\d+ ') break } const result = await executeQuery( state.database, `SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}' ORDER BY ordinal_position`, state.token, state.apiUrl ) if ('error' in result) { console.error(`ERROR: ${result.error}`) } else { console.log(`Table "${tableName}":`) printQueryResult(result, { format: 'table' }) } break } case '\\di': { // List indexes const result = await executeQuery( state.database, `SELECT indexname, tablename, indexdef FROM pg_indexes WHERE schemaname = 'public' ORDER BY tablename, indexname`, state.token, state.apiUrl ) if ('error' in result) { console.error(`ERROR: ${result.error}`) } else { printQueryResult(result, { format: 'table' }) } break } case '\\l': case '\\list': console.log(`Connected to: ${state.database}`) break case '\\timing': state.timing = !state.timing console.log(`Timing is ${state.timing ? 'on' : 'off'}.`) break case '\\x': state.outputFormat = state.outputFormat === 'aligned' ? 'table' : 'aligned' console.log(`Expanded display is ${state.outputFormat === 'aligned' ? 'on' : 'off'}.`) break case '\\o': const format = args[0] as typeof state.outputFormat if (format && ['table', 'csv', 'json', 'aligned'].includes(format)) { state.outputFormat = format console.log(`Output format: ${state.outputFormat}`) } else { console.log(`Current output format: ${state.outputFormat}`) console.log('Usage: \\o [table|csv|json|aligned]') } break case '\\i': { // Include file const filePath = args[0] if (!filePath) { console.error('Usage: \\i ') break } try { const { readFileSync, existsSync } = await import('node:fs') const { resolve } = await import('node:path') const fullPath = resolve(process.cwd(), filePath) if (!existsSync(fullPath)) { console.error(`File not found: ${fullPath}`) break } const content = readFileSync(fullPath, 'utf-8') const statements = content.split(';').filter((s) => s.trim().length > 0) for (const statement of statements) { const result = await executeQuery(state.database, statement, state.token, state.apiUrl) if ('error' in result) { console.error(`ERROR: ${result.error}`) } else { const opts: { format?: string } = {} if (state.outputFormat === 'json') opts.format = 'json' printQueryResult(result, opts) } } } catch (error) { console.error(`Error reading file: ${error instanceof Error ? error.message : 'Unknown error'}`) } break } case '\\e': case '\\edit': console.log('Editor mode not supported in this version.') break case '\\!': { // Shell command const shellCmd = args.join(' ') if (!shellCmd) { console.error('Usage: \\! ') break } const { execSync } = await import('node:child_process') try { const output = execSync(shellCmd, { encoding: 'utf-8' }) console.log(output) } catch (error) { console.error(`Command failed: ${error instanceof Error ? error.message : 'Unknown error'}`) } break } default: console.error(`Unknown command: ${cmd}. Type \\? for help.`) } } /** * Execute a SQL query */ async function executeQuery( database: string, sql: string, token: string, apiUrl: string ): Promise { try { const response = await fetch(`${apiUrl}/v1/databases/${database}/query`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ sql }), }) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Query failed' })) return { error: (error as { message?: string; error?: string }).message || (error as { error?: string }).error || 'Query failed', } } return await response.json() as QueryResult } catch (error) { return { error: error instanceof Error ? error.message : 'Unknown error', } } } /** * Print query result */ function printQueryResult(result: QueryResult, options: { format?: string }): void { const { rows, fields, rowCount, command } = result // Handle non-SELECT commands if (command !== 'SELECT' && rows.length === 0) { console.log(`${command} ${rowCount}`) return } if (rows.length === 0) { console.log('(0 rows)') return } if (options.format === 'json') { console.log(JSON.stringify(rows, null, 2)) return } // Format as table const headers = fields.map((f) => f.name) const tableRows = rows.map((row) => headers.map((h) => formatCellValue(row[h])) ) console.log(formatTable(headers, tableRows)) console.log(`(${rowCount} row${rowCount === 1 ? '' : 's'})`) } /** * Format a cell value for display */ function formatCellValue(value: unknown): string { if (value === null) return 'NULL' if (value === undefined) return '' if (typeof value === 'object') return JSON.stringify(value) return String(value) } /** * Get prompt string */ function getPrompt(state: ShellState): string { if (state.isMultiLine) { return `${state.database}-> ` } return `${state.database}=> ` } /** * Print help */ function printHelp(): void { console.log(` General: \\q, \\quit Exit psql \\?, \\help Show this help Informational: \\d, \\dt List tables \\d+ Describe table \\di List indexes \\l, \\list Show current database Formatting: \\x Toggle expanded output \\o [format] Set output format (table|csv|json|aligned) \\timing Toggle query timing Input/Output: \\i Execute commands from file \\! Execute shell command Query Buffer: Type SQL ending with semicolon to execute Multi-line queries supported `) }