761 lines
23 KiB
TypeScript
761 lines
23 KiB
TypeScript
import type { WebContainer } from '@webcontainer/api';
|
|
import { path as nodePath } from '~/utils/path';
|
|
import { atom, map, type MapStore } from 'nanostores';
|
|
import type { ActionAlert, BoltAction, DeployAlert, FileHistory, SupabaseAction, SupabaseAlert } from '~/types/actions';
|
|
import { createScopedLogger } from '~/utils/logger';
|
|
import { unreachable } from '~/utils/unreachable';
|
|
import type { ActionCallbackData } from './message-parser';
|
|
import type { BoltShell } from '~/utils/shell';
|
|
|
|
const logger = createScopedLogger('ActionRunner');
|
|
|
|
export type ActionStatus = 'pending' | 'running' | 'complete' | 'aborted' | 'failed';
|
|
|
|
export type BaseActionState = BoltAction & {
|
|
status: Exclude<ActionStatus, 'failed'>;
|
|
abort: () => void;
|
|
executed: boolean;
|
|
abortSignal: AbortSignal;
|
|
};
|
|
|
|
export type FailedActionState = BoltAction &
|
|
Omit<BaseActionState, 'status'> & {
|
|
status: Extract<ActionStatus, 'failed'>;
|
|
error: string;
|
|
};
|
|
|
|
export type ActionState = BaseActionState | FailedActionState;
|
|
|
|
type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'abort' | 'executed'>>;
|
|
|
|
export type ActionStateUpdate =
|
|
| BaseActionUpdate
|
|
| (Omit<BaseActionUpdate, 'status'> & { status: 'failed'; error: string });
|
|
|
|
type ActionsMap = MapStore<Record<string, ActionState>>;
|
|
|
|
class ActionCommandError extends Error {
|
|
readonly _output: string;
|
|
readonly _header: string;
|
|
|
|
constructor(message: string, output: string) {
|
|
// Create a formatted message that includes both the error message and output
|
|
const formattedMessage = `Failed To Execute Shell Command: ${message}\n\nOutput:\n${output}`;
|
|
super(formattedMessage);
|
|
|
|
// Set the output separately so it can be accessed programmatically
|
|
this._header = message;
|
|
this._output = output;
|
|
|
|
// Maintain proper prototype chain
|
|
Object.setPrototypeOf(this, ActionCommandError.prototype);
|
|
|
|
// Set the name of the error for better debugging
|
|
this.name = 'ActionCommandError';
|
|
}
|
|
|
|
// Optional: Add a method to get just the terminal output
|
|
get output() {
|
|
return this._output;
|
|
}
|
|
get header() {
|
|
return this._header;
|
|
}
|
|
}
|
|
|
|
export class ActionRunner {
|
|
#webcontainer: Promise<WebContainer>;
|
|
#currentExecutionPromise: Promise<void> = Promise.resolve();
|
|
#shellTerminal: () => BoltShell;
|
|
runnerId = atom<string>(`${Date.now()}`);
|
|
actions: ActionsMap = map({});
|
|
onAlert?: (alert: ActionAlert) => void;
|
|
onSupabaseAlert?: (alert: SupabaseAlert) => void;
|
|
onDeployAlert?: (alert: DeployAlert) => void;
|
|
buildOutput?: { path: string; exitCode: number; output: string };
|
|
|
|
constructor(
|
|
webcontainerPromise: Promise<WebContainer>,
|
|
getShellTerminal: () => BoltShell,
|
|
onAlert?: (alert: ActionAlert) => void,
|
|
onSupabaseAlert?: (alert: SupabaseAlert) => void,
|
|
onDeployAlert?: (alert: DeployAlert) => void,
|
|
) {
|
|
this.#webcontainer = webcontainerPromise;
|
|
this.#shellTerminal = getShellTerminal;
|
|
this.onAlert = onAlert;
|
|
this.onSupabaseAlert = onSupabaseAlert;
|
|
this.onDeployAlert = onDeployAlert;
|
|
}
|
|
|
|
addAction(data: ActionCallbackData) {
|
|
const { actionId } = data;
|
|
|
|
const actions = this.actions.get();
|
|
const action = actions[actionId];
|
|
|
|
if (action) {
|
|
// action already added
|
|
return;
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
|
|
this.actions.setKey(actionId, {
|
|
...data.action,
|
|
status: 'pending',
|
|
executed: false,
|
|
abort: () => {
|
|
abortController.abort();
|
|
this.#updateAction(actionId, { status: 'aborted' });
|
|
},
|
|
abortSignal: abortController.signal,
|
|
});
|
|
|
|
this.#currentExecutionPromise.then(() => {
|
|
this.#updateAction(actionId, { status: 'running' });
|
|
});
|
|
}
|
|
|
|
async runAction(data: ActionCallbackData, isStreaming: boolean = false) {
|
|
const { actionId } = data;
|
|
const action = this.actions.get()[actionId];
|
|
|
|
if (!action) {
|
|
unreachable(`Action ${actionId} not found`);
|
|
}
|
|
|
|
if (action.executed) {
|
|
return; // No return value here
|
|
}
|
|
|
|
if (isStreaming && action.type !== 'file') {
|
|
return; // No return value here
|
|
}
|
|
|
|
this.#updateAction(actionId, { ...action, ...data.action, executed: !isStreaming });
|
|
|
|
this.#currentExecutionPromise = this.#currentExecutionPromise
|
|
.then(() => {
|
|
return this.#executeAction(actionId, isStreaming);
|
|
})
|
|
.catch((error) => {
|
|
logger.error('Action execution promise failed:', error);
|
|
});
|
|
|
|
await this.#currentExecutionPromise;
|
|
|
|
return;
|
|
}
|
|
|
|
async #executeAction(actionId: string, isStreaming: boolean = false) {
|
|
const action = this.actions.get()[actionId];
|
|
|
|
this.#updateAction(actionId, { status: 'running' });
|
|
|
|
try {
|
|
switch (action.type) {
|
|
case 'shell': {
|
|
await this.#runShellAction(action);
|
|
break;
|
|
}
|
|
case 'file': {
|
|
await this.#runFileAction(action);
|
|
break;
|
|
}
|
|
case 'supabase': {
|
|
try {
|
|
await this.handleSupabaseAction(action as SupabaseAction);
|
|
} catch (error: any) {
|
|
// Update action status
|
|
this.#updateAction(actionId, {
|
|
status: 'failed',
|
|
error: error instanceof Error ? error.message : 'Supabase action failed',
|
|
});
|
|
|
|
// Return early without re-throwing
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
case 'build': {
|
|
const buildOutput = await this.#runBuildAction(action);
|
|
|
|
// Store build output for deployment
|
|
this.buildOutput = buildOutput;
|
|
break;
|
|
}
|
|
case 'start': {
|
|
// making the start app non blocking
|
|
|
|
this.#runStartAction(action)
|
|
.then(() => this.#updateAction(actionId, { status: 'complete' }))
|
|
.catch((err: Error) => {
|
|
if (action.abortSignal.aborted) {
|
|
return;
|
|
}
|
|
|
|
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
|
|
logger.error(`[${action.type}]:Action failed\n\n`, err);
|
|
|
|
if (!(err instanceof ActionCommandError)) {
|
|
return;
|
|
}
|
|
|
|
this.onAlert?.({
|
|
type: 'error',
|
|
title: 'Dev Server Failed',
|
|
description: err.header,
|
|
content: err.output,
|
|
});
|
|
});
|
|
|
|
/*
|
|
* adding a delay to avoid any race condition between 2 start actions
|
|
* i am up for a better approach
|
|
*/
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.#updateAction(actionId, {
|
|
status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete',
|
|
});
|
|
} catch (error) {
|
|
if (action.abortSignal.aborted) {
|
|
return;
|
|
}
|
|
|
|
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
|
|
logger.error(`[${action.type}]:Action failed\n\n`, error);
|
|
|
|
if (!(error instanceof ActionCommandError)) {
|
|
return;
|
|
}
|
|
|
|
this.onAlert?.({
|
|
type: 'error',
|
|
title: 'Dev Server Failed',
|
|
description: error.header,
|
|
content: error.output,
|
|
});
|
|
|
|
// re-throw the error to be caught in the promise chain
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async #runShellAction(action: ActionState) {
|
|
if (action.type !== 'shell') {
|
|
unreachable('Expected shell action');
|
|
}
|
|
|
|
const shell = this.#shellTerminal();
|
|
await shell.ready();
|
|
|
|
if (!shell || !shell.terminal || !shell.process) {
|
|
unreachable('Shell terminal not found');
|
|
}
|
|
|
|
// Pre-validate command for common issues
|
|
const validationResult = await this.#validateShellCommand(action.content);
|
|
|
|
if (validationResult.shouldModify && validationResult.modifiedCommand) {
|
|
logger.debug(`Modified command: ${action.content} -> ${validationResult.modifiedCommand}`);
|
|
action.content = validationResult.modifiedCommand;
|
|
}
|
|
|
|
const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
|
|
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
|
|
action.abort();
|
|
});
|
|
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);
|
|
|
|
if (resp?.exitCode != 0) {
|
|
const enhancedError = this.#createEnhancedShellError(action.content, resp?.exitCode, resp?.output);
|
|
throw new ActionCommandError(enhancedError.title, enhancedError.details);
|
|
}
|
|
}
|
|
|
|
async #runStartAction(action: ActionState) {
|
|
if (action.type !== 'start') {
|
|
unreachable('Expected shell action');
|
|
}
|
|
|
|
if (!this.#shellTerminal) {
|
|
unreachable('Shell terminal not found');
|
|
}
|
|
|
|
const shell = this.#shellTerminal();
|
|
await shell.ready();
|
|
|
|
if (!shell || !shell.terminal || !shell.process) {
|
|
unreachable('Shell terminal not found');
|
|
}
|
|
|
|
const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
|
|
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
|
|
action.abort();
|
|
});
|
|
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);
|
|
|
|
if (resp?.exitCode != 0) {
|
|
throw new ActionCommandError('Failed To Start Application', resp?.output || 'No Output Available');
|
|
}
|
|
|
|
return resp;
|
|
}
|
|
|
|
async #runFileAction(action: ActionState) {
|
|
if (action.type !== 'file') {
|
|
unreachable('Expected file action');
|
|
}
|
|
|
|
const webcontainer = await this.#webcontainer;
|
|
const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
|
|
|
|
let folder = nodePath.dirname(relativePath);
|
|
|
|
// remove trailing slashes
|
|
folder = folder.replace(/\/+$/g, '');
|
|
|
|
if (folder !== '.') {
|
|
try {
|
|
await webcontainer.fs.mkdir(folder, { recursive: true });
|
|
logger.debug('Created folder', folder);
|
|
} catch (error) {
|
|
logger.error('Failed to create folder\n\n', error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await webcontainer.fs.writeFile(relativePath, action.content);
|
|
logger.debug(`File written ${relativePath}`);
|
|
} catch (error) {
|
|
logger.error('Failed to write file\n\n', error);
|
|
}
|
|
}
|
|
|
|
#updateAction(id: string, newState: ActionStateUpdate) {
|
|
const actions = this.actions.get();
|
|
|
|
this.actions.setKey(id, { ...actions[id], ...newState });
|
|
}
|
|
|
|
async getFileHistory(filePath: string): Promise<FileHistory | null> {
|
|
try {
|
|
const webcontainer = await this.#webcontainer;
|
|
const historyPath = this.#getHistoryPath(filePath);
|
|
const content = await webcontainer.fs.readFile(historyPath, 'utf-8');
|
|
|
|
return JSON.parse(content);
|
|
} catch (error) {
|
|
logger.error('Failed to get file history:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async saveFileHistory(filePath: string, history: FileHistory) {
|
|
// const webcontainer = await this.#webcontainer;
|
|
const historyPath = this.#getHistoryPath(filePath);
|
|
|
|
await this.#runFileAction({
|
|
type: 'file',
|
|
filePath: historyPath,
|
|
content: JSON.stringify(history),
|
|
changeSource: 'auto-save',
|
|
} as any);
|
|
}
|
|
|
|
#getHistoryPath(filePath: string) {
|
|
return nodePath.join('.history', filePath);
|
|
}
|
|
|
|
async #runBuildAction(action: ActionState) {
|
|
if (action.type !== 'build') {
|
|
unreachable('Expected build action');
|
|
}
|
|
|
|
// Trigger build started alert
|
|
this.onDeployAlert?.({
|
|
type: 'info',
|
|
title: 'Building Application',
|
|
description: 'Building your application...',
|
|
stage: 'building',
|
|
buildStatus: 'running',
|
|
deployStatus: 'pending',
|
|
source: 'netlify',
|
|
});
|
|
|
|
const webcontainer = await this.#webcontainer;
|
|
|
|
// Create a new terminal specifically for the build
|
|
const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
|
|
|
|
let output = '';
|
|
const outputPromise = buildProcess.output.pipeTo(
|
|
new WritableStream({
|
|
write(data) {
|
|
output += data;
|
|
},
|
|
}),
|
|
);
|
|
|
|
const exitCode = await buildProcess.exit;
|
|
await outputPromise.catch(() => {
|
|
// Ignore output piping errors; we still have whatever was captured
|
|
});
|
|
|
|
let buildDir = '';
|
|
|
|
if (exitCode !== 0) {
|
|
const buildResult = {
|
|
path: buildDir,
|
|
exitCode,
|
|
output,
|
|
};
|
|
|
|
this.buildOutput = buildResult;
|
|
|
|
// Trigger build failed alert
|
|
this.onDeployAlert?.({
|
|
type: 'error',
|
|
title: 'Build Failed',
|
|
description: 'Your application build failed',
|
|
content: output || 'No build output available',
|
|
stage: 'building',
|
|
buildStatus: 'failed',
|
|
deployStatus: 'pending',
|
|
source: 'netlify',
|
|
});
|
|
|
|
throw new ActionCommandError('Build Failed', output || 'No Output Available');
|
|
}
|
|
|
|
// Trigger build success alert
|
|
this.onDeployAlert?.({
|
|
type: 'success',
|
|
title: 'Build Completed',
|
|
description: 'Your application was built successfully',
|
|
stage: 'deploying',
|
|
buildStatus: 'complete',
|
|
deployStatus: 'running',
|
|
source: 'netlify',
|
|
});
|
|
|
|
// Check for common build directories
|
|
const commonBuildDirs = ['dist', 'build', 'out', 'output', '.next', 'public'];
|
|
|
|
// Try to find the first existing build directory
|
|
for (const dir of commonBuildDirs) {
|
|
const dirPath = nodePath.join(webcontainer.workdir, dir);
|
|
|
|
try {
|
|
await webcontainer.fs.readdir(dirPath);
|
|
buildDir = dirPath;
|
|
break;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// If no build directory was found, use the default (dist)
|
|
if (!buildDir) {
|
|
buildDir = nodePath.join(webcontainer.workdir, 'dist');
|
|
}
|
|
|
|
const buildResult = {
|
|
path: buildDir,
|
|
exitCode,
|
|
output,
|
|
};
|
|
|
|
this.buildOutput = buildResult;
|
|
|
|
return buildResult;
|
|
}
|
|
async handleSupabaseAction(action: SupabaseAction) {
|
|
const { operation, content, filePath } = action;
|
|
logger.debug('[Supabase Action]:', { operation, filePath, content });
|
|
|
|
switch (operation) {
|
|
case 'migration':
|
|
if (!filePath) {
|
|
throw new Error('Migration requires a filePath');
|
|
}
|
|
|
|
// Show alert for migration action
|
|
this.onSupabaseAlert?.({
|
|
type: 'info',
|
|
title: 'Supabase Migration',
|
|
description: `Create migration file: ${filePath}`,
|
|
content,
|
|
source: 'supabase',
|
|
});
|
|
|
|
// Only create the migration file
|
|
await this.#runFileAction({
|
|
type: 'file',
|
|
filePath,
|
|
content,
|
|
changeSource: 'supabase',
|
|
} as any);
|
|
return { success: true };
|
|
|
|
case 'query': {
|
|
// Always show the alert and let the SupabaseAlert component handle connection state
|
|
this.onSupabaseAlert?.({
|
|
type: 'info',
|
|
title: 'Supabase Query',
|
|
description: 'Execute database query',
|
|
content,
|
|
source: 'supabase',
|
|
});
|
|
|
|
// The actual execution will be triggered from SupabaseChatAlert
|
|
return { pending: true };
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown operation: ${operation}`);
|
|
}
|
|
}
|
|
|
|
// Add this method declaration to the class
|
|
handleDeployAction(
|
|
stage: 'building' | 'deploying' | 'complete',
|
|
status: ActionStatus,
|
|
details?: {
|
|
url?: string;
|
|
error?: string;
|
|
source?: 'netlify' | 'vercel' | 'github' | 'gitlab';
|
|
},
|
|
): void {
|
|
if (!this.onDeployAlert) {
|
|
logger.debug('No deploy alert handler registered');
|
|
return;
|
|
}
|
|
|
|
const alertType = status === 'failed' ? 'error' : status === 'complete' ? 'success' : 'info';
|
|
|
|
const title =
|
|
stage === 'building'
|
|
? 'Building Application'
|
|
: stage === 'deploying'
|
|
? 'Deploying Application'
|
|
: 'Deployment Complete';
|
|
|
|
const description =
|
|
status === 'failed'
|
|
? `${stage === 'building' ? 'Build' : 'Deployment'} failed`
|
|
: status === 'running'
|
|
? `${stage === 'building' ? 'Building' : 'Deploying'} your application...`
|
|
: status === 'complete'
|
|
? `${stage === 'building' ? 'Build' : 'Deployment'} completed successfully`
|
|
: `Preparing to ${stage === 'building' ? 'build' : 'deploy'} your application`;
|
|
|
|
const buildStatus =
|
|
stage === 'building' ? status : stage === 'deploying' || stage === 'complete' ? 'complete' : 'pending';
|
|
|
|
const deployStatus = stage === 'building' ? 'pending' : status;
|
|
|
|
this.onDeployAlert({
|
|
type: alertType,
|
|
title,
|
|
description,
|
|
content: details?.error || '',
|
|
url: details?.url,
|
|
stage,
|
|
buildStatus: buildStatus as any,
|
|
deployStatus: deployStatus as any,
|
|
source: details?.source || 'netlify',
|
|
});
|
|
}
|
|
|
|
async #validateShellCommand(command: string): Promise<{
|
|
shouldModify: boolean;
|
|
modifiedCommand?: string;
|
|
warning?: string;
|
|
}> {
|
|
const trimmedCommand = command.trim();
|
|
|
|
// Handle rm commands that might fail due to missing files
|
|
if (trimmedCommand.startsWith('rm ') && !trimmedCommand.includes(' -f')) {
|
|
const rmMatch = trimmedCommand.match(/^rm\s+(.+)$/);
|
|
|
|
if (rmMatch) {
|
|
const filePaths = rmMatch[1].split(/\s+/);
|
|
|
|
// Check if any of the files exist using WebContainer
|
|
try {
|
|
const webcontainer = await this.#webcontainer;
|
|
const existingFiles = [];
|
|
|
|
for (const filePath of filePaths) {
|
|
if (filePath.startsWith('-')) {
|
|
continue;
|
|
} // Skip flags
|
|
|
|
try {
|
|
await webcontainer.fs.readFile(filePath);
|
|
existingFiles.push(filePath);
|
|
} catch {
|
|
// File doesn't exist, skip it
|
|
}
|
|
}
|
|
|
|
if (existingFiles.length === 0) {
|
|
// No files exist, modify command to use -f flag to avoid error
|
|
return {
|
|
shouldModify: true,
|
|
modifiedCommand: `rm -f ${filePaths.join(' ')}`,
|
|
warning: 'Added -f flag to rm command as target files do not exist',
|
|
};
|
|
} else if (existingFiles.length < filePaths.length) {
|
|
// Some files don't exist, modify to only remove existing ones with -f for safety
|
|
return {
|
|
shouldModify: true,
|
|
modifiedCommand: `rm -f ${filePaths.join(' ')}`,
|
|
warning: 'Added -f flag to rm command as some target files do not exist',
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.debug('Could not validate rm command files:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle cd commands to non-existent directories
|
|
if (trimmedCommand.startsWith('cd ')) {
|
|
const cdMatch = trimmedCommand.match(/^cd\s+(.+)$/);
|
|
|
|
if (cdMatch) {
|
|
const targetDir = cdMatch[1].trim();
|
|
|
|
try {
|
|
const webcontainer = await this.#webcontainer;
|
|
await webcontainer.fs.readdir(targetDir);
|
|
} catch {
|
|
return {
|
|
shouldModify: true,
|
|
modifiedCommand: `mkdir -p ${targetDir} && cd ${targetDir}`,
|
|
warning: 'Directory does not exist, created it first',
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle cp/mv commands with missing source files
|
|
if (trimmedCommand.match(/^(cp|mv)\s+/)) {
|
|
const parts = trimmedCommand.split(/\s+/);
|
|
|
|
if (parts.length >= 3) {
|
|
const sourceFile = parts[1];
|
|
|
|
try {
|
|
const webcontainer = await this.#webcontainer;
|
|
await webcontainer.fs.readFile(sourceFile);
|
|
} catch {
|
|
return {
|
|
shouldModify: false,
|
|
warning: `Source file '${sourceFile}' does not exist`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { shouldModify: false };
|
|
}
|
|
|
|
#createEnhancedShellError(
|
|
command: string,
|
|
exitCode: number | undefined,
|
|
output: string | undefined,
|
|
): {
|
|
title: string;
|
|
details: string;
|
|
} {
|
|
const trimmedCommand = command.trim();
|
|
const firstWord = trimmedCommand.split(/\s+/)[0];
|
|
|
|
// Common error patterns and their explanations
|
|
const errorPatterns = [
|
|
{
|
|
pattern: /cannot remove.*No such file or directory/,
|
|
title: 'File Not Found',
|
|
getMessage: () => {
|
|
const fileMatch = output?.match(/'([^']+)'/);
|
|
const fileName = fileMatch ? fileMatch[1] : 'file';
|
|
|
|
return `The file '${fileName}' does not exist and cannot be removed.\n\nSuggestion: Use 'ls' to check what files exist, or use 'rm -f' to ignore missing files.`;
|
|
},
|
|
},
|
|
{
|
|
pattern: /No such file or directory/,
|
|
title: 'File or Directory Not Found',
|
|
getMessage: () => {
|
|
if (trimmedCommand.startsWith('cd ')) {
|
|
const dirMatch = trimmedCommand.match(/cd\s+(.+)/);
|
|
const dirName = dirMatch ? dirMatch[1] : 'directory';
|
|
|
|
return `The directory '${dirName}' does not exist.\n\nSuggestion: Use 'mkdir -p ${dirName}' to create it first, or check available directories with 'ls'.`;
|
|
}
|
|
|
|
return `The specified file or directory does not exist.\n\nSuggestion: Check the path and use 'ls' to see available files.`;
|
|
},
|
|
},
|
|
{
|
|
pattern: /Permission denied/,
|
|
title: 'Permission Denied',
|
|
getMessage: () =>
|
|
`Permission denied for '${firstWord}'.\n\nSuggestion: The file may not be executable. Try 'chmod +x filename' first.`,
|
|
},
|
|
{
|
|
pattern: /command not found/,
|
|
title: 'Command Not Found',
|
|
getMessage: () =>
|
|
`The command '${firstWord}' is not available in WebContainer.\n\nSuggestion: Check available commands or use a package manager to install it.`,
|
|
},
|
|
{
|
|
pattern: /Is a directory/,
|
|
title: 'Target is a Directory',
|
|
getMessage: () =>
|
|
`Cannot perform this operation - target is a directory.\n\nSuggestion: Use 'ls' to list directory contents or add appropriate flags.`,
|
|
},
|
|
{
|
|
pattern: /File exists/,
|
|
title: 'File Already Exists',
|
|
getMessage: () => `File already exists.\n\nSuggestion: Use a different name or add '-f' flag to overwrite.`,
|
|
},
|
|
];
|
|
|
|
// Try to match known error patterns
|
|
for (const errorPattern of errorPatterns) {
|
|
if (output && errorPattern.pattern.test(output)) {
|
|
return {
|
|
title: errorPattern.title,
|
|
details: errorPattern.getMessage(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Generic error with suggestions based on command type
|
|
let suggestion = '';
|
|
|
|
if (trimmedCommand.startsWith('npm ')) {
|
|
suggestion = '\n\nSuggestion: Try running "npm install" first or check package.json.';
|
|
} else if (trimmedCommand.startsWith('git ')) {
|
|
suggestion = "\n\nSuggestion: Check if you're in a git repository or if remote is configured.";
|
|
} else if (trimmedCommand.match(/^(ls|cat|rm|cp|mv)/)) {
|
|
suggestion = '\n\nSuggestion: Check file paths and use "ls" to see available files.';
|
|
}
|
|
|
|
return {
|
|
title: `Command Failed (exit code: ${exitCode})`,
|
|
details: `Command: ${trimmedCommand}\n\nOutput: ${output || 'No output available'}${suggestion}`,
|
|
};
|
|
}
|
|
}
|