โ vibesdk
#!/usr/bin/env node
/**
* Cloudflare Orange Build - Automated Deployment Script
*
* This script handles the complete setup and deployment process for the
* Cloudflare Orange Build platform, including:
* - Workers for Platforms dispatch namespace creation
* - Templates repository deployment to R2
* - Container configuration updates
* - Environment validation
*
* Used by the "Deploy to Cloudflare" button for one-click deployment.
*/
import { execSync } from 'child_process';
import { randomBytes } from 'crypto';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { parse, modify, applyEdits } from 'jsonc-parser';
import Cloudflare from 'cloudflare';
// Get current directory for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PROJECT_ROOT = join(__dirname, '..');
// Types for configuration
interface WranglerConfig {
name: string;
dispatch_namespaces?: Array<{
binding: string;
namespace: string;
experimental_remote?: boolean;
}>;
r2_buckets?: Array<{
binding: string;
bucket_name: string;
experimental_remote?: boolean;
}>;
containers?: Array<{
class_name: string;
image: string;
max_instances: number;
instance_type?: {
vcpu: number;
memory_mib: number;
disk_mb?: number;
} | string;
rollout_step_percentage?: number;
ssh?: {
enabled: boolean;
};
}>;
d1_databases?: Array<{
binding: string;
database_name: string;
database_id: string;
migrations_dir?: string;
experimental_remote?: boolean;
}>;
routes?: Array<{
pattern: string;
custom_domain: boolean;
zone_id?: string;
}>;
artifacts?: Array<{
binding: string;
namespace: string;
}>;
vars?: {
TEMPLATES_REPOSITORY?: string;
CLOUDFLARE_AI_GATEWAY?: string;
MAX_SANDBOX_INSTANCES?: string;
CUSTOM_DOMAIN?: string;
CUSTOM_PREVIEW_DOMAIN?: string;
SANDBOX_INSTANCE_TYPE?: string;
DISPATCH_NAMESPACE?: string;
[key: string]: string | undefined;
};
}
interface EnvironmentConfig {
CLOUDFLARE_API_TOKEN: string;
CLOUDFLARE_ACCOUNT_ID: string;
TEMPLATES_REPOSITORY: string;
CLOUDFLARE_AI_GATEWAY?: string;
CLOUDFLARE_AI_GATEWAY_TOKEN?: string;
}
class DeploymentError extends Error {
constructor(
message: string,
public cause?: Error,
) {
super(message);
this.name = 'DeploymentError';
}
}
class CloudflareDeploymentManager {
private config: WranglerConfig;
private env: EnvironmentConfig;
private cloudflare: Cloudflare;
private aiGatewayCloudflare?: Cloudflare; // Separate SDK instance for AI Gateway operations
private conflictingVarsForCleanup: Record<string, string> | null = null; // For signal cleanup
private artifactsBindingBackup: WranglerConfig['artifacts'] | null = null; // Restored after deploy when temporarily removed
constructor() {
this.validateEnvironment();
this.config = this.parseWranglerConfig();
this.extractConfigurationValues();
this.env = this.getEnvironmentVariables();
this.cloudflare = new Cloudflare({
apiToken: this.env.CLOUDFLARE_API_TOKEN,
});
// Set up signal handling for graceful cleanup
this.setupSignalHandlers();
}
/**
* Sets up signal handlers for graceful cleanup on Ctrl+C or termination
* Reuses existing restoreOriginalVars method following DRY principles
*/
private setupSignalHandlers(): void {
const gracefulExit = async (signal: string) => {
console.log(`\n๐ Received ${signal}, performing cleanup...`);
try {
// Restore conflicting vars using existing restoration method
if (this.conflictingVarsForCleanup || this.artifactsBindingBackup) {
console.log('๐ Restoring original wrangler.jsonc configuration...');
await this.restoreOriginalVars(this.conflictingVarsForCleanup);
this.restoreArtifactsBinding();
} else {
console.log('โน๏ธ No configuration changes to restore');
}
} catch (error) {
console.error(`โ Error during cleanup: ${error instanceof Error ? error.message : String(error)}`);
}
console.log('๐ Cleanup completed. Exiting...');
process.exit(1);
};
// Handle Ctrl+C (SIGINT)
process.on('SIGINT', () => gracefulExit('SIGINT'));
// Handle termination (SIGTERM)
process.on('SIGTERM', () => gracefulExit('SIGTERM'));
console.log('โ
Signal handlers registered for graceful cleanup');
}
/**
* Validates that all required build variables are present
*/
private validateEnvironment(): void {
const requiredBuildVars = ['CLOUDFLARE_API_TOKEN'];
const missingVars = requiredBuildVars.filter(
(varName) => !process.env[varName],
);
if (missingVars.length > 0) {
throw new DeploymentError(
`Missing required build variables: ${missingVars.join(', ')}\n` +
`Please ensure all required build variables are configured in your deployment.`,
);
}
console.log('โ
Build variables validation passed');
}
/**
* Extracts and validates key configuration values from wrangler.jsonc
*/
private extractConfigurationValues(): void {
console.log(
'๐ Extracting configuration values from wrangler.jsonc...',
);
// Log key extracted values
const databaseName = this.config.d1_databases?.[0]?.database_name;
const customDomain = this.config.vars?.CUSTOM_DOMAIN;
const customPreviewDomain = this.config.vars?.CUSTOM_PREVIEW_DOMAIN;
const maxInstances = this.config.vars?.MAX_SANDBOX_INSTANCES;
const templatesRepo = this.config.vars?.TEMPLATES_REPOSITORY;
const aiGateway = this.config.vars?.CLOUDFLARE_AI_GATEWAY;
const dispatchNamespace = this.config.vars?.DISPATCH_NAMESPACE;
console.log('๐ Configuration Summary:');
console.log(` Database Name: ${databaseName || 'Not configured'}`);
console.log(` Custom Domain: ${customDomain || 'Not configured'}`);
console.log(` Custom Preview Domain: ${customPreviewDomain || 'Not configured'}`);
console.log(
` Max Sandbox Instances: ${maxInstances || 'Not configured'}`,
);
console.log(
` Templates Repository: ${templatesRepo || 'Not configured'}`,
);
console.log(` AI Gateway: ${aiGateway || 'Not configured'}`);
console.log(` Dispatch Namespace: ${dispatchNamespace || 'Not configured'}`);
// Validate critical configuration
if (!databaseName) {
console.warn(
'โ ๏ธ No D1 database configured - database operations may fail',
);
}
if (!customDomain) {
console.warn(
'โ ๏ธ No custom domain configured - using default routes',
);
}
console.log('โ
Configuration extraction completed');
}
/**
* Safely parses wrangler.jsonc file, handling comments and JSON-like syntax
*/
private parseWranglerConfig(): WranglerConfig {
const wranglerPath = this.getWranglerPath();
if (!existsSync(wranglerPath)) {
throw new DeploymentError(
'wrangler.jsonc not found',
new Error('Please ensure wrangler.jsonc exists in the project root'),
);
}
try {
const { config } = this.readWranglerConfig();
this.logSuccess(`Parsed wrangler.jsonc - Project: ${config.name}`);
return config;
} catch (error) {
throw new DeploymentError(
'Failed to parse wrangler.jsonc',
error instanceof Error ? error : new Error(`Please check your wrangler.jsonc syntax: ${String(error)}`),
);
}
}
/**
* Gets and validates environment variables, with defaults from wrangler.jsonc
*/
private getEnvironmentVariables(): EnvironmentConfig {
const apiToken = process.env.CLOUDFLARE_API_TOKEN!;
const aiGatewayToken = process.env.CLOUDFLARE_AI_GATEWAY_TOKEN || apiToken;
return {
CLOUDFLARE_API_TOKEN: apiToken,
CLOUDFLARE_ACCOUNT_ID:
process.env.CLOUDFLARE_ACCOUNT_ID ||
this.config.vars?.CLOUDFLARE_ACCOUNT_ID!,
TEMPLATES_REPOSITORY:
process.env.TEMPLATES_REPOSITORY ||
this.config.vars?.TEMPLATES_REPOSITORY!,
CLOUDFLARE_AI_GATEWAY:
process.env.CLOUDFLARE_AI_GATEWAY ||
this.config.vars?.CLOUDFLARE_AI_GATEWAY || "orange-build-gateway",
CLOUDFLARE_AI_GATEWAY_TOKEN: aiGatewayToken,
};
}
/**
* Creates or ensures Workers for Platforms dispatch namespace exists
*/
private async ensureDispatchNamespace(): Promise<void> {
const dispatchConfig = this.config.dispatch_namespaces?.[0];
if (!dispatchConfig) {
console.log('โน๏ธ No dispatch namespace configuration found, skipping setup');
return;
}
const namespaceName = dispatchConfig.namespace;
console.log(`๐ Checking dispatch namespace: ${namespaceName}`);
try {
// Check if namespace exists using Cloudflare SDK
try {
await this.cloudflare.workersForPlatforms.dispatch.namespaces.get(
namespaceName,
{ account_id: this.env.CLOUDFLARE_ACCOUNT_ID },
);
console.log(
`โ
Dispatch namespace '${namespaceName}' already exists`,
);
return;
} catch (error: any) {
// Check if error indicates dispatch namespaces are not available
const errorMessage = error?.message || '';
if (errorMessage.includes('You do not have access to dispatch namespaces') ||
errorMessage.includes('code: 10121')) {
console.log('โ ๏ธ Dispatch namespaces became unavailable during execution');
console.log(' Workers for Platforms access may have changed');
return;
}
// If error is not 404, re-throw it
if (
error?.status !== 404 &&
error?.message?.indexOf('not found') === -1
) {
throw error;
}
// Namespace doesn't exist, continue to create it
}
console.log(`๐ฆ Creating dispatch namespace: ${namespaceName}`);
await this.cloudflare.workersForPlatforms.dispatch.namespaces.create(
{
account_id: this.env.CLOUDFLARE_ACCOUNT_ID,
name: namespaceName,
},
);
console.log(
`โ
Successfully created dispatch namespace: ${namespaceName}`,
);
} catch (error) {
// Check if the error is related to dispatch namespace access
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('You do not have access to dispatch namespaces') ||
errorMessage.includes('code: 10121')) {
console.warn('โ ๏ธ Dispatch namespaces are not available for this account');
console.warn(' Skipping dispatch namespace setup and continuing deployment');
return;
}
throw new DeploymentError(
`Failed to ensure dispatch namespace: ${namespaceName}`,
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Creates or ensures AI Gateway exists (non-blocking)
*/
private async ensureAIGateway(): Promise<void> {
if (!this.env.CLOUDFLARE_AI_GATEWAY) {
console.log(
'โน๏ธ AI Gateway setup skipped (CLOUDFLARE_AI_GATEWAY not provided)',
);
return;
}
const gatewayName = this.env.CLOUDFLARE_AI_GATEWAY;
console.log(`๐ Checking AI Gateway: ${gatewayName}`);
try {
// Step 1: Check main token permissions and create AI Gateway token if needed
console.log('๐ Checking API token permissions...');
const tokenCheck = await this.checkTokenPermissions();
const aiGatewayToken = await this.ensureAIGatewayToken();
// Step 2: Check if gateway exists first using appropriate SDK
const aiGatewaySDK = this.getAIGatewaySDK();
try {
await aiGatewaySDK.aiGateway.get(gatewayName, {
account_id: this.env.CLOUDFLARE_ACCOUNT_ID,
});
console.log(`โ
AI Gateway '${gatewayName}' already exists`);
return;
} catch (error: any) {
// If error is not 404, log but continue
if (
error?.status !== 404 &&
!error?.message?.includes('not found')
) {
console.warn(
`โ ๏ธ Could not check AI Gateway '${gatewayName}': ${error.message}`,
);
return;
}
// Gateway doesn't exist, continue to create it
}
// Validate gateway name length (64 character limit)
if (gatewayName.length > 64) {
console.warn(
`โ ๏ธ AI Gateway name too long (${gatewayName.length} > 64 chars), skipping creation`,
);
return;
}
// Step 3: Create AI Gateway with authentication based on token availability
console.log(`๐ฆ Creating AI Gateway: ${gatewayName}`);
await aiGatewaySDK.aiGateway.create({
account_id: this.env.CLOUDFLARE_ACCOUNT_ID,
id: gatewayName,
cache_invalidate_on_update: true,
cache_ttl: 3600,
collect_logs: true,
rate_limiting_interval: 0,
rate_limiting_limit: 0,
rate_limiting_technique: 'sliding',
authentication: !!aiGatewayToken, // Enable authentication only if we have a token
});
console.log(
`โ
Successfully created AI Gateway: ${gatewayName} (authentication: ${aiGatewayToken ? 'enabled' : 'disabled'})`,
);
} catch (error) {
// Non-blocking: Log warning but continue deployment
console.warn(
`โ ๏ธ Could not create AI Gateway '${gatewayName}': ${error instanceof Error ? error.message : String(error)}`,
);
console.warn(
' Continuing deployment without AI Gateway setup...',
);
}
}
/**
* Verifies if the current API token has AI Gateway permissions
*/
private async checkTokenPermissions(): Promise<{
hasAIGatewayAccess: boolean;
tokenInfo?: any;
}> {
try {
const verifyResponse = await fetch(
'https://api.cloudflare.com/client/v4/user/tokens/verify',
{
headers: {
Authorization: `Bearer ${this.env.CLOUDFLARE_API_TOKEN}`,
},
},
);
if (!verifyResponse.ok) {
console.warn('โ ๏ธ Could not verify API token permissions');
return { hasAIGatewayAccess: false };
}
const verifyData = await verifyResponse.json();
if (!verifyData.success) {
console.warn('โ ๏ธ API token verification failed');
return { hasAIGatewayAccess: false };
}
// For now, assume we need to create a separate token for AI Gateway operations
// This is a conservative approach since permission checking is complex
console.log(
'โน๏ธ Main API token verified, but will create dedicated AI Gateway token',
);
return { hasAIGatewayAccess: false, tokenInfo: verifyData.result };
} catch (error) {
console.warn(
`โ ๏ธ Token verification failed: ${error instanceof Error ? error.message : String(error)}`,
);
return { hasAIGatewayAccess: false };
}
}
/**
* Creates AI Gateway authentication token if needed (non-blocking)
* Returns the token if created/available, null otherwise
*/
private async ensureAIGatewayToken(): Promise<string | null> {
const currentToken = this.env.CLOUDFLARE_AI_GATEWAY_TOKEN;
// Check if token is already set and not the default placeholder
if (
currentToken &&
currentToken !== 'optional-your-cf-ai-gateway-token'
) {
console.log('โ
AI Gateway token already configured');
// Initialize separate AI Gateway SDK instance
this.aiGatewayCloudflare = new Cloudflare({
apiToken: currentToken,
});
return currentToken;
}
try {
console.log(`๐ Creating AI Gateway authentication token...`);
// Create API token with required permissions for AI Gateway including RUN
const tokenResponse = await fetch(
`https://api.cloudflare.com/client/v4/user/tokens`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.env.CLOUDFLARE_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: `AI Gateway Token - ${new Date().toISOString().split('T')[0]}`,
policies: [
{
effect: 'allow',
resources: {
[`com.cloudflare.api.account.${this.env.CLOUDFLARE_ACCOUNT_ID}`]:
'*',
},
permission_groups: [
// Note: Using descriptive names, actual IDs would need to be fetched from the API
{ name: 'AI Gateway Read' },
{ name: 'AI Gateway Edit' },
{ name: 'AI Gateway Run' }, // This is the key permission for authentication
{ name: 'Workers AI Read' },
{ name: 'Workers AI Edit' },
],
},
],
condition: {
request_ip: { in: [], not_in: [] },
},
expires_on: new Date(
Date.now() + 365 * 24 * 60 * 60 * 1000,
).toISOString(), // 1 year
}),
},
);
if (!tokenResponse.ok) {
const errorData = await tokenResponse
.json()
.catch(() => ({ errors: [{ message: 'Unknown error' }] }));
throw new Error(
`API token creation failed: ${errorData.errors?.[0]?.message || tokenResponse.statusText}`,
);
}
const tokenData = await tokenResponse.json();
if (tokenData.success && tokenData.result?.value) {
const newToken = tokenData.result.value;
console.log(
'โ
AI Gateway authentication token created successfully',
);
console.log(` Token ID: ${tokenData.result.id}`);
console.warn(
'โ ๏ธ Store this token securely and set CLOUDFLARE_AI_GATEWAY_TOKEN for future deployments.',
);
if (!process.env.CI) {
console.warn(` ${newToken}`);
} else {
console.warn(' (Token value suppressed because CI=true)');
}
// Initialize separate AI Gateway SDK instance
this.aiGatewayCloudflare = new Cloudflare({
apiToken: newToken,
});
return newToken;
} else {
throw new Error(
'Token creation succeeded but no token value returned',
);
}
} catch (error) {
// Non-blocking: Log warning but continue
console.warn(
`โ ๏ธ Could not create AI Gateway token: ${error instanceof Error ? error.message : String(error)}`,
);
console.warn(
' AI Gateway will be created without authentication...',
);
return null;
}
}
/**
* Gets the appropriate Cloudflare SDK instance for AI Gateway operations
*/
private getAIGatewaySDK(): Cloudflare {
return this.aiGatewayCloudflare || this.cloudflare;
}
/**
* Clones templates repository and deploys templates to R2
*/
private async deployTemplates(): Promise<void> {
const templatesDir = join(PROJECT_ROOT, 'templates');
const templatesRepo = this.env.TEMPLATES_REPOSITORY;
console.log(`๐ฅ Setting up templates from: ${templatesRepo}`);
try {
// Create templates directory if it doesn't exist
if (!existsSync(templatesDir)) {
mkdirSync(templatesDir, { recursive: true });
}
// Clone repository if not already present
if (!existsSync(join(templatesDir, '.git'))) {
console.log(`๐ Cloning templates repository...`);
execSync(`git clone "${templatesRepo}" "${templatesDir}"`, {
stdio: 'pipe',
cwd: PROJECT_ROOT,
});
console.log('โ
Templates repository cloned successfully');
} else {
console.log(
'๐ Templates repository already exists, pulling latest changes...',
);
try {
execSync('git pull origin main || git pull origin master', {
stdio: 'pipe',
cwd: templatesDir,
});
console.log('โ
Templates repository updated');
} catch (pullError) {
console.warn(
'โ ๏ธ Could not pull latest changes, continuing with existing templates',
);
}
}
// Find R2 bucket name from config
const templatesBucket = this.config.r2_buckets?.find(
(bucket) => bucket.binding === 'TEMPLATES_BUCKET',
);
if (!templatesBucket) {
throw new Error(
'TEMPLATES_BUCKET not found in wrangler.jsonc r2_buckets configuration',
);
}
// Check if deploy script exists
const deployScript = join(templatesDir, 'deploy_templates.sh');
if (!existsSync(deployScript)) {
console.warn(
'โ ๏ธ deploy_templates.sh not found in templates repository, skipping template deployment',
);
return;
}
// Make script executable
execSync(`chmod +x "${deployScript}"`, { cwd: templatesDir });
// Run deployment script with environment variables
console.log(
`๐ Deploying templates to R2 bucket: ${templatesBucket.bucket_name}`,
);
const inheritedKeys = [
'PATH',
'HOME',
'USER',
'SHELL',
'TMPDIR',
'TMP',
'TEMP',
'LANG',
'LC_ALL',
'CI',
'GITHUB_WORKSPACE',
];
const inheritedEnv: NodeJS.ProcessEnv = {};
inheritedKeys.forEach((key) => {
const value = process.env[key];
if (value !== undefined) {
inheritedEnv[key] = value;
}
});
const deployEnv: NodeJS.ProcessEnv = {
...inheritedEnv,
CLOUDFLARE_API_TOKEN: this.env.CLOUDFLARE_API_TOKEN,
CLOUDFLARE_ACCOUNT_ID: this.env.CLOUDFLARE_ACCOUNT_ID,
BUCKET_NAME: templatesBucket.bucket_name,
R2_BUCKET_NAME: templatesBucket.bucket_name,
};
execSync('./deploy_templates.sh', {
stdio: 'inherit',
cwd: templatesDir,
env: deployEnv,
});
console.log('โ
Templates deployed successfully to R2');
} catch (error) {
// Don't fail the entire deployment if templates fail
console.warn(
'โ ๏ธ Templates deployment failed, but continuing with main deployment:',
);
console.warn(
` ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Cleans ARM64 platform flags from SandboxDockerfile for production deployment
* Returns the original content if ARM64 flags were removed (for restoration)
*/
private cleanDockerfileForDeployment(): string | null {
const dockerfilePath = join(PROJECT_ROOT, 'SandboxDockerfile');
if (!existsSync(dockerfilePath)) {
console.log(' โน๏ธ SandboxDockerfile not found - skipping ARM64 cleanup');
return null;
}
try {
const originalContent = readFileSync(dockerfilePath, 'utf-8');
let modified = false;
// Split content into lines for processing
const lines = originalContent.split('\n');
const cleanedLines = lines.map(line => {
// Look for FROM statements with --platform=linux/arm64 and remove the flag
const fromMatch = line.match(/^(\s*FROM\s+)--platform=linux\/arm64\s+(.*)/);
if (fromMatch) {
modified = true;
const [, prefix, image] = fromMatch;
return `${prefix}${image}`;
}
return line;
});
if (modified) {
writeFileSync(dockerfilePath, cleanedLines.join('\n'), 'utf-8');
console.log(' โ
Removed ARM64 platform flags from SandboxDockerfile');
return originalContent; // Return original for restoration
} else {
console.log(' โ
No ARM64 platform flags found in SandboxDockerfile');
return null; // Nothing to restore
}
} catch (error) {
console.warn(
` โ ๏ธ Could not clean SandboxDockerfile: ${error instanceof Error ? error.message : String(error)}`,
);
console.warn(' โ Continuing deployment, but may encounter platform compatibility issues');
return null;
}
}
/**
* Restores ARM64 platform flags to SandboxDockerfile if they were previously removed
*/
private restoreDockerfileARM64Flags(originalContent: string): void {
const dockerfilePath = join(PROJECT_ROOT, 'SandboxDockerfile');
try {
writeFileSync(dockerfilePath, originalContent, 'utf-8');
console.log('๐ Restored ARM64 platform flags to SandboxDockerfile for local development');
} catch (error) {
console.warn(
`โ ๏ธ Could not restore ARM64 flags to SandboxDockerfile: ${error instanceof Error ? error.message : String(error)}`,
);
console.warn(' You may need to manually re-run the setup script to restore ARM64 flags');
}
}
/**
* Updates package.json database commands with the actual database name from wrangler.jsonc
*/
private updatePackageJsonDatabaseCommands(): void {
const databaseName = this.config.d1_databases?.[0]?.database_name;
if (!databaseName) {
console.log(
'โน๏ธ No D1 database found in wrangler.jsonc, skipping package.json database command update',
);
return;
}
console.log(
`๐ง Updating package.json database commands with database: ${databaseName}`,
);
try {
const packageJsonPath = join(PROJECT_ROOT, 'package.json');
const content = readFileSync(packageJsonPath, 'utf-8');
// Parse the package.json file
const packageJson = JSON.parse(content);
if (!packageJson.scripts) {
console.warn('โ ๏ธ No scripts section found in package.json');
return;
}
// Update database migration commands
const commandsToUpdate = ['db:migrate:local', 'db:migrate:remote'];
// When deploying with a non-default wrangler config (e.g. staging),
// the migration commands must also target that config or wrangler
// will look up the db binding in the default `wrangler.jsonc`.
const wranglerConfigArg = this.getWranglerConfigArg();
let updated = false;
commandsToUpdate.forEach((command) => {
if (packageJson.scripts[command]) {
const oldCommand = packageJson.scripts[command];
// Replace any existing database name in the wrangler d1 migrations apply command
let newCommand = oldCommand.replace(
/wrangler d1 migrations apply [^\s]+ /,
`wrangler d1 migrations apply ${databaseName} `,
);
// Inject `--config <path>` once, if needed and not already present.
if (wranglerConfigArg && !/--config\s/.test(newCommand)) {
newCommand = newCommand.replace(
/wrangler d1 migrations apply [^\s]+/,
(match: string) => `${match}${wranglerConfigArg}`,
);
}
if (newCommand !== oldCommand) {
packageJson.scripts[command] = newCommand;
console.log(
` โ
Updated ${command}: ${oldCommand} โ ${newCommand}`,
);
updated = true;
}
}
});
if (updated) {
// Write back the updated package.json with proper formatting
writeFileSync(
packageJsonPath,
JSON.stringify(packageJson, null, '\t'),
'utf-8',
);
console.log(
'โ
Updated package.json database commands successfully',
);
} else {
console.log(
'โน๏ธ No database commands needed updating in package.json',
);
}
} catch (error) {
console.warn(
`โ ๏ธ Could not update package.json database commands: ${error instanceof Error ? error.message : String(error)}`,
);
// Non-blocking - continue deployment
}
}
/**
* Gets the zone name and ID for a given domain by testing subdomains
*/
private async detectZoneForDomain(customDomain: string, originalDomain: string): Promise<{
zoneName: string | null;
zoneId: string | null;
}> {
console.log(`๐ Detecting zone for custom domain: ${customDomain}, Original domain was: ${originalDomain}`);
// Extract possible zone names by progressively removing subdomains
const domainParts = customDomain.split('.');
const possibleZones: string[] = [];
// Generate all possible zone names from longest to shortest
// e.g., for 'abc.test.xyz.build.cloudflare.dev' generates:
// ['abc.test.xyz.build.cloudflare.dev', 'test.xyz.build.cloudflare.dev', 'xyz.build.cloudflare.dev', 'build.cloudflare.dev', 'cloudflare.dev']
for (let i = 0; i < domainParts.length - 1; i++) {
const zoneName = domainParts.slice(i).join('.');
possibleZones.push(zoneName);
}
console.log(`๐ Testing possible zones: ${possibleZones.join(', ')}`);
// Test each possible zone name
for (const zoneName of possibleZones) {
try {
console.log(` Testing zone: ${zoneName}`);
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones?name=${encodeURIComponent(zoneName)}`,
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.env.CLOUDFLARE_API_TOKEN}`,
},
}
);
if (!response.ok) {
console.log(` โ API error for zone ${zoneName}: ${response.status} ${response.statusText}`);
continue;
}
const data = await response.json();
if (data.success && data.result && data.result.length > 0) {
const zone = data.result[0];
console.log(` โ
Found zone: ${zoneName} (ID: ${zone.id})`);
console.log(` Zone status: ${zone.status}`);
console.log(` Account: ${zone.account.name}`);
return {
zoneName: zoneName,
zoneId: zone.id,
};
} else {
console.log(` โ No zone found for: ${zoneName}`);
}
} catch (error) {
console.log(` โ Error checking zone ${zoneName}: ${error instanceof Error ? error.message : String(error)}`);
}
}
console.error(`โ No valid zone found for custom domain: ${customDomain}`);
console.error(` Tested zones: ${possibleZones.join(', ')}`);
console.error(` Please ensure:`);
console.error(` 1. The domain is managed by Cloudflare`);
console.error(` 2. Your API token has zone read permissions`);
console.error(` 3. The domain is active and properly configured`);
return { zoneName: null, zoneId: null };
}
/**
* Updates wrangler.jsonc routes and deployment settings based on CUSTOM_DOMAIN
*/
/**
* Standard formatting options for JSONC modifications
*/
private static readonly JSONC_FORMAT_OPTIONS = {
formattingOptions: {
insertSpaces: true,
keepLines: true,
tabSize: 4
}
};
/**
* Gets the path to the wrangler config file. Honors WRANGLER_CONFIG_PATH
* (relative to project root) so the same deploy script can target staging
* via `wrangler.staging.jsonc`. Defaults to `wrangler.jsonc`.
*/
private getWranglerPath(): string {
const override = process.env.WRANGLER_CONFIG_PATH;
return join(PROJECT_ROOT, override && override.trim() !== '' ? override : 'wrangler.jsonc');
}
/**
* Builds the `--config` argument suffix for wrangler subcommands when a
* non-default config path is in use. Returns an empty string for the default.
*/
private getWranglerConfigArg(): string {
const override = process.env.WRANGLER_CONFIG_PATH;
if (!override || override.trim() === '' || override === 'wrangler.jsonc') {
return '';
}
return ` --config ${override}`;
}
/**
* Reads and parses wrangler.jsonc file
*/
private readWranglerConfig(): { content: string; config: WranglerConfig } {
const wranglerPath = this.getWranglerPath();
const content = readFileSync(wranglerPath, 'utf-8');
const config = parse(content) as WranglerConfig;
return { content, config };
}
/**
* Writes content to wrangler.jsonc file
*/
private writeWranglerConfig(content: string): void {
const wranglerPath = this.getWranglerPath();
writeFileSync(wranglerPath, content, 'utf-8');
}
/**
* Standardized success logging
*/
private logSuccess(message: string, details?: string[]): void {
console.log(`โ
${message}`);
if (details) {
details.forEach(detail => console.log(` ${detail}`));
}
}
/**
* Standardized warning logging
*/
private logWarning(message: string, details?: string[]): void {
console.warn(`โ ๏ธ ${message}`);
if (details) {
details.forEach(detail => console.warn(` ${detail}`));
}
}
/**
* Updates a specific field in wrangler.jsonc configuration
*/
private updateWranglerField<T>(content: string, field: string, value: T): string {
const edits = modify(content, [field], value, CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS);
return applyEdits(content, edits);
}
/**
* Updates wrangler.jsonc for workers.dev deployment (no custom domain)
*/
private updateWranglerForWorkersDev(content: string): string {
let updatedContent = content;
// Remove routes property if it exists
const removeRoutesEdits = modify(content, ['routes'], undefined, CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS);
updatedContent = applyEdits(updatedContent, removeRoutesEdits);
// Set workers_dev = true and preview_urls = true
updatedContent = this.updateWranglerField(updatedContent, 'workers_dev', true);
updatedContent = this.updateWranglerField(updatedContent, 'preview_urls', true);
return updatedContent;
}
/**
* Updates wrangler.jsonc for custom domain deployment
*/
private updateWranglerForCustomDomain(
content: string,
routes: Array<{ pattern: string; custom_domain: boolean; zone_id?: string; zone_name?: string }>,
preserveExistingFlags: boolean = false
): string {
let updatedContent = content;
// Update routes
updatedContent = this.updateWranglerField(updatedContent, 'routes', routes);
// Only update workers_dev and preview_urls if not preserving existing flags
if (!preserveExistingFlags) {
updatedContent = this.updateWranglerField(updatedContent, 'workers_dev', false);
updatedContent = this.updateWranglerField(updatedContent, 'preview_urls', false);
}
return updatedContent;
}
/**
* Safely detects zone information for a domain, handling failures gracefully
*/
private async safeDetectZoneForDomain(
customDomain: string,
originalCustomDomain: string | null
): Promise<{ zoneName: string | null; zoneId: string | null; success: boolean }> {
try {
if (!originalCustomDomain) {
return { zoneName: null, zoneId: null, success: false };
}
const { zoneName, zoneId } = await this.detectZoneForDomain(customDomain, originalCustomDomain);
if (!zoneId) {
return { zoneName: null, zoneId: null, success: false };
}
return { zoneName, zoneId, success: true };
} catch (error) {
console.warn(
`โ ๏ธ Zone detection failed for custom domain ${customDomain}: ${error instanceof Error ? error.message : String(error)}`
);
console.log(' โ Continuing without zone-specific routes');
return { zoneName: null, zoneId: null, success: false };
}
}
private async updateCustomDomainRoutes(): Promise<string> {
const customDomain = this.config.vars?.CUSTOM_DOMAIN || process.env.CUSTOM_DOMAIN;
// Check for CUSTOM_PREVIEW_DOMAIN (env var takes priority)
const customPreviewDomain = process.env.CUSTOM_PREVIEW_DOMAIN || this.config.vars?.CUSTOM_PREVIEW_DOMAIN;
try {
const { content, config } = this.readWranglerConfig();
// Get the original custom domain from existing routes (route with custom_domain: true)
const originalCustomDomain = config.routes?.find(route => route.custom_domain)?.pattern || null;
if (!customDomain) {
console.log(
'โน๏ธ CUSTOM_DOMAIN not set - removing routes and enabling workers.dev',
);
const updatedContent = this.updateWranglerForWorkersDev(content);
this.writeWranglerConfig(updatedContent);
this.logSuccess('Updated wrangler.jsonc for workers.dev deployment:', [
'- Removed routes configuration',
'- Set workers_dev: true',
'- Set preview_urls: true'
]);
return '';
}
console.log(
`๐ง Updating wrangler.jsonc routes with custom domain: ${customDomain}`,
);
// Check if we have a custom preview domain for wildcard routes
if (customPreviewDomain && customPreviewDomain !== '') {
console.log(
`๐ง Using CUSTOM_PREVIEW_DOMAIN for wildcard routes: ${customPreviewDomain}`,
);
}
// Safely detect zone information for main domain
const { zoneId, success: zoneDetectionSuccess } = await this.safeDetectZoneForDomain(customDomain, originalCustomDomain);
// If we have a custom preview domain, detect its zone information for wildcard routes
let previewZoneName: string | null = null;
let previewZoneId: string | null = null;
let previewZoneDetectionSuccess = false;
if (customPreviewDomain && customPreviewDomain !== '') {
const previewZoneInfo = await this.safeDetectZoneForDomain(customPreviewDomain, customPreviewDomain);
previewZoneName = previewZoneInfo.zoneName;
previewZoneId = previewZoneInfo.zoneId;
previewZoneDetectionSuccess = previewZoneInfo.success;
if (previewZoneDetectionSuccess) {
console.log(`๐ Preview domain zone detected:`);
console.log(` Preview Zone Name: ${previewZoneName}`);
console.log(` Preview Zone ID: ${previewZoneId}`);
}
}
// Define the expected routes based on zone detection success
let expectedRoutes: Array<{
pattern: string;
custom_domain: boolean;
zone_id?: string;
}>;
const existingWildcardRoute = config.routes?.find(route => !route.custom_domain);
// Determine which domain and zone to use for wildcard pattern
const wildcardDomain = (customPreviewDomain && customPreviewDomain !== '') ? customPreviewDomain : customDomain;
const wildcardZoneId = (customPreviewDomain && previewZoneDetectionSuccess && previewZoneId)
? previewZoneId
: (zoneDetectionSuccess && zoneId ? zoneId : undefined);
const wildcardRoute: {
pattern: string;
custom_domain: boolean;
zone_id?: string;
} = {
pattern: `*${wildcardDomain}/*`,
custom_domain: false,
};
if (wildcardZoneId) {
// Custom domain with zone information for wildcard pattern
console.log(`๐ Creating routes with zone information:`);
console.log(` Main Domain: ${customDomain}`);
console.log(` Wildcard Domain: ${wildcardDomain}`);
console.log(` Wildcard Zone ID: ${wildcardZoneId}`);
wildcardRoute.zone_id = wildcardZoneId;
} else {
const existingZoneId = existingWildcardRoute && existingWildcardRoute.zone_id;
if (existingZoneId) {
wildcardRoute.zone_id = existingZoneId;
console.warn(
`๐ Using fallback wildcard route configuration (zone detection ${zoneDetectionSuccess ? 'returned no zone' : 'failed'})`
);
} else {
// Fatal error
console.error(`Failed to detect zone for custom domain ${customDomain}. Make sure the domain is properly configured in Cloudflare.`);
throw new Error(`Failed to detect zone for custom domain ${customDomain}`);
}
}
expectedRoutes = [
{ pattern: customDomain, custom_domain: true },
wildcardRoute,
];
// Check if routes need updating
let needsUpdate = false;
if (!config.routes || !Array.isArray(config.routes)) {
needsUpdate = true;
} else if (config.routes.length !== expectedRoutes.length) {
needsUpdate = true;
} else {
for (let i = 0; i < expectedRoutes.length; i++) {
const expected = expectedRoutes[i];
const actual = config.routes[i] as any;
if (
actual.pattern !== expected.pattern ||
actual.custom_domain !== expected.custom_domain
) {
needsUpdate = true;
break;
}
const actualZoneId = (actual && (actual as any).zone_id) ?? null;
const expectedZoneId = expected.zone_id ?? null;
if (actualZoneId !== expectedZoneId) {
needsUpdate = true;
break;
}
}
}
if (!needsUpdate) {
console.log(
'โน๏ธ Routes already match custom domain configuration',
);
return customDomain;
}
// Update wrangler configuration
// If zone detection failed, preserve existing workers_dev and preview_urls values
const preserveExistingFlags = !zoneDetectionSuccess;
const updatedContent = this.updateWranglerForCustomDomain(content, expectedRoutes, preserveExistingFlags);
this.writeWranglerConfig(updatedContent);
// Log the changes
const routeDetails = expectedRoutes.map((route, index) => {
const infoParts = [`custom_domain: ${route.custom_domain}`];
if (route.zone_id) {
infoParts.push(`zone_id: ${route.zone_id}`);
}
return `Route ${index + 1}: ${route.pattern} (${infoParts.join(', ')})`;
});
if (!preserveExistingFlags) {
routeDetails.push('Set workers_dev: false', 'Set preview_urls: false');
} else {
routeDetails.push('Preserved existing workers_dev and preview_urls settings');
}
this.logSuccess('Updated wrangler.jsonc routes:', routeDetails);
return customDomain;
} catch (error) {
console.error(
`โ ๏ธ Could not update custom domain routes: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
/**
* Updates container configuration based on MAX_SANDBOX_INSTANCES (env var overrides wrangler.jsonc)
*/
private updateContainerConfiguration(): void {
// Environment variable takes priority over wrangler.jsonc vars
const maxInstances =
process.env.MAX_SANDBOX_INSTANCES ||
this.config.vars?.MAX_SANDBOX_INSTANCES || "10";
if (!maxInstances) {
console.log(
'โน๏ธ MAX_SANDBOX_INSTANCES not set in environment variables or wrangler.jsonc vars, skipping container configuration update',
);
return;
}
const source = process.env.MAX_SANDBOX_INSTANCES
? 'environment variable'
: 'wrangler.jsonc vars';
console.log(
`๐ง Using MAX_SANDBOX_INSTANCES from ${source}: ${maxInstances}`,
);
const maxInstancesNum = parseInt(maxInstances, 10);
if (isNaN(maxInstancesNum) || maxInstancesNum <= 0) {
console.warn(
`โ ๏ธ Invalid MAX_SANDBOX_INSTANCES value: ${maxInstances}, skipping update`,
);
return;
}
console.log(
`๐ง Updating container configuration: MAX_SANDBOX_INSTANCES=${maxInstancesNum}`,
);
try {
const { content, config } = this.readWranglerConfig();
if (!config.containers || !Array.isArray(config.containers)) {
console.warn(
'โ ๏ธ No containers configuration found in wrangler.jsonc',
);
return;
}
// Find the index of UserAppSandboxService container
const sandboxContainerIndex = config.containers.findIndex(
(container) => container.class_name === 'UserAppSandboxService',
);
if (sandboxContainerIndex === -1) {
console.warn(
'โ ๏ธ UserAppSandboxService container not found in wrangler.jsonc',
);
return;
}
const oldMaxInstances =
config.containers[sandboxContainerIndex].max_instances;
// Use jsonc-parser's modify function to properly edit the file
// Path to the max_instances field: ['containers', index, 'max_instances']
const edits = modify(
content,
['containers', sandboxContainerIndex, 'max_instances'],
maxInstancesNum,
CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS,
);
// Apply the edits to get the updated content
const updatedContent = applyEdits(content, edits);
// Write back the updated configuration
this.writeWranglerConfig(updatedContent);
this.logSuccess(
`Updated UserAppSandboxService max_instances: ${oldMaxInstances} โ ${maxInstancesNum}`
);
} catch (error) {
throw new DeploymentError(
'Failed to update container configuration',
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Updates container instance types based on SANDBOX_INSTANCE_TYPE variable
*/
private updateContainerInstanceTypes(): void {
// Environment variable takes priority over wrangler.jsonc vars
const sandboxInstanceType =
process.env.SANDBOX_INSTANCE_TYPE ||
this.config.vars?.SANDBOX_INSTANCE_TYPE ||
'standard-3';
console.log(
`๐ง Configuring container instance types: ${sandboxInstanceType}`,
);
try {
const { content, config } = this.readWranglerConfig();
if (!config.containers || !Array.isArray(config.containers)) {
console.warn(
'โ ๏ธ No containers configuration found in wrangler.jsonc',
);
return;
}
// Find the indices of both containers
const userAppContainerIndex = config.containers.findIndex(
(container) => container.class_name === 'UserAppSandboxService',
);
if (userAppContainerIndex === -1) {
console.warn(
'โ ๏ธ UserAppSandboxService container not found in wrangler.jsonc',
);
return;
}
// Determine the instance type configuration
let userAppInstanceType: any;
if (sandboxInstanceType === 'enhanced') {
// Enhanced configuration as specified
userAppInstanceType = {
vcpu: 4,
memory_mib: 8192,
disk_mb: 10240
};
console.log(' Using enhanced instance type configuration');
} else {
// Use the string value directly
userAppInstanceType = sandboxInstanceType;
console.log(` Using instance type string: ${sandboxInstanceType}`);
}
// Update UserAppSandboxService instance_type
let updatedContent = content;
const userAppInstanceTypeEdits = modify(
updatedContent,
['containers', userAppContainerIndex, 'instance_type'],
userAppInstanceType,
CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS
);
updatedContent = applyEdits(updatedContent, userAppInstanceTypeEdits);
// Write back the updated configuration
this.writeWranglerConfig(updatedContent);
this.logSuccess(`Updated container instance types for SANDBOX_INSTANCE_TYPE: ${sandboxInstanceType}`, [
`UserAppSandboxService: ${JSON.stringify(userAppInstanceType)}`,
]);
} catch (error) {
this.logWarning(
`Could not update container instance types: ${error instanceof Error ? error.message : String(error)}`,
['Continuing with current configuration...']
);
// Non-blocking - continue deployment
}
}
/**
* Updates dispatch namespace configuration based on DISPATCH_NAMESPACE (env var overrides wrangler.jsonc)
* If dispatch namespaces are not available, clears the DISPATCH_NAMESPACE var
*/
private updateDispatchNamespace(dispatchNamespacesAvailable: boolean): void {
// If dispatch namespaces are not available, clear the DISPATCH_NAMESPACE var
if (!dispatchNamespacesAvailable) {
console.log('๐ง Dispatch namespaces not available - clearing DISPATCH_NAMESPACE var');
try {
const { content } = this.readWranglerConfig();
// Clear the DISPATCH_NAMESPACE var
const varsEdits = modify(
content,
['vars', 'DISPATCH_NAMESPACE'],
'',
CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS,
);
const updatedContent = applyEdits(content, varsEdits);
this.writeWranglerConfig(updatedContent);
this.logSuccess('Cleared DISPATCH_NAMESPACE var (dispatch namespaces not available)');
// Update internal config
if (this.config.vars) {
this.config.vars.DISPATCH_NAMESPACE = '';
}
} catch (error) {
this.logWarning(
`Could not clear DISPATCH_NAMESPACE var: ${error instanceof Error ? error.message : String(error)}`,
['Continuing with deployment...']
);
}
return;
}
// Environment variable takes priority over wrangler.jsonc vars
const dispatchNamespace =
process.env.DISPATCH_NAMESPACE ||
this.config.vars?.DISPATCH_NAMESPACE ||
"orange-build-default-namespace";
const source = process.env.DISPATCH_NAMESPACE
? 'environment variable'
: this.config.vars?.DISPATCH_NAMESPACE
? 'wrangler.jsonc vars'
: 'default value';
console.log(
`๐ง Using DISPATCH_NAMESPACE from ${source}: ${dispatchNamespace}`,
);
// Validate namespace name
if (!dispatchNamespace || dispatchNamespace.trim() === '') {
console.warn(
'โ ๏ธ Invalid DISPATCH_NAMESPACE value: empty string, using default',
);
return;
}
// Basic format validation (alphanumeric, hyphens, underscores)
const namespacePattern = /^[a-zA-Z0-9_-]+$/;
if (!namespacePattern.test(dispatchNamespace)) {
console.warn(
`โ ๏ธ Invalid DISPATCH_NAMESPACE format: ${dispatchNamespace}, must contain only letters, numbers, hyphens, and underscores`,
);
return;
}
console.log(
`๐ง Updating dispatch namespace configuration: DISPATCH_NAMESPACE=${dispatchNamespace}`,
);
try {
const { content, config } = this.readWranglerConfig();
if (!config.dispatch_namespaces || !Array.isArray(config.dispatch_namespaces)) {
console.warn(
'โ ๏ธ No dispatch_namespaces configuration found in wrangler.jsonc',
);
return;
}
if (config.dispatch_namespaces.length === 0) {
console.warn(
'โ ๏ธ Empty dispatch_namespaces array in wrangler.jsonc',
);
return;
}
const currentNamespace = config.dispatch_namespaces[0].namespace;
// Check if update is needed
if (currentNamespace === dispatchNamespace) {
console.log(
`โน๏ธ Dispatch namespace already set to: ${dispatchNamespace}`,
);
return;
}
let updatedContent = content;
// Update dispatch_namespaces[0].namespace
const namespaceEdits = modify(
updatedContent,
['dispatch_namespaces', 0, 'namespace'],
dispatchNamespace,
CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS,
);
updatedContent = applyEdits(updatedContent, namespaceEdits);
// Update vars.DISPATCH_NAMESPACE for consistency
const varsEdits = modify(
updatedContent,
['vars', 'DISPATCH_NAMESPACE'],
dispatchNamespace,
CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS,
);
updatedContent = applyEdits(updatedContent, varsEdits);
// Write back the updated configuration
this.writeWranglerConfig(updatedContent);
this.logSuccess(
`Updated dispatch namespace: ${currentNamespace} โ ${dispatchNamespace}`,
[
'Updated dispatch_namespaces[0].namespace',
'Updated vars.DISPATCH_NAMESPACE for consistency'
]
);
// Update internal config to reflect changes
if (!this.config.dispatch_namespaces) {
this.config.dispatch_namespaces = [];
}
this.config.dispatch_namespaces[0].namespace = dispatchNamespace;
if (!this.config.vars) {
this.config.vars = {};
}
this.config.vars.DISPATCH_NAMESPACE = dispatchNamespace;
} catch (error) {
this.logWarning(
`Could not update dispatch namespace configuration: ${error instanceof Error ? error.message : String(error)}`,
['Continuing with current configuration...']
);
// Non-blocking - continue deployment
}
}
/**
* Cleans Wrangler cache and build artifacts
*/
private cleanWranglerCache(): void {
console.log('๐งน Cleaning Wrangler cache and build artifacts...');
try {
// Remove .wrangler directory (contains wrangler cache and state)
execSync('rm -rf .wrangler', {
stdio: 'pipe',
cwd: PROJECT_ROOT,
});
console.log(' โ
Removed .wrangler directory');
// Remove wrangler.json files from dist/* directories
// Use find to locate and remove any wrangler.json files in dist subdirectories
try {
execSync('find dist -name "wrangler.json" -type f -delete 2>/dev/null || true', {
stdio: 'pipe',
cwd: PROJECT_ROOT,
});
console.log(' โ
Removed cached wrangler.json files from dist');
} catch (findError) {
// Non-critical - continue if find fails
console.log(' โน๏ธ No cached wrangler.json files found in dist');
}
console.log('โ
Cache cleanup completed');
} catch (error) {
// Non-blocking - log warning but continue
console.warn(
`โ ๏ธ Cache cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
);
console.warn(' Continuing with deployment...');
}
}
/**
* Builds the project (clean dist and run build)
*/
private async buildProject(): Promise<void> {
console.log('๐จ Building project...');
try {
// Run build
execSync('bun run build', {
stdio: 'inherit',
cwd: PROJECT_ROOT,
});
console.log('โ
Project build completed');
} catch (error) {
throw new DeploymentError(
'Failed to build project',
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Deploys the project using Wrangler
*/
private async wranglerDeploy(): Promise<void> {
console.log('๐ Deploying to Cloudflare Workers...');
try {
execSync(`wrangler deploy${this.getWranglerConfigArg()}`, {
stdio: 'inherit',
cwd: PROJECT_ROOT,
});
console.log('โ
Wrangler deployment completed');
} catch (error) {
throw new DeploymentError(
'Failed to deploy with Wrangler',
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Temporarily removes conflicting vars from wrangler.jsonc before deployment
* Returns the original vars for restoration later
*/
private async removeConflictingVars(): Promise<Record<string, string> | null> {
const prodVarsPath = join(PROJECT_ROOT, '.prod.vars');
if (!existsSync(prodVarsPath)) {
console.log('โน๏ธ No .prod.vars file found, skipping conflict resolution');
return null;
}
try {
console.log('๐ Checking for var/secret conflicts...');
// Read .prod.vars to see which secrets will be uploaded
const prodVarsContent = readFileSync(prodVarsPath, 'utf-8');
const secretVarNames = new Set<string>();
prodVarsContent.split('\n').forEach(line => {
line = line.trim();
if (line && !line.startsWith('#') && line.includes('=')) {
const varName = line.split('=')[0].trim();
secretVarNames.add(varName);
}
});
// Check which vars in wrangler.jsonc conflict with secrets
const conflictingVars: Record<string, string> = {};
const originalVars = { ...(this.config.vars || {}) };
Object.keys(originalVars).forEach(varName => {
if (secretVarNames.has(varName)) {
conflictingVars[varName] = originalVars[varName] || '';
console.log(`๐ Found conflict: ${varName} (will be moved from var to secret)`);
}
});
if (Object.keys(conflictingVars).length === 0) {
console.log('โ
No var/secret conflicts found');
return null;
}
console.log(`โ ๏ธ Temporarily removing ${Object.keys(conflictingVars).length} conflicting vars from wrangler.jsonc`);
// Remove conflicting vars from wrangler.jsonc
const { content } = this.readWranglerConfig();
const updatedVars = { ...originalVars };
Object.keys(conflictingVars).forEach(varName => {
delete updatedVars[varName];
});
// Update wrangler.jsonc with vars removed
const edits = modify(
content,
['vars'],
updatedVars,
CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS
);
const updatedContent = applyEdits(content, edits);
this.writeWranglerConfig(updatedContent);
this.logSuccess('Temporarily removed conflicting vars from wrangler.jsonc');
return conflictingVars;
} catch (error) {
this.logWarning(`Could not remove conflicting vars: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* Restores the original vars to wrangler.jsonc after deployment
*/
private async restoreOriginalVars(originalConflictingVars: Record<string, string> | null): Promise<void> {
if (!originalConflictingVars || Object.keys(originalConflictingVars).length === 0) {
return;
}
try {
console.log('๐ Restoring original vars to wrangler.jsonc...');
const { content, config } = this.readWranglerConfig();
// Merge back the conflicting vars
const restoredVars = {
...(config.vars || {}),
...originalConflictingVars
};
const edits = modify(
content,
['vars'],
restoredVars,
CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS
);
const updatedContent = applyEdits(content, edits);
this.writeWranglerConfig(updatedContent);
this.logSuccess(`Restored ${Object.keys(originalConflictingVars).length} original vars to wrangler.jsonc`);
} catch (error) {
this.logWarning(`Could not restore original vars: ${error instanceof Error ? error.message : String(error)}`, [
'You may need to manually restore wrangler.jsonc vars'
]);
}
}
/**
* Creates .prod.vars file with current environment variables
*/
private createProdVarsFile(): void {
const prodVarsPath = join(PROJECT_ROOT, '.prod.vars');
console.log(
'๐ Creating .prod.vars file from environment variables...',
);
// Map of environment variables to include in production secrets
const secretVars = [
'CLOUDFLARE_API_TOKEN',
'CLOUDFLARE_ACCOUNT_ID',
'TEMPLATES_REPOSITORY',
'CLOUDFLARE_AI_GATEWAY',
'CLOUDFLARE_AI_GATEWAY_URL',
'CLOUDFLARE_AI_GATEWAY_TOKEN',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GOOGLE_AI_STUDIO_API_KEY',
'OPENROUTER_API_KEY',
'GROQ_API_KEY',
'GOOGLE_CLIENT_SECRET',
'GOOGLE_CLIENT_ID',
'GITHUB_CLIENT_ID',
'GITHUB_CLIENT_SECRET',
'CLOUDFLARE_OAUTH_CLIENT_ID',
'CLOUDFLARE_OAUTH_CLIENT_SECRET',
'CF_OAUTH_ENCRYPTION_KEY',
'JWT_SECRET',
'SENTRY_DSN',
'AI_PROXY_JWT_SECRET',
'ENABLE_ARTIFACTS',
'MAX_SANDBOX_INSTANCES',
'CUSTOM_DOMAIN',
'CUSTOM_PREVIEW_DOMAIN',
'SANDBOX_INSTANCE_TYPE',
'DISPATCH_NAMESPACE',
'ENVIRONMENT',
'PLATFORM_MODEL_PROVIDERS',
];
const generatedJwtSecret = process.env.JWT_SECRET ? undefined : randomBytes(64).toString('base64url');
if (generatedJwtSecret) {
console.log('๐ Generated JWT_SECRET for this deployment');
}
const prodVarsContent: string[] = [
'# Production environment variables for Cloudflare Orange Build',
'# Generated automatically during deployment',
'',
'# Essential Secrets:',
];
// Add environment variables that are set
secretVars.forEach((varName) => {
let value = varName === 'JWT_SECRET' ? generatedJwtSecret : process.env[varName];
// Apply fallback logic for CLOUDFLARE_AI_GATEWAY_TOKEN
if (varName === 'CLOUDFLARE_AI_GATEWAY_TOKEN' && (!value || value === '')) {
value = this.env.CLOUDFLARE_AI_GATEWAY_TOKEN;
}
if (value && value !== '') {
// Skip placeholder values
if (
value.startsWith('optional-') ||
value.startsWith('your-')
) {
prodVarsContent.push(
`# ${varName}="${value}" # Placeholder - update with actual value`,
);
} else {
prodVarsContent.push(`${varName}="${value}"`);
}
} else {
prodVarsContent.push(
`# ${varName}="" # Not set in current environment`,
);
}
});
// Add environment marker
prodVarsContent.push('');
// prodVarsContent.push('ENVIRONMENT="prod"');
try {
writeFileSync(
prodVarsPath,
prodVarsContent.join('\n') + '\n',
'utf-8',
);
console.log(
`โ
Created .prod.vars file with ${secretVars.length} environment variables`,
);
} catch (error) {
console.warn(
`โ ๏ธ Could not create .prod.vars file: ${error instanceof Error ? error.message : String(error)}`,
);
throw new DeploymentError(
'Failed to create .prod.vars file',
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Updates secrets using Wrangler (non-blocking)
*/
private async updateSecrets(): Promise<void> {
console.log('๐ Updating production secrets...');
try {
const prodVarsPath = join(PROJECT_ROOT, '.prod.vars');
// Check if .prod.vars file exists, create it if not
if (!existsSync(prodVarsPath)) {
console.log(
'๐ .prod.vars file not found, creating from environment variables...',
);
this.createProdVarsFile();
}
// Verify file exists after creation attempt
if (!existsSync(prodVarsPath)) {
console.warn(
'โ ๏ธ Could not create .prod.vars file, skipping secret update',
);
return;
}
execSync(`wrangler secret bulk .prod.vars${this.getWranglerConfigArg()}`, {
stdio: 'inherit',
cwd: PROJECT_ROOT,
});
console.log('โ
Production secrets updated successfully');
} catch (error) {
// Non-blocking: Log warning but don't fail deployment
console.warn(
`โ ๏ธ Could not update secrets: ${error instanceof Error ? error.message : String(error)}`,
);
console.warn(
' You may need to update secrets manually if required',
);
}
}
/**
* Checks if dispatch namespaces (Workers for Platforms) are available for the user
*/
private async checkDispatchNamespaceAvailability(): Promise<boolean> {
console.log('๐ Checking dispatch namespace availability (Workers for Platforms)...');
try {
// Run the wrangler dispatch-namespace list command to test availability
const result = execSync(`npx wrangler dispatch-namespace list${this.getWranglerConfigArg()}`, {
stdio: 'pipe',
cwd: PROJECT_ROOT,
encoding: 'utf8',
});
// If the command succeeds without error, dispatch namespaces are available
console.log('โ
Dispatch namespaces are available');
return true;
} catch (error: any) {
// Parse the error to check if it's specifically about dispatch namespace access
const errorOutput = error.stderr || error.stdout || error.message || '';
if (errorOutput.includes('You do not have access to dispatch namespaces') ||
errorOutput.includes('code: 10121')) {
console.log('โ ๏ธ Dispatch namespaces are NOT available');
console.log(' Workers for Platforms is not enabled for this account');
console.log(' You can purchase it at: https://dash.cloudflare.com?to=/:account/workers-for-platforms');
console.log(' If you are an Enterprise customer, please contact your account team');
return false;
}
// For other errors, log them but assume availability (conservative approach)
console.warn(`โ ๏ธ Could not verify dispatch namespace availability: ${errorOutput}`);
console.warn(' Proceeding with assumption that dispatch namespaces are available');
return true;
}
}
/**
* Comments out the dispatch_namespaces section in wrangler.jsonc when not available
*/
private commentOutDispatchNamespaces(): void {
try {
console.log('๐ง Commenting out dispatch_namespaces in wrangler.jsonc...');
const wranglerPath = this.getWranglerPath();
const content = readFileSync(wranglerPath, 'utf-8');
// Check if dispatch_namespaces is currently uncommented
if (!content.includes('"dispatch_namespaces": [')) {
console.log('โน๏ธ dispatch_namespaces already commented out or not present');
return;
}
// Comment out the dispatch_namespaces section
// Look for the pattern and replace it with commented version
const commentedContent = content.replace(
/(\s*)"dispatch_namespaces": \[[\s\S]*?\]/,
'$1// "dispatch_namespaces": [\n$1// {\n$1// "binding": "DISPATCHER",\n$1// "namespace": "orange-build-default-namespace",\n$1// "experimental_remote": true\n$1// }\n$1// ]'
);
if (commentedContent !== content) {
this.writeWranglerConfig(commentedContent);
this.logSuccess('Successfully commented out dispatch_namespaces in wrangler.jsonc');
} else {
console.log('โน๏ธ No changes needed for dispatch_namespaces');
}
} catch (error) {
this.logWarning(`Could not comment out dispatch_namespaces: ${error instanceof Error ? error.message : String(error)}`, [
'Continuing with deployment...'
]);
}
}
/**
* Whether Cloudflare Artifacts should be enabled for this deployment.
* Controlled by ENABLE_ARTIFACTS (env var takes priority over wrangler vars).
* Cloudflare Artifacts is a closed beta; accounts without access cannot
* deploy a Worker that declares the ARTIFACTS binding.
*/
private artifactsEnabled(): boolean {
const value = (process.env.ENABLE_ARTIFACTS ?? this.config.vars?.ENABLE_ARTIFACTS ?? '')
.toString()
.trim()
.toLowerCase();
return value === 'true';
}
/**
* Removes the `artifacts` binding from the wrangler config before deploy
* when Artifacts is not enabled. Without access to the closed beta, declaring
* the binding fails `wrangler deploy` (code 10015). The binding is unused when
* ENABLE_ARTIFACTS is not "true", so it is safe to drop. Backed up for restore.
*/
private removeArtifactsBindingIfDisabled(): void {
if (this.artifactsEnabled()) {
console.log('โ
ENABLE_ARTIFACTS="true" - keeping ARTIFACTS binding');
return;
}
try {
const { content, config } = this.readWranglerConfig();
if (!config.artifacts) {
return;
}
this.artifactsBindingBackup = config.artifacts;
const edits = modify(content, ['artifacts'], undefined, CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS);
this.writeWranglerConfig(applyEdits(content, edits));
this.logSuccess('Removed ARTIFACTS binding for deploy (ENABLE_ARTIFACTS is not "true")');
} catch (error) {
this.logWarning(`Could not remove ARTIFACTS binding: ${error instanceof Error ? error.message : String(error)}`, [
'Continuing with deployment...'
]);
}
}
/**
* Restores the `artifacts` binding removed by removeArtifactsBindingIfDisabled.
*/
private restoreArtifactsBinding(): void {
if (!this.artifactsBindingBackup) {
return;
}
try {
const { content } = this.readWranglerConfig();
const edits = modify(content, ['artifacts'], this.artifactsBindingBackup, CloudflareDeploymentManager.JSONC_FORMAT_OPTIONS);
this.writeWranglerConfig(applyEdits(content, edits));
this.logSuccess('Restored ARTIFACTS binding to wrangler config');
} catch (error) {
this.logWarning(`Could not restore ARTIFACTS binding: ${error instanceof Error ? error.message : String(error)}`, [
'You may need to manually restore the artifacts binding in the wrangler config'
]);
} finally {
this.artifactsBindingBackup = null;
}
}
/**
* Runs database migrations
*/
private async runDatabaseMigrations(): Promise<void> {
console.log('Running database migrations...');
// `db:generate` and `db:migrate:local` are best-effort dev conveniences.
// Run them independently so a local failure does NOT short-circuit the
// required remote migration (previously chained with `&&`, which left the
// deployed Worker running against an unmigrated database).
const runBestEffort = (label: string, command: string): void => {
try {
execSync(command, { stdio: 'inherit', cwd: PROJECT_ROOT, encoding: 'utf8' });
} catch (error) {
console.warn(
`โ ๏ธ ${label} failed (continuing): ${error instanceof Error ? error.message : String(error)}`,
);
}
};
runBestEffort('db:generate', 'bun run db:generate');
runBestEffort('db:migrate:local', 'bun run db:migrate:local');
// Remote migrations are required: deploying a Worker against a database
// missing schema changes causes runtime SQLITE_ERRORs (e.g. "table users
// has no column named ..."). Fail the deploy if they cannot be applied.
try {
execSync('bun run db:migrate:remote', { stdio: 'inherit', cwd: PROJECT_ROOT, encoding: 'utf8' });
} catch (error) {
throw new DeploymentError(
'Remote database migrations failed - the deployed Worker may be running against an unmigrated database',
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Main deployment orchestration method
*/
public async deploy(): Promise<void> {
console.log(
'๐งก Cloudflare Orange Build - Automated Deployment Starting...\n',
);
const startTime = Date.now();
let customDomain: string | null = null;
let originalDockerfileContent: string | null = null;
try {
// Step 1: Early Configuration Updates (must happen before any wrangler commands)
this.cleanWranglerCache();
console.log('\n๐ Step 1: Updating configuration files...');
console.log(' ๐ง Cleaning ARM64 development flags from Dockerfile');
originalDockerfileContent = this.cleanDockerfileForDeployment();
console.log(' ๐ง Updating package.json database commands');
this.updatePackageJsonDatabaseCommands();
console.log(' ๐ง Updating wrangler.jsonc custom domain routes');
customDomain = await this.updateCustomDomainRoutes();
console.log(' ๐ง Updating container instance types');
this.updateContainerInstanceTypes();
console.log(' ๐ง Configuring Artifacts binding');
this.removeArtifactsBindingIfDisabled();
console.log('โ
Configuration files updated successfully!\n');
// Step 1.5: Check dispatch namespace availability early
console.log('\n๐ Step 1.5: Checking dispatch namespace availability...');
const dispatchNamespacesAvailable = await this.checkDispatchNamespaceAvailability();
// Comment out dispatch_namespaces in wrangler.jsonc if not available
if (!dispatchNamespacesAvailable) {
this.commentOutDispatchNamespaces();
}
console.log('โ
Dispatch namespace availability check completed!\n');
// Step 2: Update container configuration if needed
console.log('\n๐ Step 2: Updating container configuration...');
this.updateContainerConfiguration();
this.updateDispatchNamespace(dispatchNamespacesAvailable);
// Step 3: Create .prod.vars and resolve var/secret conflicts before deployment
console.log('\n๐ Step 3: Creating .prod.vars and resolving var/secret conflicts...');
this.createProdVarsFile();
const conflictingVars = await this.removeConflictingVars();
// Store for potential cleanup on early exit
this.conflictingVarsForCleanup = conflictingVars;
// Steps 2-4: Run all setup operations in parallel
const operations: Promise<void>[] = [
this.deployTemplates(),
this.buildProject(),
];
// Only add dispatch namespace setup if available
if (dispatchNamespacesAvailable) {
operations.push(this.ensureDispatchNamespace());
}
// Add AI Gateway setup if gateway name is provided
if (this.env.CLOUDFLARE_AI_GATEWAY) {
operations.push(this.ensureAIGateway());
}
// Log the operations that will run in parallel
console.log(
'๐ Step 4: Running all setup operations in parallel...',
);
if (dispatchNamespacesAvailable) {
console.log(' ๐ Workers for Platforms namespace setup');
} else {
console.log(' โญ๏ธ Skipping Workers for Platforms namespace setup (not available)');
}
console.log(' ๐ Templates repository deployment');
console.log(' ๐ Project build (clean + compile)');
if (this.env.CLOUDFLARE_AI_GATEWAY) {
console.log(' ๐ AI Gateway setup and configuration');
}
await Promise.all(operations);
console.log(
'โ
Parallel setup and build operations completed!',
);
let deploymentSucceeded = false;
try {
// Step 5: Deploy with Wrangler (now without conflicts)
console.log('\n๐ Step 5: Deploying to Cloudflare Workers...');
await this.wranglerDeploy();
// Step 6: Update secrets (now no conflicts)
console.log('\n๐ Step 6: Updating production secrets...');
await this.updateSecrets();
deploymentSucceeded = true;
} finally {
// Step 7: Always restore original vars (even if deployment failed)
console.log('\n๐ Step 7: Restoring original configuration...');
await this.restoreOriginalVars(conflictingVars);
this.restoreArtifactsBinding();
// Clear the backup since we've restored
this.conflictingVarsForCleanup = null;
}
// Step 8: Run database migrations
console.log('\n๐ Step 8: Running database migrations...');
await this.runDatabaseMigrations();
// Deployment complete
if (deploymentSucceeded) {
const duration = Math.round((Date.now() - startTime) / 1000);
console.log(
`\n๐ Complete deployment finished successfully in ${duration}s!`,
);
console.log(
`โ
Your Cloudflare Orange Build platform is now live at https://${customDomain}! ๐`,
);
// Restore ARM64 flags for continued local development
if (originalDockerfileContent) {
console.log('\n๐ Restoring local development configuration...');
this.restoreDockerfileARM64Flags(originalDockerfileContent);
}
} else {
throw new DeploymentError('Deployment failed during wrangler deploy or secret update');
}
} catch (error) {
console.error('\nโ Deployment failed:');
if (error instanceof DeploymentError) {
console.error(` ${error.message}`);
if (error.cause) {
console.error(` Caused by: ${error.cause.message}`);
}
} else {
console.error(` ${error}`);
}
console.error('\n๐ Troubleshooting tips:');
console.error(
' - Verify all environment variables are correctly set',
);
console.error(
' - Check your Cloudflare API token has required permissions',
);
console.error(
' - Ensure your account has access to Workers for Platforms',
);
console.error(' - Verify the templates repository is accessible');
console.error(
' - Check that bun is installed and build script works',
);
process.exit(1);
} finally {
// Always restore ARM64 flags if they were removed, even on deployment failure
if (originalDockerfileContent) {
console.log('\n๐ Restoring local development configuration...');
this.restoreDockerfileARM64Flags(originalDockerfileContent);
}
}
}
}
// Main execution
if (import.meta.url === `file://${process.argv[1]}`) {
const deployer = new CloudflareDeploymentManager();
deployer.deploy().catch((error) => {
console.error('Unexpected error:', error);
process.exit(1);
});
}
export default CloudflareDeploymentManager;