From e94fd0cbc8ca8f99db195dfb6d545f51005ba5a1 Mon Sep 17 00:00:00 2001 From: embire2 Date: Thu, 5 Feb 2026 14:15:48 +0200 Subject: [PATCH] fix netlify deploy output and uploads --- app/components/deploy/GitHubDeploy.client.tsx | 7 +- app/components/deploy/GitLabDeploy.client.tsx | 7 +- .../deploy/NetlifyDeploy.client.tsx | 9 ++- app/components/deploy/VercelDeploy.client.tsx | 9 ++- app/components/deploy/deployUtils.ts | 15 ++++ app/lib/runtime/action-runner.ts | 23 +++++- app/routes/api.netlify-deploy.ts | 75 ++++++++++++++----- 7 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 app/components/deploy/deployUtils.ts diff --git a/app/components/deploy/GitHubDeploy.client.tsx b/app/components/deploy/GitHubDeploy.client.tsx index d49f4278de..313d901cc6 100644 --- a/app/components/deploy/GitHubDeploy.client.tsx +++ b/app/components/deploy/GitHubDeploy.client.tsx @@ -7,6 +7,7 @@ import { useState } from 'react'; import type { ActionCallbackData } from '~/lib/runtime/message-parser'; import { chatId } from '~/lib/persistence/useChatHistory'; import { getLocalStorage } from '~/lib/persistence/localStorage'; +import { formatBuildFailureOutput } from './deployUtils'; export function useGitHubDeploy() { const [isDeploying, setIsDeploying] = useState(false); @@ -65,10 +66,12 @@ export function useGitHubDeploy() { // Then run it await artifact.runner.runAction(actionData); - if (!artifact.runner.buildOutput) { + const buildOutput = artifact.runner.buildOutput; + + if (!buildOutput || buildOutput.exitCode !== 0) { // Notify that build failed deployArtifact.runner.handleDeployAction('building', 'failed', { - error: 'Build failed. Check the terminal for details.', + error: formatBuildFailureOutput(buildOutput?.output), source: 'github', }); throw new Error('Build failed'); diff --git a/app/components/deploy/GitLabDeploy.client.tsx b/app/components/deploy/GitLabDeploy.client.tsx index 1173bac8a6..92bd274d79 100644 --- a/app/components/deploy/GitLabDeploy.client.tsx +++ b/app/components/deploy/GitLabDeploy.client.tsx @@ -7,6 +7,7 @@ import { useState } from 'react'; import type { ActionCallbackData } from '~/lib/runtime/message-parser'; import { chatId } from '~/lib/persistence/useChatHistory'; import { getLocalStorage } from '~/lib/persistence/localStorage'; +import { formatBuildFailureOutput } from './deployUtils'; export function useGitLabDeploy() { const [isDeploying, setIsDeploying] = useState(false); @@ -65,10 +66,12 @@ export function useGitLabDeploy() { // Then run it await artifact.runner.runAction(actionData); - if (!artifact.runner.buildOutput) { + const buildOutput = artifact.runner.buildOutput; + + if (!buildOutput || buildOutput.exitCode !== 0) { // Notify that build failed deployArtifact.runner.handleDeployAction('building', 'failed', { - error: 'Build failed. Check the terminal for details.', + error: formatBuildFailureOutput(buildOutput?.output), source: 'gitlab', }); throw new Error('Build failed'); diff --git a/app/components/deploy/NetlifyDeploy.client.tsx b/app/components/deploy/NetlifyDeploy.client.tsx index 327efba3a9..2c0a71326f 100644 --- a/app/components/deploy/NetlifyDeploy.client.tsx +++ b/app/components/deploy/NetlifyDeploy.client.tsx @@ -7,6 +7,7 @@ import { path } from '~/utils/path'; import { useState } from 'react'; import type { ActionCallbackData } from '~/lib/runtime/message-parser'; import { chatId } from '~/lib/persistence/useChatHistory'; +import { formatBuildFailureOutput } from './deployUtils'; export function useNetlifyDeploy() { const [isDeploying, setIsDeploying] = useState(false); @@ -65,10 +66,12 @@ export function useNetlifyDeploy() { // Then run it await artifact.runner.runAction(actionData); - if (!artifact.runner.buildOutput) { + const buildOutput = artifact.runner.buildOutput; + + if (!buildOutput || buildOutput.exitCode !== 0) { // Notify that build failed deployArtifact.runner.handleDeployAction('building', 'failed', { - error: 'Build failed. Check the terminal for details.', + error: formatBuildFailureOutput(buildOutput?.output), source: 'netlify', }); throw new Error('Build failed'); @@ -81,7 +84,7 @@ export function useNetlifyDeploy() { const container = await webcontainer; // Remove /home/project from buildPath if it exists - const buildPath = artifact.runner.buildOutput.path.replace('/home/project', ''); + const buildPath = buildOutput.path.replace('/home/project', ''); console.log('Original buildPath', buildPath); diff --git a/app/components/deploy/VercelDeploy.client.tsx b/app/components/deploy/VercelDeploy.client.tsx index 46d2d415b4..b98f1429ba 100644 --- a/app/components/deploy/VercelDeploy.client.tsx +++ b/app/components/deploy/VercelDeploy.client.tsx @@ -7,6 +7,7 @@ import { path } from '~/utils/path'; import { useState } from 'react'; import type { ActionCallbackData } from '~/lib/runtime/message-parser'; import { chatId } from '~/lib/persistence/useChatHistory'; +import { formatBuildFailureOutput } from './deployUtils'; export function useVercelDeploy() { const [isDeploying, setIsDeploying] = useState(false); @@ -64,10 +65,12 @@ export function useVercelDeploy() { // Then run it await artifact.runner.runAction(actionData); - if (!artifact.runner.buildOutput) { + const buildOutput = artifact.runner.buildOutput; + + if (!buildOutput || buildOutput.exitCode !== 0) { // Notify that build failed deployArtifact.runner.handleDeployAction('building', 'failed', { - error: 'Build failed. Check the terminal for details.', + error: formatBuildFailureOutput(buildOutput?.output), source: 'vercel', }); throw new Error('Build failed'); @@ -80,7 +83,7 @@ export function useVercelDeploy() { const container = await webcontainer; // Remove /home/project from buildPath if it exists - const buildPath = artifact.runner.buildOutput.path.replace('/home/project', ''); + const buildPath = buildOutput.path.replace('/home/project', ''); // Check if the build path exists let finalBuildPath = buildPath; diff --git a/app/components/deploy/deployUtils.ts b/app/components/deploy/deployUtils.ts new file mode 100644 index 0000000000..e8019c2886 --- /dev/null +++ b/app/components/deploy/deployUtils.ts @@ -0,0 +1,15 @@ +const MAX_BUILD_OUTPUT_CHARS = 4000; + +export function formatBuildFailureOutput(output?: string) { + const trimmed = output?.trim(); + + if (!trimmed) { + return 'Build failed with no output captured.'; + } + + if (trimmed.length <= MAX_BUILD_OUTPUT_CHARS) { + return trimmed; + } + + return `Build output (truncated):\n${trimmed.slice(-MAX_BUILD_OUTPUT_CHARS)}`; +} diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index b14d3a89b0..64f5ee6d1b 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -395,7 +395,7 @@ export class ActionRunner { const buildProcess = await webcontainer.spawn('npm', ['run', 'build']); let output = ''; - buildProcess.output.pipeTo( + const outputPromise = buildProcess.output.pipeTo( new WritableStream({ write(data) { output += data; @@ -404,8 +404,21 @@ export class ActionRunner { ); 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', @@ -435,8 +448,6 @@ export class ActionRunner { // 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); @@ -455,11 +466,15 @@ export class ActionRunner { buildDir = nodePath.join(webcontainer.workdir, 'dist'); } - return { + const buildResult = { path: buildDir, exitCode, output, }; + + this.buildOutput = buildResult; + + return buildResult; } async handleSupabaseAction(action: SupabaseAction) { const { operation, content, filePath } = action; diff --git a/app/routes/api.netlify-deploy.ts b/app/routes/api.netlify-deploy.ts index 48543e97cc..40dd65eb34 100644 --- a/app/routes/api.netlify-deploy.ts +++ b/app/routes/api.netlify-deploy.ts @@ -8,6 +8,23 @@ interface DeployRequestBody { chatId: string; } +async function readNetlifyError(response: Response) { + try { + const contentType = response.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + const data = (await response.json()) as { message?: string; error?: string } | undefined; + return data?.message || data?.error || JSON.stringify(data); + } + + const text = await response.text(); + + return text; + } catch { + return undefined; + } +} + export async function action({ request }: ActionFunctionArgs) { try { const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string }; @@ -35,7 +52,11 @@ export async function action({ request }: ActionFunctionArgs) { }); if (!createSiteResponse.ok) { - return json({ error: 'Failed to create site' }, { status: 400 }); + const errorDetail = await readNetlifyError(createSiteResponse); + return json( + { error: `Failed to create site${errorDetail ? `: ${errorDetail}` : ''}` }, + { status: createSiteResponse.status }, + ); } const newSite = (await createSiteResponse.json()) as any; @@ -84,7 +105,11 @@ export async function action({ request }: ActionFunctionArgs) { }); if (!createSiteResponse.ok) { - return json({ error: 'Failed to create site' }, { status: 400 }); + const errorDetail = await readNetlifyError(createSiteResponse); + return json( + { error: `Failed to create site${errorDetail ? `: ${errorDetail}` : ''}` }, + { status: createSiteResponse.status }, + ); } const newSite = (await createSiteResponse.json()) as any; @@ -121,18 +146,22 @@ export async function action({ request }: ActionFunctionArgs) { skip_processing: false, draft: false, // Change this to false for production deployments function_schedules: [], - required: Object.keys(fileDigests), // Add this line framework: null, }), }); if (!deployResponse.ok) { - return json({ error: 'Failed to create deployment' }, { status: 400 }); + const errorDetail = await readNetlifyError(deployResponse); + return json( + { error: `Failed to create deployment${errorDetail ? `: ${errorDetail}` : ''}` }, + { status: deployResponse.status }, + ); } const deploy = (await deployResponse.json()) as any; let retryCount = 0; const maxRetries = 60; + let filesUploaded = false; // Poll until deploy is ready for file uploads while (retryCount < maxRetries) { @@ -142,12 +171,24 @@ export async function action({ request }: ActionFunctionArgs) { }, }); + if (!statusResponse.ok) { + const errorDetail = await readNetlifyError(statusResponse); + return json( + { error: `Failed to check deployment status${errorDetail ? `: ${errorDetail}` : ''}` }, + { status: statusResponse.status }, + ); + } + const status = (await statusResponse.json()) as any; - if (status.state === 'prepared' || status.state === 'uploaded') { + if (!filesUploaded && (status.state === 'prepared' || status.state === 'uploaded')) { // Upload all files regardless of required array for (const [filePath, content] of Object.entries(files)) { const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath; + const encodedPath = normalizedPath + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); let uploadSuccess = false; let uploadRetries = 0; @@ -155,7 +196,7 @@ export async function action({ request }: ActionFunctionArgs) { while (!uploadSuccess && uploadRetries < 3) { try { const uploadResponse = await fetch( - `https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`, + `https://api.netlify.com/api/v1/deploys/${deploy.id}/files${encodedPath}`, { method: 'PUT', headers: { @@ -184,21 +225,21 @@ export async function action({ request }: ActionFunctionArgs) { return json({ error: `Failed to upload file ${filePath}` }, { status: 500 }); } } + + filesUploaded = true; } if (status.state === 'ready') { // Only return after files are uploaded - if (Object.keys(files).length === 0 || status.summary?.status === 'ready') { - return json({ - success: true, - deploy: { - id: status.id, - state: status.state, - url: status.ssl_url || status.url, - }, - site: siteInfo, - }); - } + return json({ + success: true, + deploy: { + id: status.id, + state: status.state, + url: status.ssl_url || status.url, + }, + site: siteInfo, + }); } if (status.state === 'error') {