@nuxfly/cli
Version:
CLI tool for deploying Nuxt applications to Fly.io
304 lines (255 loc) • 12 kB
JavaScript
import { join, basename } from 'path';
import { readFile } from 'fs/promises';
import consola from 'consola';
import { flyLaunch, executeFlyctl, ensurePublicBucketUrlSecret } from '../utils/flyctl.mjs';
import { ensureNuxflyDir, fileExists, writeFile, copyDrizzleMigrations } from '../utils/filesystem.mjs';
import { validateLaunchCommand } from '../utils/validation.mjs';
import { withErrorHandling, NuxflyError } from '../utils/errors.mjs';
import { getExistingBuckets, getOrgName, createLitestreamBucket, createPrivateBucket, createPublicBucket } from '../utils/buckets.mjs';
import { generateDockerfile, generateDockerignore } from '../templates/dockerfile.mjs';
import { generateFlyToml } from '../templates/fly-toml.mjs';
import { generateDrizzleConfig, generateLitestreamConfig, generateStartScript, generateDrizzlePackageJson } from '../templates/database.mjs';
import { installNuxflyDependencies } from '../utils/build.mjs';
import { loadConfig, getEnvironmentSpecificFlyTomlPath } from '../utils/config.mjs';
/**
* Launch command - runs fly launch and saves config to environment-specific fly.toml
*/
export const launch = withErrorHandling(async (args, config) => {
consola.info('🚀 Launching new Fly.io app...');
// Validate command requirements
await validateLaunchCommand(args);
// Ensure .nuxfly directory exists
const nuxflyDir = await ensureNuxflyDir(config);
// Check if environment-specific fly.toml already exists
const envFlyToml = getEnvironmentSpecificFlyTomlPath();
if (envFlyToml && fileExists(envFlyToml)) {
const flyTomlFilename = envFlyToml.split('/').pop();
consola.error(`❌ ${flyTomlFilename} already exists!`);
consola.info(`💡 Remove the existing ${flyTomlFilename} file first, or use a different environment with NUXFLY_ENV.`);
process.exit(1);
}
// Generate Dockerfile
if (!fileExists(join(nuxflyDir, 'Dockerfile'))) {
const dockerfileContent = generateDockerfile({
nodeVersion: config.nodeVersion,
});
await writeFile(join(nuxflyDir, 'Dockerfile'), dockerfileContent);
}
// Determine app name based on environment if not explicitly provided
let appName = args.name;
if (!appName) {
const env = process.env.NUXFLY_ENV;
if (env && env !== 'prod') {
// Load base config to get the base app name
const baseConfig = await loadConfig();
const baseAppName = baseConfig.app || 'nuxfly-app';
appName = `${baseAppName}-${env}`;
consola.info(`Using environment-specific app name: ${appName}`);
}
}
// Prepare launch options
const launchOptions = {
name: appName,
region: args.region,
noDeploy: args['no-deploy'] !== false, // Default to true (no deploy)
noObjectStorage: true, // Skip default bucket creation
config: envFlyToml !== join(process.cwd(), 'fly.toml') ? basename(envFlyToml) : undefined,
extraArgs: ['--ha=false'], // Will be populated with any additional args
};
// Add any extra arguments passed after --
if (args._) {
const dashIndex = process.argv.indexOf('--');
if (dashIndex !== -1) {
launchOptions.extraArgs = process.argv.slice(dashIndex + 1);
}
}
consola.debug('Launch options:', launchOptions);
let newConfig;
try {
// Generate environment-specific fly.toml first
newConfig = await loadConfig();
// CRITICAL FIX: Ensure the app name and region are set in the config object
// These should come from the launch options, not just the fly.toml
if (appName && !newConfig.app) {
newConfig.app = appName;
consola.debug(`Set app name in config: ${appName}`);
}
if (args.region && !newConfig.region) {
newConfig.region = args.region;
consola.debug(`Set region in config: ${args.region}`);
}
const newFlyTomlContent = generateFlyToml(newConfig);
await writeFile(envFlyToml, newFlyTomlContent);
consola.success(`Generated environment-specific fly.toml: ${envFlyToml}`);
// Generate .dockerignore file
const dockerignoreContent = generateDockerignore();
await writeFile(join(process.cwd(), '.dockerignore'), dockerignoreContent);
// Run fly launch with the pre-generated config
consola.info('Running fly launch...');
consola.debug('Launch command:', `flyctl launch ${launchOptions.name ? `--name ${launchOptions.name}` : ''} ${launchOptions.region ? `--region ${launchOptions.region}` : ''} ${launchOptions.noDeploy ? '--no-deploy' : ''} --no-object-storage ${launchOptions.config ? `--config ${launchOptions.config}` : ''} --yes --ha=false`);
await flyLaunch(launchOptions, newConfig);
// Generate database-related files
consola.info('📄 Generating database configuration files...');
// Generate drizzle.config.ts
const drizzleConfigContent = generateDrizzleConfig();
await writeFile(join(nuxflyDir, 'drizzle.config.ts'), drizzleConfigContent);
consola.success('Generated drizzle.config.ts');
// Generate litestream.yml
const litestreamConfigContent = generateLitestreamConfig({});
await writeFile(join(nuxflyDir, 'litestream.yml'), litestreamConfigContent);
consola.success('Generated litestream.yml');
// Generate start.sh
const startScriptContent = generateStartScript();
await writeFile(join(nuxflyDir, 'start.sh'), startScriptContent);
consola.success('Generated start.sh');
// Generate package.json for drizzle-kit
const drizzlePackageJsonContent = await generateDrizzlePackageJson();
await writeFile(join(nuxflyDir, 'package.json'), drizzlePackageJsonContent);
consola.success('Generated package.json for drizzle-kit');
// Install dependencies to populate package-lock.json
await installNuxflyDependencies(nuxflyDir);
// Copy drizzle migrations from parent project
await copyDrizzleMigrations(newConfig);
// Create SQLite volume after successful launch
const region = args.region || await extractRegionFromFlyToml(envFlyToml) || 'ord';
const volumeSize = args.size || '1';
try {
await createSqliteVolume(region, volumeSize, newConfig);
} catch (error) {
throw new NuxflyError(`Failed to create SQLite volume: ${error.message}`, {
suggestion: `You can create the volume manually with: flyctl volumes create sqlite_data --region ${region} --size ${volumeSize}`,
});
}
// Create S3 buckets after successful launch
try {
const existingBuckets = await getExistingBuckets(newConfig);
consola.info('🪣 Creating S3 buckets...');
// Get organization name for storage commands
const orgName = await getOrgName(newConfig);
if (!orgName) {
consola.warn('Could not determine organization name. Bucket creation may fail.');
consola.info('You can create buckets manually later during deployment.');
return;
}
consola.debug(`Using organization: ${orgName}`);
// Load Nuxt config to detect bucket requirements
const nuxflyConfig = newConfig.nuxt?.nuxfly || {};
// Create litestream bucket
if (nuxflyConfig.litestream) {
if (!existingBuckets.includes(`${newConfig.app}-litestream`)) {
await createLitestreamBucket(orgName, newConfig);
} else {
consola.error('Litestream bucket already exists, skipping creation. You will need to set the NUXT_NUXFLY_LITESTREAM_S3_ secrets manually.');
}
}
// Create public bucket if configured
if (nuxflyConfig.publicStorage) {
if (!existingBuckets.includes(`${newConfig.app}-public`)) {
await createPublicBucket(orgName, newConfig);
} else {
consola.error('Public bucket already exists, skipping creation. You will need to set the NUXT_NUXFLY_PUBLIC_BUCKET_S3_ secrets manually.');
}
}
// Create private bucket if configured
if (nuxflyConfig.privateStorage) {
if (!existingBuckets.includes(`${newConfig.app}-private`)) {
await createPrivateBucket(orgName, newConfig);
} else {
consola.error('Private bucket already exists, skipping creation. You will need to set the NUXT_NUXFLY_PRIVATE_BUCKET_S3_ secrets manually.');
}
}
} catch (error) {
throw new NuxflyError(`Failed to create S3 buckets: ${error.message}`, {
suggestion: 'You can create buckets manually later with: nuxfly buckets create',
cause: error,
});
}
// Set public bucket URL secret if needed
try {
await ensurePublicBucketUrlSecret(newConfig);
} catch (error) {
consola.warn(`Failed to set public bucket URL secret: ${error.message}`);
consola.debug('Continuing with launch...');
}
// Display next steps
displayNextSteps(newConfig || config);
} catch (error) {
if (error.exitCode === 130) {
// User cancelled (Ctrl+C)
consola.info('Launch cancelled by user');
return;
}
throw new NuxflyError(`Launch failed: ${error.message}`, {
suggestion: 'Check the fly launch output above for details',
cause: error,
});
}
});
/**
* Parse fly.toml file and extract the primary region
*/
async function extractRegionFromFlyToml(flyTomlPath) {
try {
const flyTomlContent = await readFile(flyTomlPath, 'utf-8');
// Parse the primary_region from the fly.toml file
const regionMatch = flyTomlContent.match(/^primary_region\s*=\s*['"]([^'"]+)['"]$/m);
if (regionMatch && regionMatch[1]) {
return regionMatch[1];
}
consola.debug('No primary_region found in fly.toml');
return null;
} catch (error) {
consola.debug('Failed to read or parse fly.toml:', error.message);
return null;
}
}
/**
* Create SQLite volume for the app
*/
async function createSqliteVolume(region, size, config) {
consola.info(`Creating SQLite volume (${size}GB) in region ${region}...`);
try {
// Check if sqlite_data volume already exists
const { executeFlyctlWithOutput } = await import('../utils/flyctl.mjs');
const volumeListResult = await executeFlyctlWithOutput('volumes', ['list'], config);
// Parse the output to check for existing sqlite_data volume
const existingVolumes = volumeListResult.stdout.split('\n');
const sqliteVolumeExists = existingVolumes.some(line =>
line.includes('sqlite_data') && line.includes(region)
);
if (sqliteVolumeExists) {
consola.info(`SQLite volume 'sqlite_data' already exists in region ${region}`);
return;
}
// Create the volume if it doesn't exist
const volumeArgs = ['sqlite_data', '--region', region, '--size', size];
await executeFlyctl('volumes', ['create', ...volumeArgs], config);
consola.success(`SQLite volume created successfully in ${region}`);
} catch (error) {
// Fail the entire launch if volume creation fails
throw new NuxflyError(`Failed to create SQLite volume: ${error.message}`, {
suggestion: `You can create the volume manually later with: flyctl volumes create sqlite_data --region ${region} --size ${size}`,
cause: error,
});
}
}
/**
* Display helpful next steps after successful launch
*/
function displayNextSteps(config) {
const flyTomlPath = getEnvironmentSpecificFlyTomlPath() || 'fly.toml';
const flyTomlFilename = flyTomlPath.split('/').pop();
consola.box({
title: '🎉 App created successfully!',
message: `Your app "${config.app}" has been created on Fly.io but is not yet deployed.
Next steps:
1. Edit your nuxt.config.js nuxfly section to configure additional buckets if needed
2. Inspect everything in .nuxfly/ and set additional environment variables (optional): nuxfly secrets set KEY=value
3. Deploy your app: nuxfly deploy
If you're using version control, it is recommended to commit your ${flyTomlFilename} file and the contents of .nuxfly/.`,
style: {
borderColor: 'green',
padding: 1,
},
});
}