mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-05-07 05:34:40 +00:00
Introduce a new `DeployAlert` interface and related components to provide visual feedback on build and deployment stages. This includes status updates for Vercel and Netlify deployments, with progress visualization and error handling. The changes enhance user experience by offering real-time updates during the deployment process.
556 lines
16 KiB
TypeScript
556 lines
16 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) => {
|
|
console.error('Action 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');
|
|
}
|
|
|
|
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 Execute Shell Command`, resp?.output || 'No Output Available');
|
|
}
|
|
}
|
|
|
|
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 = '';
|
|
buildProcess.output.pipeTo(
|
|
new WritableStream({
|
|
write(data) {
|
|
output += data;
|
|
},
|
|
}),
|
|
);
|
|
|
|
const exitCode = await buildProcess.exit;
|
|
|
|
if (exitCode !== 0) {
|
|
// 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'];
|
|
|
|
let buildDir = '';
|
|
|
|
// 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;
|
|
logger.debug(`Found build directory: ${buildDir}`);
|
|
break;
|
|
} catch (error) {
|
|
// Directory doesn't exist, try the next one
|
|
logger.debug(`Build directory ${dir} not found, trying next option. ${error}`);
|
|
}
|
|
}
|
|
|
|
// If no build directory was found, use the default (dist)
|
|
if (!buildDir) {
|
|
buildDir = nodePath.join(webcontainer.workdir, 'dist');
|
|
logger.debug(`No build directory found, defaulting to: ${buildDir}`);
|
|
}
|
|
|
|
return {
|
|
path: buildDir,
|
|
exitCode,
|
|
output,
|
|
};
|
|
}
|
|
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';
|
|
},
|
|
): 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',
|
|
});
|
|
}
|
|
}
|