nuxthub
Version:
Interface with the NuxtHub platform from the command line.
380 lines (344 loc) • 16.1 kB
JavaScript
import prettyBytes from 'pretty-bytes'
import ora from 'ora'
import { consola } from 'consola'
import { colors } from 'consola/utils'
import { isCancel, confirm } from '@clack/prompts'
import { defineCommand, runCommand } from 'citty'
import { join, resolve, relative } from 'pathe'
import { execa } from 'execa'
import { setupDotenv } from 'c12'
import { $api, fetchUser, selectTeam, selectProject, projectPath, fetchProject, linkProject, gitInfo, determineEnvironment } from '../utils/index.mjs'
import { getStorage, getPathsToDeploy, getFile, uploadAssetsToCloudflare, uploadWorkersAssetsToCloudflare, isMetaPath, isWorkerMetaPath, isServerPath, isWorkerServerPath, getPublicFiles, getWorkerPublicFiles } from '../utils/deploy.mjs'
import { createMigrationsTable, fetchRemoteMigrations, queryDatabase } from '../utils/database.mjs'
import login from './login.mjs'
import ensure from './ensure.mjs'
export default defineCommand({
meta: {
name: 'deploy',
description: 'Deploy your project to NuxtHub.',
},
args: {
cwd: {
type: 'positional',
description: 'The directory to build and deploy.',
required: false,
default: '.'
},
build: {
type: 'boolean',
description: 'Build the project before deploying.',
default: true
},
production: {
type: 'boolean',
description: 'Force the current deployment as production.',
default: false
},
preview: {
type: 'boolean',
description: 'Force the current deployment as preview.',
default: false
},
env: {
type: 'string',
description: 'Force the environment of the current deployment. Available for Workers projects only.',
default: ''
},
dotenv: {
type: 'string',
description: 'Point to another .env file to load, relative to the root directory.',
default: ''
}
},
async run({ args }) {
const cmdCwd = process.cwd()
const cwd = resolve(cmdCwd, args.cwd)
if (args.dotenv) {
consola.info(`Loading env from \`${args.dotenv}\``)
await setupDotenv({
cwd,
fileName: args.dotenv
})
} else if (cwd !== cmdCwd) {
consola.info(`Loading env from \`${relative(cmdCwd, cwd)}/.env\``)
await setupDotenv({
cwd,
fileName: '.env'
})
}
let user = await fetchUser()
if (!user) {
consola.info('Please login to deploy your project or set the `NUXT_HUB_USER_TOKEN` environment variable.')
await runCommand(login, {})
user = await fetchUser()
}
let linkedProject = await fetchProject()
// If the project is not linked
if (!linkedProject) {
consola.info('No project is linked with the `NUXT_HUB_PROJECT_KEY` environment variable.')
const shouldDeploy = await confirm({
message: `Deploy ${colors.blueBright(projectPath())} to NuxtHub?`
})
if (!shouldDeploy || isCancel(shouldDeploy)) {
return consola.log('Cancelled.')
}
const team = await selectTeam()
if (!team) return
const project = await selectProject(team)
if (!project) return consola.log('Cancelled.')
await linkProject(project)
// Use correct project format
linkedProject = await fetchProject()
}
const git = gitInfo()
// Default to main branch
git.branch = git.branch || 'main'
let deployEnv = git.branch === linkedProject.productionBranch ? 'production' : 'preview'
if (args.production) {
git.branch = linkedProject.productionBranch
deployEnv = 'production'
} else if (args.preview) {
if (git.branch === linkedProject.productionBranch) {
git.branch += '-preview'
}
deployEnv = 'preview'
} else if (linkedProject.type === 'worker' && args.env) {
deployEnv = args.env
} else if (linkedProject.type === 'worker' && git.branch !== linkedProject.productionBranch) {
deployEnv = await determineEnvironment(linkedProject.teamSlug, linkedProject.slug, git.branch)
}
const deployEnvColored = deployEnv === 'production'
? colors.greenBright(deployEnv)
: deployEnv === 'preview'
? colors.yellowBright(deployEnv)
: colors.blueBright(deployEnv) // additional environments
consola.success(`Connected to ${colors.blueBright(linkedProject.teamSlug)} team.`)
consola.success(`Linked to ${colors.blueBright(linkedProject.slug)} project.`)
// #region Build
if (args.build) {
consola.info('Building the Nuxt project...')
// Ensure the NuxtHub Core module is installed and registered in the project
await runCommand(ensure, { rawArgs: [cwd] })
const nuxiBuildArgs = []
if (args.dotenv) {
nuxiBuildArgs.push(`--dotenv=${args.dotenv}`)
}
await execa({
stdio: 'inherit',
preferLocal: true,
cwd,
extendEnv: false,
env: {
REMOTE_PROJECT_TYPE: linkedProject.type === 'worker' ? 'workers' : 'pages'
}
})`nuxi build ${nuxiBuildArgs}`
.catch((err) => {
if (err.code === 'ENOENT') {
consola.error('`nuxt` is not installed, please make sure that you are inside a Nuxt project.')
process.exit(1)
}
throw err
})
}
// #endregion
// #region Prepare deployment
const distDir = join(cwd, 'dist')
const storage = await getStorage(distDir).catch((err) => {
consola.error(err.message.includes('directory not found') ? `${err.message}, please make sure that you have built your project.` : err.message)
process.exit(1)
})
const fileKeys = await storage.getKeys()
const pathsToDeploy = getPathsToDeploy(fileKeys)
const config = await storage.getItem('hub.config.json')
if (!config.nitroPreset && linkedProject.type === 'worker') {
consola.error('Please upgrade `@nuxthub/core` to the latest version to deploy to a worker project.')
process.exit(1)
}
const isWorkerPreset = ['cloudflare_module', 'cloudflare_durable', 'cloudflare-module', 'cloudflare-durable'].includes(config.nitroPreset)
const { format: formatNumber } = new Intl.NumberFormat('en-US')
let spinner = ora(`Preparing ${colors.blueBright(linkedProject.slug)} deployment for ${deployEnvColored}...`).start()
const spinnerColors = ['magenta', 'blue', 'yellow', 'green']
let spinnerColorIndex = 0
const spinnerColorInterval = setInterval(() => {
spinner.color = spinnerColors[spinnerColorIndex]
spinnerColorIndex = (spinnerColorIndex + 1) % spinnerColors.length
}, 2500)
let deploymentKey, serverFiles, metaFiles, completionToken
try {
let url = `/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/${deployEnv}/deploy/prepare`
let publicFiles, publicManifest
if (isWorkerPreset) {
url = `/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/${deployEnv}/deploy/worker/prepare`
publicFiles = await getWorkerPublicFiles(storage, pathsToDeploy)
/**
* { "/index.html": { hash: "hash", size: 30 }
*/
publicManifest = publicFiles.reduce((acc, file) => {
acc[file.path] = {
hash: file.hash,
size: file.size
}
return acc
}, {})
} else {
publicFiles = await getPublicFiles(storage, pathsToDeploy)
/**
* { "/index.html": "hash" }
*/
publicManifest = publicFiles.reduce((acc, file) => {
acc[file.path] = file.hash
return acc
}, {})
}
// Get deployment info by preparing the deployment
const deploymentInfo = await $api(url, {
method: 'POST',
body: {
config,
publicManifest
}
})
spinner.succeed(`${colors.blueBright(linkedProject.slug)} ready to deploy.`)
deploymentKey = deploymentInfo.deploymentKey
const { cloudflareUploadJwt, buckets, accountId } = deploymentInfo
// missingPublicHash is sent for pages & buckets for worker
let missingPublicHashes = deploymentInfo.missingPublicHashes || buckets.flat()
const publicFilesToUpload = publicFiles.filter(file => missingPublicHashes.includes(file.hash))
if (publicFilesToUpload.length) {
const totalSizeToUpload = publicFilesToUpload.reduce((acc, file) => acc + file.size, 0)
spinner = ora(`Uploading ${colors.blueBright(formatNumber(publicFilesToUpload.length))} new static assets (${colors.blueBright(prettyBytes(totalSizeToUpload))})...`).start()
if (linkedProject.type === 'pages') {
await uploadAssetsToCloudflare(publicFilesToUpload, cloudflareUploadJwt, ({ progressSize, totalSize }) => {
const percentage = Math.round((progressSize / totalSize) * 100)
spinner.text = `${percentage}% uploaded (${prettyBytes(progressSize)}/${prettyBytes(totalSize)})...`
})
} else {
completionToken = await uploadWorkersAssetsToCloudflare(accountId, publicFilesToUpload, cloudflareUploadJwt, ({ progressSize, totalSize }) => {
const percentage = Math.round((progressSize / totalSize) * 100)
spinner.text = `${percentage}% uploaded (${prettyBytes(progressSize)}/${prettyBytes(totalSize)})...`
})
}
spinner.succeed(`${colors.blueBright(formatNumber(publicFilesToUpload.length))} new static assets uploaded (${colors.blueBright(prettyBytes(totalSizeToUpload))})`)
}
if (publicFiles.length) {
const totalSize = publicFiles.reduce((acc, file) => acc + file.size, 0)
const totalGzipSize = publicFiles.reduce((acc, file) => acc + file.gzipSize, 0)
consola.info(`${colors.blueBright(formatNumber(publicFiles.length))} static assets (${colors.blueBright(prettyBytes(totalSize))} / ${colors.blueBright(prettyBytes(totalGzipSize))} gzip)`)
}
metaFiles = await Promise.all(pathsToDeploy.filter(isWorkerPreset ? isWorkerMetaPath : isMetaPath).map(p => getFile(storage, p, 'base64')))
serverFiles = await Promise.all(pathsToDeploy.filter(isWorkerPreset ? isWorkerServerPath : isServerPath).map(p => getFile(storage, p, 'base64')))
if (isWorkerPreset) {
serverFiles = serverFiles.map(file => ({
...file,
path: file.path.replace('/server/', '/')
}))
}
const serverFilesSize = serverFiles.reduce((acc, file) => acc + file.size, 0)
const serverFilesGzipSize = serverFiles.reduce((acc, file) => acc + file.gzipSize, 0)
consola.info(`${colors.blueBright(formatNumber(serverFiles.length))} server files (${colors.blueBright(prettyBytes(serverFilesSize))} / ${colors.blueBright(prettyBytes(serverFilesGzipSize))} gzip)...`)
} catch (err) {
spinner.fail(`Failed to deploy ${colors.blueBright(linkedProject.slug)} to ${deployEnvColored}.`)
consola.debug(err, err.data)
if (err.data) {
const message = err.data.statusMessage && err.data.message ? `${err.data.statusMessage} - ${err.data.message}` : (err.data.statusMessage || err.data.message)
consola.error(err.data.data?.issues || message || err.data)
}
else {
consola.error(err.message.split(' - ')[1] || err.message)
}
process.exit(1)
}
if (config.database) {
// #region Database migrations
const remoteMigrationsSpinner = ora(`Retrieving database migrations on ${deployEnvColored} for ${colors.blueBright(linkedProject.slug)}...`).start()
await createMigrationsTable({ env: deployEnv })
const remoteMigrations = await fetchRemoteMigrations({ env: deployEnv }).catch((error) => {
remoteMigrationsSpinner.fail(`Could not retrieve database migrations on ${deployEnvColored} for ${colors.blueBright(linkedProject.slug)}.`)
consola.error(error.message)
process.exit(1)
})
remoteMigrationsSpinner.succeed(`Found ${remoteMigrations.length} database migration${remoteMigrations.length === 1 ? '' : 's'} on ${colors.blueBright(linkedProject.slug)}`)
const localMigrations = fileKeys
.filter(fileKey => {
const isMigrationsDir = fileKey.startsWith('database:migrations:')
const isSqlFile = fileKey.endsWith('.sql')
return isMigrationsDir && isSqlFile
})
.map(fileName => {
return fileName
.replace('database:migrations:', '')
.replace('.sql', '')
})
const pendingMigrations = localMigrations.filter(localName => !remoteMigrations.find(({ name }) => name === localName))
if (!pendingMigrations.length) consola.info('No pending database migrations to apply.')
for (const migration of pendingMigrations) {
const migrationSpinner = ora(`Applying database migration ${colors.blueBright(migration)}...`).start()
let query = await storage.getItem(`database/migrations/${migration}.sql`)
if (query.at(-1) !== ';') query += ';' // ensure previous statement ended before running next query
query += `
INSERT INTO _hub_migrations (name) values ('${migration}');
`;
try {
await queryDatabase({ env: deployEnv, query })
} catch (error) {
migrationSpinner.fail(`Failed to apply database migration ${colors.blueBright(migration)}.`)
if (error) consola.error(error.response?._data?.message || error.message)
break
}
migrationSpinner.succeed(`Applied database migration ${colors.blueBright(migration)}.`)
}
// #endregion
// #region Database queries
const localQueries = fileKeys
.filter(fileKey => fileKey.startsWith('database:queries:') && fileKey.endsWith('.sql'))
.map(fileKey => fileKey.replace('database:queries:', '').replace('.sql', ''))
if (localQueries.length) {
const querySpinner = ora(`Applying ${colors.blueBright(formatNumber(localQueries.length))} database ${localQueries.length === 1 ? 'query' : 'queries'}...`).start()
for (const queryName of localQueries) {
const query = await storage.getItem(`database/queries/${queryName}.sql`)
try {
await queryDatabase({ env: deployEnv, query })
} catch (error) {
querySpinner.fail(`Failed to apply database query ${colors.blueBright(queryName)}.`)
if (error) consola.error(error.response?._data?.message || error.message)
break
}
}
querySpinner.succeed(`Applied ${colors.blueBright(formatNumber(localQueries.length))} database ${localQueries.length === 1 ? 'query' : 'queries'}.`)
}
// #endregion
}
// #region Complete deployment
spinner = ora(`Deploying ${colors.blueBright(linkedProject.slug)} to ${deployEnvColored}...`).start()
const deployment = await $api(`/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/${deployEnv}/deploy/${isWorkerPreset ? 'worker/complete' : 'complete'}`, {
method: 'POST',
body: {
deploymentKey,
git,
serverFiles,
metaFiles,
completionToken
},
}).catch((err) => {
spinner.fail(`Failed to deploy ${colors.blueBright(linkedProject.slug)} to ${deployEnvColored}.`)
// Error with workers size limit
if (err.data?.data?.name === 'ZodError') {
consola.error(err.data.data.issues)
}
else if (err.message.includes('- Error: ')) {
consola.error(err.message.split('- Error: ')[1])
} else {
consola.error(err.message.split(' - ')[1] || err.message)
}
process.exit(1)
})
spinner.succeed(`Deployed ${colors.blueBright(linkedProject.slug)} to ${deployEnvColored}...`)
// Check DNS & ready url for first deployment
consola.success(`Deployment is ready at ${colors.cyanBright(deployment.primaryUrl || deployment.url)}`)
if (deployment.isFirstDeploy) {
consola.info('As this is the first deployment, please note that domain propagation may take a few minutes.')
}
clearInterval(spinnerColorInterval)
process.exit(0)
},
})