From 50dd74de071dba4ed90a42a25884a2e9c0128cc2 Mon Sep 17 00:00:00 2001 From: Stijnus <72551117+Stijnus@users.noreply.github.com> Date: Sat, 8 Mar 2025 20:37:56 +0100 Subject: [PATCH] fix: settings bugfix error building my application issue #1414 (#1436) * Fix: error building my application #1414 * fix for vite * Update vite.config.ts * Update root.tsx * fix the root.tsx and the debugtab * lm studio fix and fix for the api key * Update api.enhancer for prompt enhancement * bugfixes * Revert api.enhancer.ts back to original code * Update api.enhancer.ts * Update api.git-proxy.$.ts * Update api.git-proxy.$.ts * Update api.enhancer.ts --- .../tabs/connections/ConnectionsTab.tsx | 25 +- .../tabs/connections/GithubConnection.tsx | 2 +- .../tabs/connections/NetlifyConnection.tsx | 2 +- app/lib/hooks/useShortcuts.ts | 2 +- app/lib/modules/llm/providers/lmstudio.ts | 2 +- app/lib/modules/llm/providers/ollama.ts | 31 +- app/root.tsx | 7 +- app/routes/api.check-env-key.ts | 33 +- app/routes/api.deploy.ts | 12 +- app/routes/api.enhancer.ts | 18 +- app/routes/api.git-proxy.$.ts | 8 +- app/routes/api.health.ts | 20 +- app/routes/api.system.app-info.ts | 47 +- app/routes/api.system.git-info.ts | 168 ++--- app/routes/api.update.ts | 580 +----------------- package.json | 1 + pnpm-lock.yaml | 3 + vite.config.ts | 43 +- 18 files changed, 233 insertions(+), 771 deletions(-) diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx index 450d241a..72ff6434 100644 --- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx +++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx @@ -1,6 +1,19 @@ import { motion } from 'framer-motion'; -import { GithubConnection } from './GithubConnection'; -import { NetlifyConnection } from './NetlifyConnection'; +import React, { Suspense } from 'react'; + +// Use React.lazy for dynamic imports +const GithubConnection = React.lazy(() => import('./GithubConnection')); +const NetlifyConnection = React.lazy(() => import('./NetlifyConnection')); + +// Loading fallback component +const LoadingFallback = () => ( +
+
+
+ Loading connection... +
+
+); export default function ConnectionsTab() { return ( @@ -20,8 +33,12 @@ export default function ConnectionsTab() {

- - + }> + + + }> + +
); diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx index 36197f2e..789cf0b1 100644 --- a/app/components/@settings/tabs/connections/GithubConnection.tsx +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -66,7 +66,7 @@ interface GitHubConnection { stats?: GitHubStats; } -export function GithubConnection() { +export default function GithubConnection() { const [connection, setConnection] = useState({ user: null, token: '', diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx index 5881b761..d811602e 100644 --- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -13,7 +13,7 @@ import { } from '~/lib/stores/netlify'; import type { NetlifyUser } from '~/types/netlify'; -export function NetlifyConnection() { +export default function NetlifyConnection() { const connection = useStore(netlifyConnection); const connecting = useStore(isConnecting); const fetchingStats = useStore(isFetchingStats); diff --git a/app/lib/hooks/useShortcuts.ts b/app/lib/hooks/useShortcuts.ts index 39308fcd..447a97e3 100644 --- a/app/lib/hooks/useShortcuts.ts +++ b/app/lib/hooks/useShortcuts.ts @@ -41,7 +41,7 @@ export function useShortcuts(): void { } // Debug logging in development only - if (process.env.NODE_ENV === 'development') { + if (import.meta.env.DEV) { console.log('Key pressed:', { key: event.key, code: event.code, diff --git a/app/lib/modules/llm/providers/lmstudio.ts b/app/lib/modules/llm/providers/lmstudio.ts index 9dabc3eb..fe5b27cd 100644 --- a/app/lib/modules/llm/providers/lmstudio.ts +++ b/app/lib/modules/llm/providers/lmstudio.ts @@ -75,7 +75,7 @@ export default class LMStudioProvider extends BaseProvider { throw new Error('No baseUrl found for LMStudio provider'); } - const isDocker = process.env.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true'; + const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true'; if (typeof window === 'undefined') { baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl; diff --git a/app/lib/modules/llm/providers/ollama.ts b/app/lib/modules/llm/providers/ollama.ts index a3974ab3..e50ecae5 100644 --- a/app/lib/modules/llm/providers/ollama.ts +++ b/app/lib/modules/llm/providers/ollama.ts @@ -27,8 +27,6 @@ export interface OllamaApiResponse { models: OllamaModel[]; } -export const DEFAULT_NUM_CTX = process?.env?.DEFAULT_NUM_CTX ? parseInt(process.env.DEFAULT_NUM_CTX, 10) : 32768; - export default class OllamaProvider extends BaseProvider { name = 'Ollama'; getApiKeyLink = 'https://ollama.com/download'; @@ -41,6 +39,26 @@ export default class OllamaProvider extends BaseProvider { staticModels: ModelInfo[] = []; + private _convertEnvToRecord(env?: Env): Record { + if (!env) { + return {}; + } + + // Convert Env to a plain object with string values + return Object.entries(env).reduce( + (acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, + {} as Record, + ); + } + + getDefaultNumCtx(serverEnv?: Env): number { + const envRecord = this._convertEnvToRecord(serverEnv); + return envRecord.DEFAULT_NUM_CTX ? parseInt(envRecord.DEFAULT_NUM_CTX, 10) : 32768; + } + async getDynamicModels( apiKeys?: Record, settings?: IProviderSetting, @@ -81,6 +99,7 @@ export default class OllamaProvider extends BaseProvider { maxTokenAllowed: 8000, })); } + getModelInstance: (options: { model: string; serverEnv?: Env; @@ -88,10 +107,12 @@ export default class OllamaProvider extends BaseProvider { providerSettings?: Record; }) => LanguageModelV1 = (options) => { const { apiKeys, providerSettings, serverEnv, model } = options; + const envRecord = this._convertEnvToRecord(serverEnv); + let { baseUrl } = this.getProviderBaseUrlAndKey({ apiKeys, providerSettings: providerSettings?.[this.name], - serverEnv: serverEnv as any, + serverEnv: envRecord, defaultBaseUrlKey: 'OLLAMA_API_BASE_URL', defaultApiTokenKey: '', }); @@ -101,14 +122,14 @@ export default class OllamaProvider extends BaseProvider { throw new Error('No baseUrl found for OLLAMA provider'); } - const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || serverEnv?.RUNNING_IN_DOCKER === 'true'; + const isDocker = process?.env?.RUNNING_IN_DOCKER === 'true' || envRecord.RUNNING_IN_DOCKER === 'true'; baseUrl = isDocker ? baseUrl.replace('localhost', 'host.docker.internal') : baseUrl; baseUrl = isDocker ? baseUrl.replace('127.0.0.1', 'host.docker.internal') : baseUrl; logger.debug('Ollama Base Url used: ', baseUrl); const ollamaInstance = ollama(model, { - numCtx: DEFAULT_NUM_CTX, + numCtx: this.getDefaultNumCtx(serverEnv), }) as LanguageModelV1 & { config: any }; ollamaInstance.config.baseURL = `${baseUrl}/api`; diff --git a/app/root.tsx b/app/root.tsx index b49d7355..a7ccb285 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -8,6 +8,7 @@ import { createHead } from 'remix-island'; import { useEffect } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; +import { ClientOnly } from 'remix-utils/client-only'; import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url'; import globalStyles from './styles/index.scss?url'; @@ -72,11 +73,11 @@ export function Layout({ children }: { children: React.ReactNode }) { }, [theme]); return ( - - {children} + <> + {() => {children}} - + ); } diff --git a/app/routes/api.check-env-key.ts b/app/routes/api.check-env-key.ts index 14d21236..70f65bf0 100644 --- a/app/routes/api.check-env-key.ts +++ b/app/routes/api.check-env-key.ts @@ -1,16 +1,41 @@ import type { LoaderFunction } from '@remix-run/cloudflare'; -import { providerBaseUrlEnvKeys } from '~/utils/constants'; +import { LLMManager } from '~/lib/modules/llm/manager'; +import { getApiKeysFromCookie } from '~/lib/api/cookies'; export const loader: LoaderFunction = async ({ context, request }) => { const url = new URL(request.url); const provider = url.searchParams.get('provider'); - if (!provider || !providerBaseUrlEnvKeys[provider].apiTokenKey) { + if (!provider) { return Response.json({ isSet: false }); } - const envVarName = providerBaseUrlEnvKeys[provider].apiTokenKey; - const isSet = !!(process.env[envVarName] || (context?.cloudflare?.env as Record)?.[envVarName]); + const llmManager = LLMManager.getInstance(context?.cloudflare?.env as any); + const providerInstance = llmManager.getProvider(provider); + + if (!providerInstance || !providerInstance.config.apiTokenKey) { + return Response.json({ isSet: false }); + } + + const envVarName = providerInstance.config.apiTokenKey; + + // Get API keys from cookie + const cookieHeader = request.headers.get('Cookie'); + const apiKeys = getApiKeysFromCookie(cookieHeader); + + /* + * Check API key in order of precedence: + * 1. Client-side API keys (from cookies) + * 2. Server environment variables (from Cloudflare env) + * 3. Process environment variables (from .env.local) + * 4. LLMManager environment variables + */ + const isSet = !!( + apiKeys?.[provider] || + (context?.cloudflare?.env as Record)?.[envVarName] || + process.env[envVarName] || + llmManager.env[envVarName] + ); return Response.json({ isSet }); }; diff --git a/app/routes/api.deploy.ts b/app/routes/api.deploy.ts index 48543e97..0bc1c5e4 100644 --- a/app/routes/api.deploy.ts +++ b/app/routes/api.deploy.ts @@ -1,5 +1,4 @@ import { type ActionFunctionArgs, json } from '@remix-run/cloudflare'; -import crypto from 'crypto'; import type { NetlifySiteInfo } from '~/types/netlify'; interface DeployRequestBody { @@ -8,6 +7,15 @@ interface DeployRequestBody { chatId: string; } +async function sha1(message: string) { + const msgBuffer = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; +} + export async function action({ request }: ActionFunctionArgs) { try { const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string }; @@ -104,7 +112,7 @@ export async function action({ request }: ActionFunctionArgs) { for (const [filePath, content] of Object.entries(files)) { // Ensure file path starts with a forward slash const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath; - const hash = crypto.createHash('sha1').update(content).digest('hex'); + const hash = await sha1(content); fileDigests[normalizedPath] = hash; } diff --git a/app/routes/api.enhancer.ts b/app/routes/api.enhancer.ts index 4ab54f31..1e7bad5f 100644 --- a/app/routes/api.enhancer.ts +++ b/app/routes/api.enhancer.ts @@ -95,24 +95,28 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) { }, }); + // Handle streaming errors in a non-blocking way (async () => { - for await (const part of result.fullStream) { - if (part.type === 'error') { - const error: any = part.error; - logger.error(error); - - return; + try { + for await (const part of result.fullStream) { + if (part.type === 'error') { + const error: any = part.error; + logger.error('Streaming error:', error); + break; + } } + } catch (error) { + logger.error('Error processing stream:', error); } })(); + // Return the text stream directly since it's already text data return new Response(result.textStream, { status: 200, headers: { 'Content-Type': 'text/event-stream', Connection: 'keep-alive', 'Cache-Control': 'no-cache', - 'Text-Encoding': 'chunked', }, }); } catch (error: unknown) { diff --git a/app/routes/api.git-proxy.$.ts b/app/routes/api.git-proxy.$.ts index 45230520..a4e8f1e0 100644 --- a/app/routes/api.git-proxy.$.ts +++ b/app/routes/api.git-proxy.$.ts @@ -114,10 +114,14 @@ async function handleProxyRequest(request: Request, path: string | undefined) { redirect: 'follow', }; - // Add body and duplex option for non-GET/HEAD requests + // Add body for non-GET/HEAD requests if (!['GET', 'HEAD'].includes(request.method)) { fetchOptions.body = request.body; - fetchOptions.duplex = 'half'; // This fixes the "duplex option is required when sending a body" error + + /* + * Note: duplex property is removed to ensure TypeScript compatibility + * across different environments and versions + */ } // Forward the request to the target URL diff --git a/app/routes/api.health.ts b/app/routes/api.health.ts index 9d3bd839..e5f5a30d 100644 --- a/app/routes/api.health.ts +++ b/app/routes/api.health.ts @@ -1,18 +1,8 @@ -import type { LoaderFunctionArgs } from '@remix-run/node'; +import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare'; export const loader = async ({ request: _request }: LoaderFunctionArgs) => { - // Return a simple 200 OK response with some basic health information - return new Response( - JSON.stringify({ - status: 'healthy', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }, - ); + return json({ + status: 'healthy', + timestamp: new Date().toISOString(), + }); }; diff --git a/app/routes/api.system.app-info.ts b/app/routes/api.system.app-info.ts index d3ed0185..01953b68 100644 --- a/app/routes/api.system.app-info.ts +++ b/app/routes/api.system.app-info.ts @@ -1,6 +1,5 @@ import type { ActionFunctionArgs, LoaderFunction } from '@remix-run/cloudflare'; import { json } from '@remix-run/cloudflare'; -import { execSync } from 'child_process'; // These are injected by Vite at build time declare const __APP_VERSION: string; @@ -11,34 +10,24 @@ declare const __PKG_DEPENDENCIES: Record; declare const __PKG_DEV_DEPENDENCIES: Record; declare const __PKG_PEER_DEPENDENCIES: Record; declare const __PKG_OPTIONAL_DEPENDENCIES: Record; +declare const __COMMIT_HASH: string; +declare const __GIT_BRANCH: string; +declare const __GIT_COMMIT_TIME: string; +declare const __GIT_AUTHOR: string; +declare const __GIT_EMAIL: string; +declare const __GIT_REMOTE_URL: string; +declare const __GIT_REPO_NAME: string; const getGitInfo = () => { - try { - return { - commitHash: execSync('git rev-parse --short HEAD').toString().trim(), - branch: execSync('git rev-parse --abbrev-ref HEAD').toString().trim(), - commitTime: execSync('git log -1 --format=%cd').toString().trim(), - author: execSync('git log -1 --format=%an').toString().trim(), - email: execSync('git log -1 --format=%ae').toString().trim(), - remoteUrl: execSync('git config --get remote.origin.url').toString().trim(), - repoName: execSync('git config --get remote.origin.url') - .toString() - .trim() - .replace(/^.*github.com[:/]/, '') - .replace(/\.git$/, ''), - }; - } catch (error) { - console.error('Failed to get git info:', error); - return { - commitHash: 'unknown', - branch: 'unknown', - commitTime: 'unknown', - author: 'unknown', - email: 'unknown', - remoteUrl: 'unknown', - repoName: 'unknown', - }; - } + return { + commitHash: __COMMIT_HASH || 'unknown', + branch: __GIT_BRANCH || 'unknown', + commitTime: __GIT_COMMIT_TIME || 'unknown', + author: __GIT_AUTHOR || 'unknown', + email: __GIT_EMAIL || 'unknown', + remoteUrl: __GIT_REMOTE_URL || 'unknown', + repoName: __GIT_REPO_NAME || 'unknown', + }; }; const formatDependencies = ( @@ -60,11 +49,11 @@ const getAppResponse = () => { version: __APP_VERSION || '0.1.0', description: __PKG_DESCRIPTION || 'A DIY LLM interface', license: __PKG_LICENSE || 'MIT', - environment: process.env.NODE_ENV || 'development', + environment: 'cloudflare', gitInfo, timestamp: new Date().toISOString(), runtimeInfo: { - nodeVersion: process.version || 'unknown', + nodeVersion: 'cloudflare', }, dependencies: { production: formatDependencies(__PKG_DEPENDENCIES, 'production'), diff --git a/app/routes/api.system.git-info.ts b/app/routes/api.system.git-info.ts index d6bf9975..63c879ce 100644 --- a/app/routes/api.system.git-info.ts +++ b/app/routes/api.system.git-info.ts @@ -1,138 +1,48 @@ -import type { LoaderFunction } from '@remix-run/cloudflare'; -import { json } from '@remix-run/cloudflare'; -import { execSync } from 'child_process'; +import { json, type LoaderFunction } from '@remix-run/cloudflare'; -interface GitHubRepoInfo { - name: string; - full_name: string; - default_branch: string; - stargazers_count: number; - forks_count: number; - open_issues_count: number; - parent?: { - full_name: string; - default_branch: string; - stargazers_count: number; - forks_count: number; +interface GitInfo { + local: { + commitHash: string; + branch: string; + commitTime: string; + author: string; + email: string; + remoteUrl: string; + repoName: string; }; + github?: { + currentRepo?: { + fullName: string; + defaultBranch: string; + stars: number; + forks: number; + openIssues?: number; + }; + }; + isForked?: boolean; } -const getLocalGitInfo = () => { - try { - return { - commitHash: execSync('git rev-parse HEAD').toString().trim(), - branch: execSync('git rev-parse --abbrev-ref HEAD').toString().trim(), - commitTime: execSync('git log -1 --format=%cd').toString().trim(), - author: execSync('git log -1 --format=%an').toString().trim(), - email: execSync('git log -1 --format=%ae').toString().trim(), - remoteUrl: execSync('git config --get remote.origin.url').toString().trim(), - repoName: execSync('git config --get remote.origin.url') - .toString() - .trim() - .replace(/^.*github.com[:/]/, '') - .replace(/\.git$/, ''), - }; - } catch (error) { - console.error('Failed to get local git info:', error); - return null; - } -}; +// These values will be replaced at build time +declare const __COMMIT_HASH: string; +declare const __GIT_BRANCH: string; +declare const __GIT_COMMIT_TIME: string; +declare const __GIT_AUTHOR: string; +declare const __GIT_EMAIL: string; +declare const __GIT_REMOTE_URL: string; +declare const __GIT_REPO_NAME: string; -const getGitHubInfo = async (repoFullName: string) => { - try { - // Add GitHub token if available - const headers: Record = { - Accept: 'application/vnd.github.v3+json', - }; - - const githubToken = process.env.GITHUB_TOKEN; - - if (githubToken) { - headers.Authorization = `token ${githubToken}`; - } - - console.log('Fetching GitHub info for:', repoFullName); // Debug log - - const response = await fetch(`https://api.github.com/repos/${repoFullName}`, { - headers, - }); - - if (!response.ok) { - console.error('GitHub API error:', { - status: response.status, - statusText: response.statusText, - repoFullName, - }); - - // If we get a 404, try the main repo as fallback - if (response.status === 404 && repoFullName !== 'stackblitz-labs/bolt.diy') { - return getGitHubInfo('stackblitz-labs/bolt.diy'); - } - - throw new Error(`GitHub API error: ${response.statusText}`); - } - - const data = await response.json(); - console.log('GitHub API response:', data); // Debug log - - return data as GitHubRepoInfo; - } catch (error) { - console.error('Failed to get GitHub info:', error); - return null; - } -}; - -export const loader: LoaderFunction = async ({ request: _request }) => { - const localInfo = getLocalGitInfo(); - console.log('Local git info:', localInfo); // Debug log - - // If we have local info, try to get GitHub info for both our fork and upstream - let githubInfo = null; - - if (localInfo?.repoName) { - githubInfo = await getGitHubInfo(localInfo.repoName); - } - - // If no local info or GitHub info, try the main repo - if (!githubInfo) { - githubInfo = await getGitHubInfo('stackblitz-labs/bolt.diy'); - } - - const response = { - local: localInfo || { - commitHash: 'unknown', - branch: 'unknown', - commitTime: 'unknown', - author: 'unknown', - email: 'unknown', - remoteUrl: 'unknown', - repoName: 'unknown', +export const loader: LoaderFunction = async () => { + const gitInfo: GitInfo = { + local: { + commitHash: typeof __COMMIT_HASH !== 'undefined' ? __COMMIT_HASH : 'development', + branch: typeof __GIT_BRANCH !== 'undefined' ? __GIT_BRANCH : 'main', + commitTime: typeof __GIT_COMMIT_TIME !== 'undefined' ? __GIT_COMMIT_TIME : new Date().toISOString(), + author: typeof __GIT_AUTHOR !== 'undefined' ? __GIT_AUTHOR : 'development', + email: typeof __GIT_EMAIL !== 'undefined' ? __GIT_EMAIL : 'development@local', + remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local', + repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'bolt.diy', }, - github: githubInfo - ? { - currentRepo: { - fullName: githubInfo.full_name, - defaultBranch: githubInfo.default_branch, - stars: githubInfo.stargazers_count, - forks: githubInfo.forks_count, - openIssues: githubInfo.open_issues_count, - }, - upstream: githubInfo.parent - ? { - fullName: githubInfo.parent.full_name, - defaultBranch: githubInfo.parent.default_branch, - stars: githubInfo.parent.stargazers_count, - forks: githubInfo.parent.forks_count, - } - : null, - } - : null, - isForked: Boolean(githubInfo?.parent), - timestamp: new Date().toISOString(), }; - console.log('Final response:', response); - - // Debug log - return json(response); + return json(gitInfo); }; diff --git a/app/routes/api.update.ts b/app/routes/api.update.ts index 9f79d4ae..97d28ce0 100644 --- a/app/routes/api.update.ts +++ b/app/routes/api.update.ts @@ -1,573 +1,21 @@ -import { json } from '@remix-run/node'; -import type { ActionFunction } from '@remix-run/node'; -import { exec } from 'child_process'; -import { promisify } from 'util'; - -const execAsync = promisify(exec); - -interface UpdateRequestBody { - branch: string; - autoUpdate?: boolean; -} - -interface UpdateProgress { - stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete'; - message: string; - progress?: number; - error?: string; - details?: { - changedFiles?: string[]; - additions?: number; - deletions?: number; - commitMessages?: string[]; - totalSize?: string; - currentCommit?: string; - remoteCommit?: string; - updateReady?: boolean; - changelog?: string; - compareUrl?: string; - }; -} +import { json, type ActionFunction } from '@remix-run/cloudflare'; export const action: ActionFunction = async ({ request }) => { if (request.method !== 'POST') { return json({ error: 'Method not allowed' }, { status: 405 }); } - try { - const body = await request.json(); - - if (!body || typeof body !== 'object' || !('branch' in body) || typeof body.branch !== 'string') { - return json({ error: 'Invalid request body: branch is required and must be a string' }, { status: 400 }); - } - - const { branch, autoUpdate = false } = body as UpdateRequestBody; - - // Create a ReadableStream to send progress updates - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - const sendProgress = (update: UpdateProgress) => { - controller.enqueue(encoder.encode(JSON.stringify(update) + '\n')); - }; - - try { - // Initial check stage - sendProgress({ - stage: 'fetch', - message: 'Checking repository status...', - progress: 0, - }); - - // Check if remote exists - let defaultBranch = branch || 'main'; // Make branch mutable - - try { - await execAsync('git remote get-url upstream'); - sendProgress({ - stage: 'fetch', - message: 'Repository remote verified', - progress: 10, - }); - } catch { - throw new Error( - 'No upstream repository found. Please set up the upstream repository first by running:\ngit remote add upstream https://github.com/stackblitz-labs/bolt.diy.git', - ); - } - - // Get default branch if not specified - if (!branch) { - sendProgress({ - stage: 'fetch', - message: 'Detecting default branch...', - progress: 20, - }); - - try { - const { stdout } = await execAsync('git remote show upstream | grep "HEAD branch" | cut -d" " -f5'); - defaultBranch = stdout.trim() || 'main'; - sendProgress({ - stage: 'fetch', - message: `Using branch: ${defaultBranch}`, - progress: 30, - }); - } catch { - defaultBranch = 'main'; // Fallback to main if we can't detect - sendProgress({ - stage: 'fetch', - message: 'Using default branch: main', - progress: 30, - }); - } - } - - // Fetch stage - sendProgress({ - stage: 'fetch', - message: 'Fetching latest changes...', - progress: 40, - }); - - // Fetch all remotes - await execAsync('git fetch --all'); - sendProgress({ - stage: 'fetch', - message: 'Remote changes fetched', - progress: 50, - }); - - // Check if remote branch exists - try { - await execAsync(`git rev-parse --verify upstream/${defaultBranch}`); - sendProgress({ - stage: 'fetch', - message: 'Remote branch verified', - progress: 60, - }); - } catch { - throw new Error( - `Remote branch 'upstream/${defaultBranch}' not found. Please ensure the upstream repository is properly configured.`, - ); - } - - // Get current commit hash and remote commit hash - sendProgress({ - stage: 'fetch', - message: 'Comparing versions...', - progress: 70, - }); - - const { stdout: currentCommit } = await execAsync('git rev-parse HEAD'); - const { stdout: remoteCommit } = await execAsync(`git rev-parse upstream/${defaultBranch}`); - - // If we're on the same commit, no update is available - if (currentCommit.trim() === remoteCommit.trim()) { - sendProgress({ - stage: 'complete', - message: 'No updates available. You are on the latest version.', - progress: 100, - details: { - currentCommit: currentCommit.trim().substring(0, 7), - remoteCommit: remoteCommit.trim().substring(0, 7), - }, - }); - return; - } - - sendProgress({ - stage: 'fetch', - message: 'Analyzing changes...', - progress: 80, - }); - - // Initialize variables - let changedFiles: string[] = []; - let commitMessages: string[] = []; - let stats: RegExpMatchArray | null = null; - let totalSizeInBytes = 0; - - // Format size for display - const formatSize = (bytes: number) => { - if (bytes === 0) { - return '0 B'; - } - - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; - }; - - // Get list of changed files and their sizes - try { - const { stdout: diffOutput } = await execAsync( - `git diff --name-status ${currentCommit.trim()}..${remoteCommit.trim()}`, - ); - const files = diffOutput.split('\n').filter(Boolean); - - if (files.length === 0) { - sendProgress({ - stage: 'complete', - message: `No file changes detected between your version and upstream/${defaultBranch}. You might be on a different branch.`, - progress: 100, - details: { - currentCommit: currentCommit.trim().substring(0, 7), - remoteCommit: remoteCommit.trim().substring(0, 7), - }, - }); - return; - } - - sendProgress({ - stage: 'fetch', - message: `Found ${files.length} changed files, calculating sizes...`, - progress: 90, - }); - - // Get size information for each changed file - for (const line of files) { - const [status, file] = line.split('\t'); - - if (status !== 'D') { - // Skip deleted files - try { - const { stdout: sizeOutput } = await execAsync(`git cat-file -s ${remoteCommit.trim()}:${file}`); - const size = parseInt(sizeOutput) || 0; - totalSizeInBytes += size; - } catch { - console.debug(`Could not get size for file: ${file}`); - } - } - } - - changedFiles = files.map((line) => { - const [status, file] = line.split('\t'); - return `${status === 'M' ? 'Modified' : status === 'A' ? 'Added' : 'Deleted'}: ${file}`; - }); - } catch (err) { - console.debug('Failed to get changed files:', err); - throw new Error(`Failed to compare changes with upstream/${defaultBranch}. Are you on the correct branch?`); - } - - // Get commit messages between current and remote - try { - const { stdout: logOutput } = await execAsync( - `git log --pretty=format:"%h|%s|%aI" ${currentCommit.trim()}..${remoteCommit.trim()}`, - ); - - // Parse and group commits by type - const commits = logOutput - .split('\n') - .filter(Boolean) - .map((line) => { - const [hash, subject, timestamp] = line.split('|'); - let type = 'other'; - let message = subject; - - if (subject.startsWith('feat:') || subject.startsWith('feature:')) { - type = 'feature'; - message = subject.replace(/^feat(?:ure)?:/, '').trim(); - } else if (subject.startsWith('fix:')) { - type = 'fix'; - message = subject.replace(/^fix:/, '').trim(); - } else if (subject.startsWith('docs:')) { - type = 'docs'; - message = subject.replace(/^docs:/, '').trim(); - } else if (subject.startsWith('style:')) { - type = 'style'; - message = subject.replace(/^style:/, '').trim(); - } else if (subject.startsWith('refactor:')) { - type = 'refactor'; - message = subject.replace(/^refactor:/, '').trim(); - } else if (subject.startsWith('perf:')) { - type = 'perf'; - message = subject.replace(/^perf:/, '').trim(); - } else if (subject.startsWith('test:')) { - type = 'test'; - message = subject.replace(/^test:/, '').trim(); - } else if (subject.startsWith('build:')) { - type = 'build'; - message = subject.replace(/^build:/, '').trim(); - } else if (subject.startsWith('ci:')) { - type = 'ci'; - message = subject.replace(/^ci:/, '').trim(); - } - - return { - hash, - type, - message, - timestamp: new Date(timestamp), - }; - }); - - // Group commits by type - const groupedCommits = commits.reduce( - (acc, commit) => { - if (!acc[commit.type]) { - acc[commit.type] = []; - } - - acc[commit.type].push(commit); - - return acc; - }, - {} as Record, - ); - - // Format commit messages with emojis and timestamps - const formattedMessages = Object.entries(groupedCommits).map(([type, commits]) => { - const emoji = { - feature: 'โœจ', - fix: '๐Ÿ›', - docs: '๐Ÿ“š', - style: '๐Ÿ’Ž', - refactor: 'โ™ป๏ธ', - perf: 'โšก', - test: '๐Ÿงช', - build: '๐Ÿ› ๏ธ', - ci: 'โš™๏ธ', - other: '๐Ÿ”', - }[type]; - - const title = { - feature: 'Features', - fix: 'Bug Fixes', - docs: 'Documentation', - style: 'Styles', - refactor: 'Code Refactoring', - perf: 'Performance', - test: 'Tests', - build: 'Build', - ci: 'CI', - other: 'Other Changes', - }[type]; - - return `### ${emoji} ${title}\n\n${commits - .map((c) => `* ${c.message} (${c.hash.substring(0, 7)}) - ${c.timestamp.toLocaleString()}`) - .join('\n')}`; - }); - - commitMessages = formattedMessages; - } catch { - // Handle silently - empty commitMessages array will be used - } - - // Get diff stats using the specific commits - try { - const { stdout: diffStats } = await execAsync( - `git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`, - ); - stats = diffStats.match( - /(\d+) files? changed(?:, (\d+) insertions?\\(\\+\\))?(?:, (\d+) deletions?\\(-\\))?/, - ); - } catch { - // Handle silently - null stats will be used - } - - // If we somehow still have no changes detected - if (!stats && changedFiles.length === 0) { - sendProgress({ - stage: 'complete', - message: `No changes detected between your version and upstream/${defaultBranch}. This might be unexpected - please check your git status.`, - progress: 100, - }); - return; - } - - // Fetch changelog - sendProgress({ - stage: 'fetch', - message: 'Fetching changelog...', - progress: 95, - }); - - const changelog = await fetchChangelog(currentCommit.trim(), remoteCommit.trim()); - - // We have changes, send the details - sendProgress({ - stage: 'fetch', - message: `Changes detected on upstream/${defaultBranch}`, - progress: 100, - details: { - changedFiles, - additions: stats?.[2] ? parseInt(stats[2]) : 0, - deletions: stats?.[3] ? parseInt(stats[3]) : 0, - commitMessages, - totalSize: formatSize(totalSizeInBytes), - currentCommit: currentCommit.trim().substring(0, 7), - remoteCommit: remoteCommit.trim().substring(0, 7), - updateReady: true, - changelog, - compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`, - }, - }); - - // Only proceed with update if autoUpdate is true - if (!autoUpdate) { - sendProgress({ - stage: 'complete', - message: 'Update is ready to be applied. Click "Update Now" to proceed.', - progress: 100, - details: { - changedFiles, - additions: stats?.[2] ? parseInt(stats[2]) : 0, - deletions: stats?.[3] ? parseInt(stats[3]) : 0, - commitMessages, - totalSize: formatSize(totalSizeInBytes), - currentCommit: currentCommit.trim().substring(0, 7), - remoteCommit: remoteCommit.trim().substring(0, 7), - updateReady: true, - changelog, - compareUrl: `https://github.com/stackblitz-labs/bolt.diy/compare/${currentCommit.trim().substring(0, 7)}...${remoteCommit.trim().substring(0, 7)}`, - }, - }); - return; - } - - // Pull stage - sendProgress({ - stage: 'pull', - message: `Pulling changes from upstream/${defaultBranch}...`, - progress: 0, - }); - - await execAsync(`git pull upstream ${defaultBranch}`); - - sendProgress({ - stage: 'pull', - message: 'Changes pulled successfully', - progress: 100, - }); - - // Install stage - sendProgress({ - stage: 'install', - message: 'Installing dependencies...', - progress: 0, - }); - - await execAsync('pnpm install'); - - sendProgress({ - stage: 'install', - message: 'Dependencies installed successfully', - progress: 100, - }); - - // Build stage - sendProgress({ - stage: 'build', - message: 'Building application...', - progress: 0, - }); - - await execAsync('pnpm build'); - - sendProgress({ - stage: 'build', - message: 'Build completed successfully', - progress: 100, - }); - - // Complete - sendProgress({ - stage: 'complete', - message: 'Update completed successfully! Click Restart to apply changes.', - progress: 100, - }); - } catch (err) { - sendProgress({ - stage: 'complete', - message: 'Update failed', - error: err instanceof Error ? err.message : 'Unknown error occurred', - }); - } finally { - controller.close(); - } - }, - }); - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }); - } catch (err) { - console.error('Update preparation failed:', err); - return json( - { - success: false, - error: err instanceof Error ? err.message : 'Unknown error occurred while preparing update', - }, - { status: 500 }, - ); - } + return json( + { + error: 'Updates must be performed manually in a server environment', + instructions: [ + '1. Navigate to the project directory', + '2. Run: git fetch upstream', + '3. Run: git pull upstream main', + '4. Run: pnpm install', + '5. Run: pnpm run build', + ], + }, + { status: 400 }, + ); }; - -// Add this function to fetch the changelog -async function fetchChangelog(currentCommit: string, remoteCommit: string): Promise { - try { - // First try to get the changelog.md content - const { stdout: changelogContent } = await execAsync('git show upstream/main:changelog.md'); - - // If we have a changelog, return it - if (changelogContent) { - return changelogContent; - } - - // If no changelog.md, generate one in a similar format - let changelog = '# Changes in this Update\n\n'; - - // Get commit messages grouped by type - const { stdout: commitLog } = await execAsync( - `git log --pretty=format:"%h|%s|%b" ${currentCommit.trim()}..${remoteCommit.trim()}`, - ); - - const commits = commitLog.split('\n').filter(Boolean); - const categorizedCommits: Record = { - 'โœจ Features': [], - '๐Ÿ› Bug Fixes': [], - '๐Ÿ“š Documentation': [], - '๐Ÿ’Ž Styles': [], - 'โ™ป๏ธ Code Refactoring': [], - 'โšก Performance': [], - '๐Ÿงช Tests': [], - '๐Ÿ› ๏ธ Build': [], - 'โš™๏ธ CI': [], - '๐Ÿ” Other Changes': [], - }; - - // Categorize commits - for (const commit of commits) { - const [hash, subject] = commit.split('|'); - let category = '๐Ÿ” Other Changes'; - - if (subject.startsWith('feat:') || subject.startsWith('feature:')) { - category = 'โœจ Features'; - } else if (subject.startsWith('fix:')) { - category = '๐Ÿ› Bug Fixes'; - } else if (subject.startsWith('docs:')) { - category = '๐Ÿ“š Documentation'; - } else if (subject.startsWith('style:')) { - category = '๐Ÿ’Ž Styles'; - } else if (subject.startsWith('refactor:')) { - category = 'โ™ป๏ธ Code Refactoring'; - } else if (subject.startsWith('perf:')) { - category = 'โšก Performance'; - } else if (subject.startsWith('test:')) { - category = '๐Ÿงช Tests'; - } else if (subject.startsWith('build:')) { - category = '๐Ÿ› ๏ธ Build'; - } else if (subject.startsWith('ci:')) { - category = 'โš™๏ธ CI'; - } - - const message = subject.includes(':') ? subject.split(':')[1].trim() : subject.trim(); - categorizedCommits[category].push(`* ${message} (${hash.substring(0, 7)})`); - } - - // Build changelog content - for (const [category, commits] of Object.entries(categorizedCommits)) { - if (commits.length > 0) { - changelog += `\n## ${category}\n\n${commits.join('\n')}\n`; - } - } - - // Add stats - const { stdout: stats } = await execAsync(`git diff --shortstat ${currentCommit.trim()}..${remoteCommit.trim()}`); - - if (stats) { - changelog += '\n## ๐Ÿ“Š Stats\n\n'; - changelog += `${stats.trim()}\n`; - } - - return changelog; - } catch (error) { - console.error('Error fetching changelog:', error); - return 'Unable to fetch changelog'; - } -} diff --git a/package.json b/package.json index e287f0c5..144831fb 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "remark-gfm": "^4.0.0", "remix-island": "^0.2.0", "remix-utils": "^7.7.0", + "rollup-plugin-node-polyfills": "^0.2.1", "shiki": "^1.24.0", "tailwind-merge": "^2.2.1", "unist-util-visit": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc491791..bb37289b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,9 @@ importers: remix-utils: specifier: ^7.7.0 version: 7.7.0(@remix-run/cloudflare@2.15.3(@cloudflare/workers-types@4.20250204.0)(typescript@5.7.3))(@remix-run/node@2.15.3(typescript@5.7.3))(@remix-run/react@2.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.3))(@remix-run/router@1.22.0)(react@18.3.1)(zod@3.24.1) + rollup-plugin-node-polyfills: + specifier: ^0.2.1 + version: 0.2.1 shiki: specifier: ^1.24.0 version: 1.29.2 diff --git a/vite.config.ts b/vite.config.ts index 01fb3b2e..a9351c12 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -89,14 +89,55 @@ export default defineConfig((config) => { __PKG_DEV_DEPENDENCIES: JSON.stringify(pkg.devDependencies), __PKG_PEER_DEPENDENCIES: JSON.stringify(pkg.peerDependencies), __PKG_OPTIONAL_DEPENDENCIES: JSON.stringify(pkg.optionalDependencies), + // Define global values + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), }, build: { target: 'esnext', + rollupOptions: { + output: { + format: 'esm', + }, + }, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, + optimizeDeps: { + esbuildOptions: { + define: { + global: 'globalThis', + }, + }, + }, + resolve: { + alias: { + buffer: 'vite-plugin-node-polyfills/polyfills/buffer', + }, }, plugins: [ nodePolyfills({ - include: ['path', 'buffer', 'process'], + include: ['buffer', 'process', 'util', 'stream'], + globals: { + Buffer: true, + process: true, + global: true, + }, + protocolImports: true, + // Exclude Node.js modules that shouldn't be polyfilled in Cloudflare + exclude: ['child_process', 'fs', 'path'], }), + { + name: 'buffer-polyfill', + transform(code, id) { + if (id.includes('env.mjs')) { + return { + code: `import { Buffer } from 'buffer';\n${code}`, + map: null, + }; + } + }, + }, config.mode !== 'test' && remixCloudflareDevProxy(), remixVitePlugin({ future: {