diff --git a/packages/cli/src/commands/deploy.js b/packages/cli/src/commands/deploy.js index a57a99e755fe..56af9f0192ef 100644 --- a/packages/cli/src/commands/deploy.js +++ b/packages/cli/src/commands/deploy.js @@ -1,14 +1,25 @@ +import terminalLink from 'terminal-link' + +import * as baremetalCommand from './deploy/baremetal' +import * as flightcontrolCommand from './deploy/flightcontrol' +import * as netlifyCommand from './deploy/netlify' +import * as renderCommand from './deploy/render' +import * as vercelCommand from './deploy/vercel' + export const command = 'deploy ' export const description = 'Deploy your Redwood project' -import terminalLink from 'terminal-link' -export const builder = (yargs) => +export function builder(yargs) { yargs - .commandDir('./deploy', { recurse: false }) - .demandCommand() + .command(baremetalCommand) + .command(flightcontrolCommand) + .command(netlifyCommand) + .command(renderCommand) + .command(vercelCommand) .epilogue( `Also see the ${terminalLink( 'Redwood CLI Reference', 'https://redwoodjs.com/docs/cli-commands#deploy' )}\n` ) +} diff --git a/packages/cli/src/commands/deploy/__tests__/baremetal.test.js b/packages/cli/src/commands/deploy/__tests__/baremetal.test.js index 3a076da64799..b2b6a1f4dc91 100644 --- a/packages/cli/src/commands/deploy/__tests__/baremetal.test.js +++ b/packages/cli/src/commands/deploy/__tests__/baremetal.test.js @@ -8,7 +8,7 @@ jest.mock('@redwoodjs/project-config', () => { } }) -import * as baremetal from '../baremetal' +import * as baremetal from '../baremetalHandler' describe('verifyConfig', () => { it('throws an error if no environment specified', () => { diff --git a/packages/cli/src/commands/deploy/baremetal.js b/packages/cli/src/commands/deploy/baremetal.js index 52f3945a2b18..31fa6ec0f522 100644 --- a/packages/cli/src/commands/deploy/baremetal.js +++ b/packages/cli/src/commands/deploy/baremetal.js @@ -1,46 +1,9 @@ -import path from 'path' - -import toml from '@iarna/toml' -import boxen from 'boxen' -import fs from 'fs-extra' -import { Listr } from 'listr2' -import { env as envInterpolation } from 'string-env-interpolation' import terminalLink from 'terminal-link' -import { titleCase } from 'title-case' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' - -import { getPaths } from '../../lib' -import c from '../../lib/colors' - -const CONFIG_FILENAME = 'deploy.toml' -const SYMLINK_FLAGS = '-nsf' -const CURRENT_RELEASE_SYMLINK_NAME = 'current' -const LIFECYCLE_HOOKS = ['before', 'after'] -export const DEFAULT_SERVER_CONFIG = { - port: 22, - branch: 'main', - packageManagerCommand: 'yarn', - monitorCommand: 'pm2', - sides: ['api', 'web'], - keepReleases: 5, -} export const command = 'baremetal [environment]' export const description = 'Deploy to baremetal server(s)' -// force all paths to have forward slashes so that you can deploy to *nix -// systems from a Windows system -const pathJoin = path.posix.join - -export const execaOptions = { - cwd: pathJoin(getPaths().base), - stdio: 'inherit', - shell: true, - cleanup: true, -} - -export const builder = (yargs) => { +export function builder(yargs) { yargs.positional('environment', { describe: 'The environment to deploy to', type: 'string', @@ -131,588 +94,7 @@ export const builder = (yargs) => { ) } -// Executes a single command via SSH connection. Throws an error and sets -// the exit code with the same code returned from the SSH command. -const sshExec = async (ssh, path, command, args) => { - let sshCommand = command - - if (args) { - sshCommand += ` ${args.join(' ')}` - } - - const result = await ssh.execCommand(sshCommand, { - cwd: path, - }) - - if (result.code !== 0) { - const error = new Error( - `Error while running command \`${command} ${args.join(' ')}\`` - ) - error.exitCode = result.code - throw error - } - - return result -} - -export const throwMissingConfig = (name) => { - throw new Error( - `"${name}" config option not set. See https://redwoodjs.com/docs/deployment/baremetal#deploytoml` - ) -} - -export const verifyConfig = (config, yargs) => { - if (!yargs.environment) { - throw new Error( - 'Must specify an environment to deploy to, ex: `yarn rw deploy baremetal production`' - ) - } - - if (!config[yargs.environment]) { - throw new Error(`No servers found for environment "${yargs.environment}"`) - } - - return true -} - -export const verifyServerConfig = (config) => { - if (!config.host) { - throwMissingConfig('host') - } - - if (!config.path) { - throwMissingConfig('path') - } - - if (!config.repo) { - throwMissingConfig('repo') - } - - return true -} - -const symlinkCurrentCommand = async (dir, ssh, path) => { - return await sshExec(ssh, path, 'ln', [ - SYMLINK_FLAGS, - dir, - CURRENT_RELEASE_SYMLINK_NAME, - ]) -} - -const restartProcessCommand = async (processName, ssh, serverConfig, path) => { - return await sshExec(ssh, path, serverConfig.monitorCommand, [ - 'restart', - processName, - ]) -} - -export const serverConfigWithDefaults = (serverConfig, yargs) => { - return { - ...DEFAULT_SERVER_CONFIG, - ...serverConfig, - branch: yargs.branch || serverConfig.branch || DEFAULT_SERVER_CONFIG.branch, - } -} - -export const maintenanceTasks = (status, ssh, serverConfig) => { - const deployPath = pathJoin(serverConfig.path, CURRENT_RELEASE_SYMLINK_NAME) - const tasks = [] - - if (status === 'up') { - tasks.push({ - title: `Enabling maintenance page...`, - task: async () => { - await sshExec(ssh, deployPath, 'cp', [ - pathJoin('web', 'dist', '200.html'), - pathJoin('web', 'dist', '200.html.orig'), - ]) - await sshExec(ssh, deployPath, 'ln', [ - SYMLINK_FLAGS, - pathJoin('..', 'src', 'maintenance.html'), - pathJoin('web', 'dist', '200.html'), - ]) - }, - }) - - if (serverConfig.processNames) { - tasks.push({ - title: `Stopping ${serverConfig.processNames.join(', ')} processes...`, - task: async () => { - await sshExec(ssh, serverConfig.path, serverConfig.monitorCommand, [ - 'stop', - serverConfig.processNames.join(' '), - ]) - }, - }) - } - } else if (status === 'down') { - tasks.push({ - title: `Starting ${serverConfig.processNames.join(', ')} processes...`, - task: async () => { - await sshExec(ssh, serverConfig.path, serverConfig.monitorCommand, [ - 'start', - serverConfig.processNames.join(' '), - ]) - }, - }) - - if (serverConfig.processNames) { - tasks.push({ - title: `Disabling maintenance page...`, - task: async () => { - await sshExec(ssh, deployPath, 'rm', [ - pathJoin('web', 'dist', '200.html'), - ]) - await sshExec(ssh, deployPath, 'cp', [ - pathJoin('web', 'dist', '200.html.orig'), - pathJoin('web', 'dist', '200.html'), - ]) - }, - }) - } - } - - return tasks -} - -export const rollbackTasks = (count, ssh, serverConfig) => { - let rollbackCount = 1 - - if (parseInt(count) === count) { - rollbackCount = count - } - - const tasks = [ - { - title: `Rolling back ${rollbackCount} release(s)...`, - task: async () => { - const currentLink = ( - await sshExec(ssh, serverConfig.path, 'readlink', ['-f', 'current']) - ).stdout - .split('/') - .pop() - const dirs = ( - await sshExec(ssh, serverConfig.path, 'ls', ['-t']) - ).stdout - .split('\n') - .filter((dirs) => !dirs.match(/current/)) - - const deployedIndex = dirs.indexOf(currentLink) - const rollbackIndex = deployedIndex + rollbackCount - - if (dirs[rollbackIndex]) { - console.info('Setting symlink') - await symlinkCurrentCommand( - dirs[rollbackIndex], - ssh, - serverConfig.path - ) - } else { - throw new Error( - `Cannot rollback ${rollbackCount} release(s): ${ - dirs.length - dirs.indexOf(currentLink) - 1 - } previous release(s) available` - ) - } - }, - }, - ] - - if (serverConfig.processNames) { - for (const processName of serverConfig.processNames) { - tasks.push({ - title: `Restarting ${processName} process...`, - task: async () => { - await restartProcessCommand( - processName, - ssh, - serverConfig, - serverConfig.path - ) - }, - }) - } - } - - return tasks -} - -export const lifecycleTask = ( - lifecycle, - task, - skip, - { serverLifecycle, ssh, cmdPath } -) => { - if (serverLifecycle[lifecycle]?.[task]) { - const tasks = [] - - for (const command of serverLifecycle[lifecycle][task]) { - tasks.push({ - title: `${titleCase(lifecycle)} ${task}: \`${command}\``, - task: async () => { - await sshExec(ssh, cmdPath, command) - }, - skip: () => skip, - }) - } - - return tasks - } -} - -// wraps a given command with any defined before/after lifecycle commands -export const commandWithLifecycleEvents = ({ name, config, skip, command }) => { - const tasks = [] - - tasks.push(lifecycleTask('before', name, skip, config)) - tasks.push({ ...command, skip: () => skip }) - tasks.push(lifecycleTask('after', name, skip, config)) - - return tasks.flat().filter((t) => t) -} - -export const deployTasks = (yargs, ssh, serverConfig, serverLifecycle) => { - const cmdPath = pathJoin(serverConfig.path, yargs.releaseDir) - const config = { yargs, ssh, serverConfig, serverLifecycle, cmdPath } - const tasks = [] - - tasks.push( - commandWithLifecycleEvents({ - name: 'update', - config: { ...config, cmdPath: serverConfig.path }, - skip: !yargs.update, - command: { - title: `Cloning \`${serverConfig.branch}\` branch...`, - task: async () => { - await sshExec(ssh, serverConfig.path, 'git', [ - 'clone', - `--branch=${serverConfig.branch}`, - `--depth=1`, - serverConfig.repo, - yargs.releaseDir, - ]) - }, - }, - }) - ) - - tasks.push( - commandWithLifecycleEvents({ - name: 'symlinkEnv', - config, - skip: !yargs.update, - command: { - title: `Symlink .env...`, - task: async () => { - await sshExec(ssh, cmdPath, 'ln', [SYMLINK_FLAGS, '../.env', '.env']) - }, - }, - }) - ) - - tasks.push( - commandWithLifecycleEvents({ - name: 'install', - config, - skip: !yargs.install, - command: { - title: `Installing dependencies...`, - task: async () => { - await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ - 'install', - ]) - }, - }, - }) - ) - - tasks.push( - commandWithLifecycleEvents({ - name: 'migrate', - config, - skip: !yargs.migrate || serverConfig?.migrate === false, - command: { - title: `DB Migrations...`, - task: async () => { - await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ - 'rw', - 'prisma', - 'migrate', - 'deploy', - ]) - await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ - 'rw', - 'prisma', - 'generate', - ]) - await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ - 'rw', - 'dataMigrate', - 'up', - ]) - }, - }, - }) - ) - - for (const side of serverConfig.sides) { - tasks.push( - commandWithLifecycleEvents({ - name: 'build', - config, - skip: !yargs.build, - command: { - title: `Building ${side}...`, - task: async () => { - await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ - 'rw', - 'build', - side, - ]) - }, - }, - }) - ) - } - - tasks.push( - commandWithLifecycleEvents({ - name: 'symlinkCurrent', - config, - skip: !yargs.update, - command: { - title: `Symlinking current release...`, - task: async () => { - await symlinkCurrentCommand(yargs.releaseDir, ssh, serverConfig.path) - }, - skip: () => !yargs.update, - }, - }) - ) - - if (serverConfig.processNames) { - for (const processName of serverConfig.processNames) { - if (yargs.firstRun) { - tasks.push( - commandWithLifecycleEvents({ - name: 'restart', - config, - skip: !yargs.restart, - command: { - title: `Starting ${processName} process for the first time...`, - task: async () => { - await sshExec( - ssh, - serverConfig.path, - serverConfig.monitorCommand, - [ - 'start', - pathJoin( - CURRENT_RELEASE_SYMLINK_NAME, - 'ecosystem.config.js' - ), - '--only', - processName, - ] - ) - }, - }, - }) - ) - tasks.push({ - title: `Saving ${processName} state for future startup...`, - task: async () => { - await sshExec(ssh, serverConfig.path, serverConfig.monitorCommand, [ - 'save', - ]) - }, - skip: () => !yargs.restart, - }) - } else { - tasks.push( - commandWithLifecycleEvents({ - name: 'restart', - config, - skip: !yargs.restart, - command: { - title: `Restarting ${processName} process...`, - task: async () => { - await restartProcessCommand( - processName, - ssh, - serverConfig, - serverConfig.path - ) - }, - }, - }) - ) - } - } - } - - tasks.push( - commandWithLifecycleEvents({ - name: 'cleanup', - config: { ...config, cmdPath: serverConfig.path }, - skip: !yargs.cleanup, - command: { - title: `Cleaning up old deploys...`, - task: async () => { - // add 2 to skip `current` and start on the keepReleases + 1th release - const fileStartIndex = serverConfig.keepReleases + 2 - - await sshExec( - ssh, - serverConfig.path, - `ls -t | tail -n +${fileStartIndex} | xargs rm -rf` - ) - }, - }, - }) - ) - - return tasks.flat().filter((e) => e) -} - -// merges additional lifecycle events into an existing object -const mergeLifecycleEvents = (lifecycle, other) => { - let lifecycleCopy = JSON.parse(JSON.stringify(lifecycle)) - - for (const hook of LIFECYCLE_HOOKS) { - for (const key in other[hook]) { - lifecycleCopy[hook][key] = (lifecycleCopy[hook][key] || []).concat( - other[hook][key] - ) - } - } - - return lifecycleCopy -} - -export const parseConfig = (yargs, rawConfigToml) => { - const configToml = envInterpolation(rawConfigToml) - const config = toml.parse(configToml) - let envConfig - const emptyLifecycle = {} - - verifyConfig(config, yargs) - - // start with an empty set of hooks, { before: {}, after: {} } - for (const hook of LIFECYCLE_HOOKS) { - emptyLifecycle[hook] = {} - } - - // global lifecycle config - let envLifecycle = mergeLifecycleEvents(emptyLifecycle, config) - - // get config for given environment - envConfig = config[yargs.environment] - envLifecycle = mergeLifecycleEvents(envLifecycle, envConfig) - - return { envConfig, envLifecycle } -} - -export const commands = (yargs, ssh) => { - const deployConfig = fs - .readFileSync(pathJoin(getPaths().base, CONFIG_FILENAME)) - .toString() - - let { envConfig, envLifecycle } = parseConfig(yargs, deployConfig) - let servers = [] - let tasks = [] - - // loop through each server in deploy.toml - for (const config of envConfig.servers) { - // merge in defaults - const serverConfig = serverConfigWithDefaults(config, yargs) - - verifyServerConfig(serverConfig) - - // server-specific lifecycle - const serverLifecycle = mergeLifecycleEvents(envLifecycle, serverConfig) - - tasks.push({ - title: 'Connecting...', - task: () => - ssh.connect({ - host: serverConfig.host, - port: serverConfig.port, - username: serverConfig.username, - password: serverConfig.password, - privateKey: serverConfig.privateKey, - privateKeyPath: serverConfig.privateKeyPath, - passphrase: serverConfig.passphrase, - agent: serverConfig.agentForward && process.env.SSH_AUTH_SOCK, - agentForward: serverConfig.agentForward, - }), - }) - - if (yargs.maintenance) { - tasks = tasks.concat( - maintenanceTasks(yargs.maintenance, ssh, serverConfig) - ) - } else if (yargs.rollback) { - tasks = tasks.concat(rollbackTasks(yargs.rollback, ssh, serverConfig)) - } else { - tasks = tasks.concat( - deployTasks(yargs, ssh, serverConfig, serverLifecycle) - ) - } - - tasks.push({ - title: 'Disconnecting...', - task: () => ssh.dispose(), - }) - - // Sets each server as a "parent" task so that the actual deploy tasks - // run as children. Each server deploy can run concurrently - servers.push({ - title: serverConfig.host, - task: () => { - return new Listr(tasks) - }, - }) - } - - return servers -} - -export const handler = async (yargs) => { - recordTelemetryAttributes({ - command: 'deploy baremetal', - firstRun: yargs.firstRun, - update: yargs.update, - install: yargs.install, - migrate: yargs.migrate, - build: yargs.build, - restart: yargs.restart, - cleanup: yargs.cleanup, - maintenance: yargs.maintenance, - rollback: yargs.rollback, - verbose: yargs.verbose, - }) - - const { NodeSSH } = require('node-ssh') - const ssh = new NodeSSH() - - try { - const tasks = new Listr(commands(yargs, ssh), { - concurrent: true, - exitOnError: true, - renderer: yargs.verbose && 'verbose', - }) - await tasks.run() - } catch (e) { - console.error(c.error('\nDeploy failed:')) - console.error( - boxen(e.stderr || e.message, { - padding: { top: 0, bottom: 0, right: 1, left: 1 }, - margin: 0, - borderColor: 'red', - }) - ) - - process.exit(e?.exitCode || 1) - } +export async function handler(options) { + const { handler } = await import('./baremetalHandler.js') + return handler(options) } diff --git a/packages/cli/src/commands/deploy/baremetalHandler.js b/packages/cli/src/commands/deploy/baremetalHandler.js new file mode 100644 index 000000000000..b1421081402d --- /dev/null +++ b/packages/cli/src/commands/deploy/baremetalHandler.js @@ -0,0 +1,615 @@ +import path from 'path' + +import toml from '@iarna/toml' +import boxen from 'boxen' +import fs from 'fs-extra' +import { Listr } from 'listr2' +import { env as envInterpolation } from 'string-env-interpolation' +import { titleCase } from 'title-case' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' + +import { getPaths } from '../../lib' +import c from '../../lib/colors' + +const CONFIG_FILENAME = 'deploy.toml' +const SYMLINK_FLAGS = '-nsf' +const CURRENT_RELEASE_SYMLINK_NAME = 'current' +const LIFECYCLE_HOOKS = ['before', 'after'] +export const DEFAULT_SERVER_CONFIG = { + port: 22, + branch: 'main', + packageManagerCommand: 'yarn', + monitorCommand: 'pm2', + sides: ['api', 'web'], + keepReleases: 5, +} + +// Force all paths to have forward slashes so that we can deploy to *nix systems from a Windows system. +const pathJoin = path.posix.join + +// Executes a single command via SSH connection. Throws an error and sets +// the exit code with the same code returned from the SSH command. +const sshExec = async (ssh, path, command, args) => { + let sshCommand = command + + if (args) { + sshCommand += ` ${args.join(' ')}` + } + + const result = await ssh.execCommand(sshCommand, { + cwd: path, + }) + + if (result.code !== 0) { + const error = new Error( + `Error while running command \`${command} ${args.join(' ')}\`` + ) + error.exitCode = result.code + throw error + } + + return result +} + +const throwMissingConfig = (name) => { + throw new Error( + `"${name}" config option not set. See https://redwoodjs.com/docs/deployment/baremetal#deploytoml` + ) +} + +export const verifyConfig = (config, yargs) => { + if (!yargs.environment) { + throw new Error( + 'Must specify an environment to deploy to, ex: `yarn rw deploy baremetal production`' + ) + } + + if (!config[yargs.environment]) { + throw new Error(`No servers found for environment "${yargs.environment}"`) + } + + return true +} + +export const verifyServerConfig = (config) => { + if (!config.host) { + throwMissingConfig('host') + } + + if (!config.path) { + throwMissingConfig('path') + } + + if (!config.repo) { + throwMissingConfig('repo') + } + + return true +} + +const symlinkCurrentCommand = async (dir, ssh, path) => { + return await sshExec(ssh, path, 'ln', [ + SYMLINK_FLAGS, + dir, + CURRENT_RELEASE_SYMLINK_NAME, + ]) +} + +const restartProcessCommand = async (processName, ssh, serverConfig, path) => { + return await sshExec(ssh, path, serverConfig.monitorCommand, [ + 'restart', + processName, + ]) +} + +export const serverConfigWithDefaults = (serverConfig, yargs) => { + return { + ...DEFAULT_SERVER_CONFIG, + ...serverConfig, + branch: yargs.branch || serverConfig.branch || DEFAULT_SERVER_CONFIG.branch, + } +} + +export const maintenanceTasks = (status, ssh, serverConfig) => { + const deployPath = pathJoin(serverConfig.path, CURRENT_RELEASE_SYMLINK_NAME) + const tasks = [] + + if (status === 'up') { + tasks.push({ + title: `Enabling maintenance page...`, + task: async () => { + await sshExec(ssh, deployPath, 'cp', [ + pathJoin('web', 'dist', '200.html'), + pathJoin('web', 'dist', '200.html.orig'), + ]) + await sshExec(ssh, deployPath, 'ln', [ + SYMLINK_FLAGS, + pathJoin('..', 'src', 'maintenance.html'), + pathJoin('web', 'dist', '200.html'), + ]) + }, + }) + + if (serverConfig.processNames) { + tasks.push({ + title: `Stopping ${serverConfig.processNames.join(', ')} processes...`, + task: async () => { + await sshExec(ssh, serverConfig.path, serverConfig.monitorCommand, [ + 'stop', + serverConfig.processNames.join(' '), + ]) + }, + }) + } + } else if (status === 'down') { + tasks.push({ + title: `Starting ${serverConfig.processNames.join(', ')} processes...`, + task: async () => { + await sshExec(ssh, serverConfig.path, serverConfig.monitorCommand, [ + 'start', + serverConfig.processNames.join(' '), + ]) + }, + }) + + if (serverConfig.processNames) { + tasks.push({ + title: `Disabling maintenance page...`, + task: async () => { + await sshExec(ssh, deployPath, 'rm', [ + pathJoin('web', 'dist', '200.html'), + ]) + await sshExec(ssh, deployPath, 'cp', [ + pathJoin('web', 'dist', '200.html.orig'), + pathJoin('web', 'dist', '200.html'), + ]) + }, + }) + } + } + + return tasks +} + +export const rollbackTasks = (count, ssh, serverConfig) => { + let rollbackCount = 1 + + if (parseInt(count) === count) { + rollbackCount = count + } + + const tasks = [ + { + title: `Rolling back ${rollbackCount} release(s)...`, + task: async () => { + const currentLink = ( + await sshExec(ssh, serverConfig.path, 'readlink', ['-f', 'current']) + ).stdout + .split('/') + .pop() + const dirs = ( + await sshExec(ssh, serverConfig.path, 'ls', ['-t']) + ).stdout + .split('\n') + .filter((dirs) => !dirs.match(/current/)) + + const deployedIndex = dirs.indexOf(currentLink) + const rollbackIndex = deployedIndex + rollbackCount + + if (dirs[rollbackIndex]) { + console.info('Setting symlink') + await symlinkCurrentCommand( + dirs[rollbackIndex], + ssh, + serverConfig.path + ) + } else { + throw new Error( + `Cannot rollback ${rollbackCount} release(s): ${ + dirs.length - dirs.indexOf(currentLink) - 1 + } previous release(s) available` + ) + } + }, + }, + ] + + if (serverConfig.processNames) { + for (const processName of serverConfig.processNames) { + tasks.push({ + title: `Restarting ${processName} process...`, + task: async () => { + await restartProcessCommand( + processName, + ssh, + serverConfig, + serverConfig.path + ) + }, + }) + } + } + + return tasks +} + +const lifecycleTask = ( + lifecycle, + task, + skip, + { serverLifecycle, ssh, cmdPath } +) => { + if (serverLifecycle[lifecycle]?.[task]) { + const tasks = [] + + for (const command of serverLifecycle[lifecycle][task]) { + tasks.push({ + title: `${titleCase(lifecycle)} ${task}: \`${command}\``, + task: async () => { + await sshExec(ssh, cmdPath, command) + }, + skip: () => skip, + }) + } + + return tasks + } +} + +// wraps a given command with any defined before/after lifecycle commands +export const commandWithLifecycleEvents = ({ name, config, skip, command }) => { + const tasks = [] + + tasks.push(lifecycleTask('before', name, skip, config)) + tasks.push({ ...command, skip: () => skip }) + tasks.push(lifecycleTask('after', name, skip, config)) + + return tasks.flat().filter((t) => t) +} + +export const deployTasks = (yargs, ssh, serverConfig, serverLifecycle) => { + const cmdPath = pathJoin(serverConfig.path, yargs.releaseDir) + const config = { yargs, ssh, serverConfig, serverLifecycle, cmdPath } + const tasks = [] + + tasks.push( + commandWithLifecycleEvents({ + name: 'update', + config: { ...config, cmdPath: serverConfig.path }, + skip: !yargs.update, + command: { + title: `Cloning \`${serverConfig.branch}\` branch...`, + task: async () => { + await sshExec(ssh, serverConfig.path, 'git', [ + 'clone', + `--branch=${serverConfig.branch}`, + `--depth=1`, + serverConfig.repo, + yargs.releaseDir, + ]) + }, + }, + }) + ) + + tasks.push( + commandWithLifecycleEvents({ + name: 'symlinkEnv', + config, + skip: !yargs.update, + command: { + title: `Symlink .env...`, + task: async () => { + await sshExec(ssh, cmdPath, 'ln', [SYMLINK_FLAGS, '../.env', '.env']) + }, + }, + }) + ) + + tasks.push( + commandWithLifecycleEvents({ + name: 'install', + config, + skip: !yargs.install, + command: { + title: `Installing dependencies...`, + task: async () => { + await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ + 'install', + ]) + }, + }, + }) + ) + + tasks.push( + commandWithLifecycleEvents({ + name: 'migrate', + config, + skip: !yargs.migrate || serverConfig?.migrate === false, + command: { + title: `DB Migrations...`, + task: async () => { + await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ + 'rw', + 'prisma', + 'migrate', + 'deploy', + ]) + await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ + 'rw', + 'prisma', + 'generate', + ]) + await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ + 'rw', + 'dataMigrate', + 'up', + ]) + }, + }, + }) + ) + + for (const side of serverConfig.sides) { + tasks.push( + commandWithLifecycleEvents({ + name: 'build', + config, + skip: !yargs.build, + command: { + title: `Building ${side}...`, + task: async () => { + await sshExec(ssh, cmdPath, serverConfig.packageManagerCommand, [ + 'rw', + 'build', + side, + ]) + }, + }, + }) + ) + } + + tasks.push( + commandWithLifecycleEvents({ + name: 'symlinkCurrent', + config, + skip: !yargs.update, + command: { + title: `Symlinking current release...`, + task: async () => { + await symlinkCurrentCommand(yargs.releaseDir, ssh, serverConfig.path) + }, + skip: () => !yargs.update, + }, + }) + ) + + if (serverConfig.processNames) { + for (const processName of serverConfig.processNames) { + if (yargs.firstRun) { + tasks.push( + commandWithLifecycleEvents({ + name: 'restart', + config, + skip: !yargs.restart, + command: { + title: `Starting ${processName} process for the first time...`, + task: async () => { + await sshExec( + ssh, + serverConfig.path, + serverConfig.monitorCommand, + [ + 'start', + pathJoin( + CURRENT_RELEASE_SYMLINK_NAME, + 'ecosystem.config.js' + ), + '--only', + processName, + ] + ) + }, + }, + }) + ) + tasks.push({ + title: `Saving ${processName} state for future startup...`, + task: async () => { + await sshExec(ssh, serverConfig.path, serverConfig.monitorCommand, [ + 'save', + ]) + }, + skip: () => !yargs.restart, + }) + } else { + tasks.push( + commandWithLifecycleEvents({ + name: 'restart', + config, + skip: !yargs.restart, + command: { + title: `Restarting ${processName} process...`, + task: async () => { + await restartProcessCommand( + processName, + ssh, + serverConfig, + serverConfig.path + ) + }, + }, + }) + ) + } + } + } + + tasks.push( + commandWithLifecycleEvents({ + name: 'cleanup', + config: { ...config, cmdPath: serverConfig.path }, + skip: !yargs.cleanup, + command: { + title: `Cleaning up old deploys...`, + task: async () => { + // add 2 to skip `current` and start on the keepReleases + 1th release + const fileStartIndex = serverConfig.keepReleases + 2 + + await sshExec( + ssh, + serverConfig.path, + `ls -t | tail -n +${fileStartIndex} | xargs rm -rf` + ) + }, + }, + }) + ) + + return tasks.flat().filter((e) => e) +} + +// merges additional lifecycle events into an existing object +const mergeLifecycleEvents = (lifecycle, other) => { + let lifecycleCopy = JSON.parse(JSON.stringify(lifecycle)) + + for (const hook of LIFECYCLE_HOOKS) { + for (const key in other[hook]) { + lifecycleCopy[hook][key] = (lifecycleCopy[hook][key] || []).concat( + other[hook][key] + ) + } + } + + return lifecycleCopy +} + +export const parseConfig = (yargs, rawConfigToml) => { + const configToml = envInterpolation(rawConfigToml) + const config = toml.parse(configToml) + let envConfig + const emptyLifecycle = {} + + verifyConfig(config, yargs) + + // start with an empty set of hooks, { before: {}, after: {} } + for (const hook of LIFECYCLE_HOOKS) { + emptyLifecycle[hook] = {} + } + + // global lifecycle config + let envLifecycle = mergeLifecycleEvents(emptyLifecycle, config) + + // get config for given environment + envConfig = config[yargs.environment] + envLifecycle = mergeLifecycleEvents(envLifecycle, envConfig) + + return { envConfig, envLifecycle } +} + +export const commands = (yargs, ssh) => { + const deployConfig = fs + .readFileSync(pathJoin(getPaths().base, CONFIG_FILENAME)) + .toString() + + let { envConfig, envLifecycle } = parseConfig(yargs, deployConfig) + let servers = [] + let tasks = [] + + // loop through each server in deploy.toml + for (const config of envConfig.servers) { + // merge in defaults + const serverConfig = serverConfigWithDefaults(config, yargs) + + verifyServerConfig(serverConfig) + + // server-specific lifecycle + const serverLifecycle = mergeLifecycleEvents(envLifecycle, serverConfig) + + tasks.push({ + title: 'Connecting...', + task: () => + ssh.connect({ + host: serverConfig.host, + port: serverConfig.port, + username: serverConfig.username, + password: serverConfig.password, + privateKey: serverConfig.privateKey, + privateKeyPath: serverConfig.privateKeyPath, + passphrase: serverConfig.passphrase, + agent: serverConfig.agentForward && process.env.SSH_AUTH_SOCK, + agentForward: serverConfig.agentForward, + }), + }) + + if (yargs.maintenance) { + tasks = tasks.concat( + maintenanceTasks(yargs.maintenance, ssh, serverConfig) + ) + } else if (yargs.rollback) { + tasks = tasks.concat(rollbackTasks(yargs.rollback, ssh, serverConfig)) + } else { + tasks = tasks.concat( + deployTasks(yargs, ssh, serverConfig, serverLifecycle) + ) + } + + tasks.push({ + title: 'Disconnecting...', + task: () => ssh.dispose(), + }) + + // Sets each server as a "parent" task so that the actual deploy tasks + // run as children. Each server deploy can run concurrently + servers.push({ + title: serverConfig.host, + task: () => { + return new Listr(tasks) + }, + }) + } + + return servers +} + +export async function handler(yargs) { + recordTelemetryAttributes({ + command: 'deploy baremetal', + firstRun: yargs.firstRun, + update: yargs.update, + install: yargs.install, + migrate: yargs.migrate, + build: yargs.build, + restart: yargs.restart, + cleanup: yargs.cleanup, + maintenance: yargs.maintenance, + rollback: yargs.rollback, + verbose: yargs.verbose, + }) + + const { NodeSSH } = require('node-ssh') + const ssh = new NodeSSH() + + try { + const tasks = new Listr(commands(yargs, ssh), { + concurrent: true, + exitOnError: true, + renderer: yargs.verbose && 'verbose', + }) + await tasks.run() + } catch (e) { + console.error(c.error('\nDeploy failed:')) + console.error( + boxen(e.stderr || e.message, { + padding: { top: 0, bottom: 0, right: 1, left: 1 }, + margin: 0, + borderColor: 'red', + }) + ) + + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/deploy/edgio.js b/packages/cli/src/commands/deploy/edgio.js deleted file mode 100644 index 9cfc80f793fc..000000000000 --- a/packages/cli/src/commands/deploy/edgio.js +++ /dev/null @@ -1,146 +0,0 @@ -import path from 'path' - -import execa from 'execa' -import fs from 'fs-extra' -import { omit } from 'lodash' -import terminalLink from 'terminal-link' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { getPaths } from '@redwoodjs/project-config' - -import c from '../../lib/colors' - -import { deployBuilder, deployHandler } from './helpers/helpers' - -export const command = 'edgio [...commands]' -export const description = 'Build command for Edgio deploy' - -export const builder = async (yargs) => { - const { builder: edgioBuilder } = require('@edgio/cli/commands/deploy') - deployBuilder(yargs) - - edgioBuilder['skip-init'] = { - type: 'boolean', - description: [ - 'Edgio will attempt to initialize your project before deployment.', - 'If your project has already been initialized and you wish to skip', - 'this step, set this to `true`', - ].join(' '), - default: false, - } - - yargs - // allow Edgio CLI options to pass through - .options(edgioBuilder) - .group( - Object.keys(omit(edgioBuilder, ['skip-init'])), - 'Edgio deploy options:' - ) -} - -const execaOptions = { - cwd: path.join(getPaths().base), - shell: true, - stdio: 'inherit', - cleanup: true, -} - -export const handler = async (args) => { - recordTelemetryAttributes({ - command: 'deploy edgio', - skipInit: args.skipInit, - build: args.build, - prisma: args.prisma, - dataMigrate: args.dataMigrate, - }) - - const { builder: edgioBuilder } = require('@edgio/cli/commands/deploy') - const cwd = path.join(getPaths().base) - - try { - // check that Edgio is setup in the project - await execa('yarn', ['edgio', '--version'], execaOptions) - } catch (e) { - logAndExit(ERR_MESSAGE_MISSING_CLI) - } - - // check that the project has been already been initialized. - // if not, we will run init automatically unless specified by arg - const configExists = await fs.pathExists(path.join(cwd, 'edgio.config.js')) - - if (!configExists) { - if (args.skipInit) { - logAndExit(ERR_MESSAGE_NOT_INITIALIZED) - } - - await execa('yarn', ['edgio', 'init'], execaOptions) - } - - await deployHandler(args) - - // construct args for deploy command - const deployArgs = Object.keys(edgioBuilder).reduce((acc, key) => { - if (args[key]) { - acc.push(`--${key}=${args[key]}`) - } - return acc - }, []) - - // Even if rw builds the project, we still need to run the build for Edgio - // to bundle the router so we just skip the framework build. - // - // --skip-framework (edgio build): - // skips the framework build, but bundles the router and - // assets for deployment - // - // --skip-build (edgio deploy): - // skips the whole build process during deploy; user may - // opt out of this if they already did a build and just - // want to deploy - - // User must explicitly pass `--skip-build` during deploy in order to - // skip bundling the router. - if (!args.skipBuild) { - deployArgs.push('--skip-build') - await execa('yarn', ['edgio', 'build', '--skip-framework'], execaOptions) - } - - await execa('yarn', ['edgio', 'deploy', ...deployArgs], execaOptions) -} - -export const ERR_MESSAGE_MISSING_CLI = buildErrorMessage( - 'Edgio not found!', - [ - 'It looks like Edgio is not configured for your project.', - 'Run the following to add Edgio to your project:', - ` ${c.info('yarn add -D @edgio/cli')}`, - ].join('\n') -) - -export const ERR_MESSAGE_NOT_INITIALIZED = buildErrorMessage( - 'Edgio not initialized!', - [ - 'It looks like Edgio is not configured for your project.', - 'Run the following to initialize Edgio on your project:', - ` ${c.info('yarn edgio init')}`, - ].join('\n') -) - -export function buildErrorMessage(title, message) { - return [ - c.bold(c.error(title)), - '', - message, - '', - `Also see the ${terminalLink( - 'RedwoodJS on Edgio Guide', - 'https://docs.edg.io/guides/redwoodjs' - )} for additional resources.`, - '', - ].join('\n') -} - -function logAndExit(message) { - console.log(message) - process.exit(1) -} diff --git a/packages/cli/src/commands/deploy/flightcontrol.js b/packages/cli/src/commands/deploy/flightcontrol.js index 1274773bc17a..f05f615412fe 100644 --- a/packages/cli/src/commands/deploy/flightcontrol.js +++ b/packages/cli/src/commands/deploy/flightcontrol.js @@ -1,19 +1,11 @@ -import path from 'path' - -import execa from 'execa' import terminalLink from 'terminal-link' -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { getConfig } from '@redwoodjs/project-config' - -import { getPaths } from '../../lib' -import { apiServerHandler } from '../serveApiHandler' - export const command = 'flightcontrol ' export const alias = 'fc' export const description = 'Build, Migrate, and Serve commands for Flightcontrol deploy' -export const builder = (yargs) => { + +export function builder(yargs) { yargs .positional('side', { choices: ['api', 'web'], @@ -44,56 +36,7 @@ export const builder = (yargs) => { ) } -export const handler = async ({ side, serve, prisma, dm: dataMigrate }) => { - recordTelemetryAttributes({ - command: 'deploy flightcontrol', - side, - prisma, - dataMigrate, - serve, - }) - const rwjsPaths = getPaths() - - const execaConfig = { - shell: true, - stdio: 'inherit', - cwd: rwjsPaths.base, - extendEnv: true, - cleanup: true, - } - - async function runApiCommands() { - if (serve) { - console.log('\nStarting api...') - await apiServerHandler({ - port: getConfig().api?.port || 8911, - apiRootPath: '/', - }) - } else { - console.log('\nBuilding api...') - execa.sync('yarn rw build api', execaConfig) - - prisma && - execa.sync( - path.join(rwjsPaths.base, 'node_modules/.bin/prisma'), - ['migrate', 'deploy', '--schema', `"${rwjsPaths.api.dbSchema}"`], - execaConfig - ) - dataMigrate && execa.sync('yarn rw dataMigrate up', execaConfig) - } - } - - async function runWebCommands() { - execa.sync('yarn rw build web', execaConfig) - } - - if (side === 'api') { - runApiCommands() - } else if (side === 'web') { - console.log('\nBuilding web...') - runWebCommands() - } else { - console.log('Error with arguments provided') - // you broke something, which should be caught by Yargs - } +export async function handler(options) { + const { handler } = await import('./flightcontrolHandler.js') + return handler(options) } diff --git a/packages/cli/src/commands/deploy/flightcontrolHandler.js b/packages/cli/src/commands/deploy/flightcontrolHandler.js new file mode 100644 index 000000000000..890895e17f0d --- /dev/null +++ b/packages/cli/src/commands/deploy/flightcontrolHandler.js @@ -0,0 +1,64 @@ +import path from 'path' + +import execa from 'execa' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' +import { getConfig } from '@redwoodjs/project-config' + +import { getPaths } from '../../lib' +import { apiServerHandler } from '../serveApiHandler' + +export async function handler({ side, serve, prisma, dm: dataMigrate }) { + recordTelemetryAttributes({ + command: 'deploy flightcontrol', + side, + prisma, + dataMigrate, + serve, + }) + + const rwjsPaths = getPaths() + + const execaConfig = { + shell: true, + stdio: 'inherit', + cwd: rwjsPaths.base, + extendEnv: true, + cleanup: true, + } + + async function runApiCommands() { + if (serve) { + console.log('\nStarting api...') + await apiServerHandler({ + port: getConfig().api?.port || 8911, + apiRootPath: '/', + }) + } else { + console.log('\nBuilding api...') + execa.sync('yarn rw build api', execaConfig) + + prisma && + execa.sync( + path.join(rwjsPaths.base, 'node_modules/.bin/prisma'), + ['migrate', 'deploy', '--schema', `"${rwjsPaths.api.dbSchema}"`], + execaConfig + ) + dataMigrate && execa.sync('yarn rw dataMigrate up', execaConfig) + } + } + + async function runWebCommands() { + execa.sync('yarn rw build web', execaConfig) + } + + if (side === 'api') { + runApiCommands() + } else if (side === 'web') { + console.log('\nBuilding web...') + runWebCommands() + } else { + console.log('Error with arguments provided') + // you broke something, which should be caught by Yargs + } +} diff --git a/packages/cli/src/commands/deploy/serverless.js b/packages/cli/src/commands/deploy/serverless.js deleted file mode 100644 index c3fc24ab0c77..000000000000 --- a/packages/cli/src/commands/deploy/serverless.js +++ /dev/null @@ -1,292 +0,0 @@ -import path from 'path' - -import boxen from 'boxen' -import chalk from 'chalk' -import { config } from 'dotenv-defaults' -import execa from 'execa' -import fs from 'fs-extra' -import { Listr } from 'listr2' -import prompts from 'prompts' -import terminalLink from 'terminal-link' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' - -import { getPaths } from '../../lib' -import c from '../../lib/colors' - -export const command = 'serverless' -export const aliases = ['aws serverless', 'sls'] -export const description = 'Deploy to AWS via the serverless framework' - -export const builder = (yargs) => { - yargs.option('stage', { - describe: - 'serverless stage pass through param: https://www.serverless.com/blog/stages-and-environments', - default: 'production', - type: 'string', - }) - - yargs.option('sides', { - describe: 'which Side(s) to deploy', - choices: ['api', 'web'], - default: ['api', 'web'], - alias: 'side', - type: 'array', - }) - - yargs.option('verbose', { - describe: 'verbosity of logs', - default: true, - type: 'boolean', - }) - - yargs.option('pack-only', { - describe: 'Only build and pack, and dont push code up using serverless', - default: false, - type: 'boolean', - }) - - yargs.option('first-run', { - describe: - 'Set this flag the first time you deploy, to configure your API URL on the webside', - default: false, - type: 'boolean', - }) - - yargs.epilogue( - `Also see the ${terminalLink( - 'Redwood CLI Reference', - 'https://redwoodjs.com/docs/cli-commands#deploy' - )}\n` - ) -} - -export const preRequisites = () => [ - { - title: 'Checking if Serverless framework is installed...', - command: ['yarn serverless', ['--version']], - errorMessage: [ - 'Looks like Serverless is not installed.', - 'Please run yarn add -W --dev serverless.', - ], - }, -] - -export const buildCommands = ({ sides }) => { - return [ - { - title: `Building ${sides.join(' & ')}...`, - command: ['yarn', ['rw', 'build', ...sides]], - }, - { - title: 'Packing Functions...', - enabled: () => sides.includes('api'), - task: async () => { - // Dynamically import this function - // because its dependencies are only installed when `rw setup deploy serverless` is run - const { nftPack } = await import('./packing/nft.js') - - await nftPack() - }, - }, - ] -} - -export const deployCommands = ({ stage, sides, firstRun, packOnly }) => { - const slsStage = stage ? ['--stage', stage] : [] - - return sides.map((side) => { - return { - title: `Deploying ${side}....`, - task: async () => { - await execa('yarn', ['serverless', 'deploy', ...slsStage], { - cwd: path.join(getPaths().base, side), - shell: true, - stdio: 'inherit', - cleanup: true, - }) - }, - skip: () => { - if (firstRun && side === 'web') { - return 'Skipping web deploy, until environment configured' - } - - if (packOnly) { - return 'Finishing early due to --pack-only flag. Your Redwood project is packaged and ready to deploy' - } - }, - } - }) -} - -const loadDotEnvForStage = (dotEnvPath) => { - // Make sure we use the correct .env based on the stage - config({ - path: dotEnvPath, - defaults: path.join(getPaths().base, '.env.defaults'), - encoding: 'utf8', - }) -} - -export const handler = async (yargs) => { - recordTelemetryAttributes({ - command: 'deploy serverless', - sides: JSON.stringify(yargs.sides), - verbose: yargs.verbose, - packOnly: yargs.packOnly, - firstRun: yargs.firstRun, - }) - - const rwjsPaths = getPaths() - const dotEnvPath = path.join(rwjsPaths.base, `.env.${yargs.stage}`) - - // Make sure .env.staging, .env.production, etc are loaded based on the --stage flag - loadDotEnvForStage(dotEnvPath) - - const tasks = new Listr( - [ - ...preRequisites(yargs).map(mapCommandsToListr), - ...buildCommands(yargs).map(mapCommandsToListr), - ...deployCommands(yargs).map(mapCommandsToListr), - ], - { - exitOnError: true, - renderer: yargs.verbose && 'verbose', - } - ) - try { - await tasks.run() - - if (yargs.firstRun) { - const SETUP_MARKER = chalk.bgBlue(chalk.black('First Setup ')) - console.log() - - console.log(SETUP_MARKER, c.green('Starting first setup wizard...')) - - const { stdout: slsInfo } = await execa( - `yarn serverless info --verbose --stage=${yargs.stage}`, - { - shell: true, - cwd: getPaths().api.base, - } - ) - - const deployedApiUrl = slsInfo.match(/HttpApiUrl: (https:\/\/.*)/)[1] - - console.log() - console.log(SETUP_MARKER, `Found ${c.green(deployedApiUrl)}`) - console.log() - - const { addDotEnv } = await prompts({ - type: 'confirm', - name: 'addDotEnv', - message: `Add API_URL to your .env.${yargs.stage}? This will be used if you deploy the web side from your machine`, - }) - - if (addDotEnv) { - fs.writeFileSync(dotEnvPath, `API_URL=${deployedApiUrl}`) - - // Reload dotenv, after adding the new file - loadDotEnvForStage(dotEnvPath) - } - - if (yargs.sides.includes('web')) { - console.log() - console.log(SETUP_MARKER, 'Deploying web side with updated API_URL') - - console.log( - SETUP_MARKER, - 'First deploys can take a good few minutes...' - ) - console.log() - - const webDeployTasks = new Listr( - [ - // Rebuild web with the new API_URL - ...buildCommands({ ...yargs, sides: ['web'], firstRun: false }).map( - mapCommandsToListr - ), - ...deployCommands({ - ...yargs, - sides: ['web'], - firstRun: false, - }).map(mapCommandsToListr), - ], - { - exitOnError: true, - renderer: yargs.verbose && 'verbose', - } - ) - - // Deploy the web side now that the API_URL has been configured - await webDeployTasks.run() - - const { stdout: slsInfo } = await execa( - `yarn serverless info --verbose --stage=${yargs.stage}`, - { - shell: true, - cwd: getPaths().web.base, - } - ) - - const deployedWebUrl = slsInfo.match(/url: (https:\/\/.*)/)[1] - - const message = [ - c.bold('Successful first deploy!'), - '', - `View your deployed site at: ${c.green(deployedWebUrl)}`, - '', - 'You can use serverless.com CI/CD by connecting/creating an app', - 'To do this run `yarn serverless` on each of the sides, and connect your account', - '', - 'Find more information in our docs:', - c.underline('https://redwoodjs.com/docs/deploy#serverless'), - ] - - console.log( - boxen(message.join('\n'), { - padding: { top: 0, bottom: 0, right: 1, left: 1 }, - margin: 1, - borderColor: 'gray', - }) - ) - } - } - } catch (e) { - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) - } -} - -const mapCommandsToListr = ({ - title, - command, - task, - cwd, - errorMessage, - skip, - enabled, -}) => { - return { - title, - task: task - ? task - : async () => { - try { - const executingCommand = execa(...command, { - cwd: cwd || getPaths().base, - shell: true, - }) - executingCommand.stdout.pipe(process.stdout) - await executingCommand - } catch (error) { - if (errorMessage) { - error.message = error.message + '\n' + errorMessage.join(' ') - } - throw error - } - }, - skip, - enabled, - } -} diff --git a/packages/cli/src/commands/experimental.js b/packages/cli/src/commands/experimental.js index 32e235b31282..820ff586ba7a 100644 --- a/packages/cli/src/commands/experimental.js +++ b/packages/cli/src/commands/experimental.js @@ -2,24 +2,29 @@ import terminalLink from 'terminal-link' import detectRwVersion from '../middleware/detectProjectRwVersion' +import * as setupDockerCommand from './experimental/setupDocker' +import * as setupInngestCommand from './experimental/setupInngest' +import * as setupOpentelementryCommand from './experimental/setupOpentelemetry' +import * as setupRscCommand from './experimental/setupRsc' +import * as setupSentryCommand from './experimental/setupSentry' +import * as setupServerFileCommand from './experimental/setupServerFile' +import * as setupStreamingSsrCommand from './experimental/setupStreamingSsr' +import * as studioCommand from './experimental/studio' + export const command = 'experimental ' export const aliases = ['exp'] export const description = 'Run or setup experimental features' -export const builder = (yargs) => +export function builder(yargs) { yargs - .commandDir('./experimental', { - recurse: true, - // @NOTE This regex will ignore all commands nested more than two - // levels deep. - // e.g. /setup/hi.js & setup/hi/hi.js are picked up, but - // setup/hi/hello/bazinga.js will be ignored - // The [/\\] bit is for supporting both windows and unix style paths - // Also take care to not trip up on paths that have "setup" earlier - // in the path by eagerly matching in the start of the regexp - exclude: /.*[/\\]experimental[/\\].*[/\\].*[/\\]/, - }) - .demandCommand() + .command(setupDockerCommand) + .command(setupInngestCommand) + .command(setupOpentelementryCommand) + .command(setupRscCommand) + .command(setupSentryCommand) + .command(setupServerFileCommand) + .command(setupStreamingSsrCommand) + .command(studioCommand) .middleware(detectRwVersion) .epilogue( `Also see the ${terminalLink( @@ -27,3 +32,4 @@ export const builder = (yargs) => 'https://redwoodjs.com/docs/cli-commands#experimental' )}` ) +} diff --git a/packages/cli/src/commands/record.js b/packages/cli/src/commands/record.js index 933c1b9dbef0..f406c11959f2 100644 --- a/packages/cli/src/commands/record.js +++ b/packages/cli/src/commands/record.js @@ -1,15 +1,17 @@ -export const command = 'record ' -export const description = - 'Setup RedwoodRecord for your project. Caches a JSON version of your data model and adds api/src/models/index.js with some config.' import terminalLink from 'terminal-link' -export const builder = (yargs) => +import * as initCommand from './record/init' + +export const command = 'record ' +export const description = 'Set up RedwoodRecord for your project' + +export function builder(yargs) { yargs - .commandDir('./record', { recurse: false }) - .demandCommand() + .command(initCommand) .epilogue( `Also see the ${terminalLink( 'RedwoodRecord Docs', 'https://redwoodjs.com/docs/redwoodrecord' )}\n` ) +} diff --git a/packages/cli/src/commands/record/init.js b/packages/cli/src/commands/record/init.js index f5d28fc08095..c5a06fef6693 100644 --- a/packages/cli/src/commands/record/init.js +++ b/packages/cli/src/commands/record/init.js @@ -1,9 +1,32 @@ import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { parseDatamodel } from '@redwoodjs/record' -export const handler = async () => { +import { exitWithError } from '../../lib/exit' + +export const command = 'init' +export const description = + 'Caches a JSON version of your data model and adds `api/src/models/index.js` with some config' + +export async function handler() { recordTelemetryAttributes({ command: 'record', }) - await parseDatamodel() + + try { + const { parseDatamodel } = await import('@redwoodjs/record') + await parseDatamodel() + } catch (e) { + if (e.code !== 'ERR_MODULE_NOT_FOUND') { + throw e + } + + exitWithError(undefined, { + message: [ + "Error: Can't find module `@redwoojds/record`. Have you added `@redwoodjs/record` to the api side?", + '', + ' yarn workspace api add @redwoodjs/record', + '', + ].join('\n'), + includeEpilogue: false, + }) + } } diff --git a/packages/cli/src/commands/setup.js b/packages/cli/src/commands/setup.js index 111a98d2682c..d7bea14b9e1c 100644 --- a/packages/cli/src/commands/setup.js +++ b/packages/cli/src/commands/setup.js @@ -2,23 +2,40 @@ import terminalLink from 'terminal-link' import detectRwVersion from '../middleware/detectProjectRwVersion' +import * as authCommand from './setup/auth/auth' +import * as cacheCommand from './setup/cache/cache' +import * as customWebIndexCommand from './setup/custom-web-index/custom-web-index' +import * as deployCommand from './setup/deploy/deploy' +import * as generatorCommand from './setup/generator/generator' +import * as graphiqlCommand from './setup/graphiql/graphiql' +import * as i18nCommand from './setup/i18n/i18n' +import * as mailerCommand from './setup/mailer/mailer' +import * as packageCommand from './setup/package/package' +import * as realtimeCommand from './setup/realtime/realtime' +import * as tsconfigCommand from './setup/tsconfig/tsconfig' +import * as uiCommand from './setup/ui/ui' +import * as viteCommand from './setup/vite/vite' +import * as webpackCommand from './setup/webpack/webpack' + export const command = 'setup ' export const description = 'Initialize project config and install packages' -export const builder = (yargs) => +export function builder(yargs) { yargs - .commandDir('./setup', { - recurse: true, - // @NOTE This regex will ignore all commands nested more than two - // levels deep. - // e.g. /setup/hi.js & setup/hi/hi.js are picked up, but - // setup/hi/hello/bazinga.js will be ignored - // The [/\\] bit is for supporting both windows and unix style paths - // Also take care to not trip up on paths that have "setup" earlier - // in the path by eagerly matching in the start of the regexp - exclude: /.*[/\\]setup[/\\].*[/\\].*[/\\]/, - }) - .demandCommand() + .command(authCommand) + .command(cacheCommand) + .command(customWebIndexCommand) + .command(deployCommand) + .command(generatorCommand) + .command(graphiqlCommand) + .command(i18nCommand) + .command(mailerCommand) + .command(packageCommand) + .command(realtimeCommand) + .command(tsconfigCommand) + .command(uiCommand) + .command(viteCommand) + .command(webpackCommand) .middleware(detectRwVersion) .epilogue( `Also see the ${terminalLink( @@ -26,3 +43,4 @@ export const builder = (yargs) => 'https://redwoodjs.com/docs/cli-commands#setup' )}` ) +} diff --git a/packages/cli/src/commands/setup/deploy/deploy.js b/packages/cli/src/commands/setup/deploy/deploy.js index 705c8ae93edb..df795484b2f3 100644 --- a/packages/cli/src/commands/setup/deploy/deploy.js +++ b/packages/cli/src/commands/setup/deploy/deploy.js @@ -1,11 +1,23 @@ +import terminalLink from 'terminal-link' + +import * as baremetalCommand from './providers/baremetal' +import * as coherenceCommand from './providers/coherence' +import * as flightcontrolCommand from './providers/flightcontrol' +import * as netlifyCommand from './providers/netlify' +import * as renderCommand from './providers/render' +import * as vercelCommand from './providers/vercel' + export const command = 'deploy ' export const description = 'Setup deployment to various targets' -import terminalLink from 'terminal-link' -export const builder = (yargs) => +export function builder(yargs) { yargs - .commandDir('./providers', { recurse: true }) - .demandCommand() + .command(baremetalCommand) + .command(coherenceCommand) + .command(flightcontrolCommand) + .command(netlifyCommand) + .command(renderCommand) + .command(vercelCommand) .option('force', { alias: 'f', default: false, @@ -18,3 +30,4 @@ export const builder = (yargs) => 'https://redwoodjs.com/docs/cli-commands#setup-deploy-config' )}` ) +} diff --git a/packages/cli/src/commands/setup/deploy/providers/baremetal.js b/packages/cli/src/commands/setup/deploy/providers/baremetal.js index 7d3ce5a0f6d4..d4b669966658 100644 --- a/packages/cli/src/commands/setup/deploy/providers/baremetal.js +++ b/packages/cli/src/commands/setup/deploy/providers/baremetal.js @@ -1,66 +1,7 @@ -// import terminalLink from 'terminal-link' -import path from 'path' - -import { Listr } from 'listr2' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { errorTelemetry } from '@redwoodjs/telemetry' - -import { addPackagesTask, getPaths, printSetupNotes } from '../../../../lib' -import c from '../../../../lib/colors' -import { addFilesTask } from '../helpers' -import { DEPLOY, ECOSYSTEM, MAINTENANCE } from '../templates/baremetal' - export const command = 'baremetal' export const description = 'Setup Baremetal deploy' -export const configFilename = 'deploy.toml' - -const files = [ - { - path: path.join(getPaths().base, configFilename), - content: DEPLOY, - }, - { - path: path.join(getPaths().base, 'ecosystem.config.js'), - content: ECOSYSTEM, - }, - { - path: path.join(getPaths().web.src, 'maintenance.html'), - content: MAINTENANCE, - }, -] - -const notes = [ - 'You are almost ready to go BAREMETAL!', - '', - 'See https://redwoodjs.com/docs/deploy/baremetal for the remaining', - 'config and setup required before you can perform your first deploy.', -] -export const handler = async ({ force }) => { - recordTelemetryAttributes({ - command: 'setup deploy baremetal', - force, - }) - const tasks = new Listr( - [ - addPackagesTask({ - packages: ['node-ssh'], - devDependency: true, - }), - addFilesTask({ - files, - force, - }), - printSetupNotes(notes), - ], - { rendererOptions: { collapseSubtasks: false } } - ) - try { - await tasks.run() - } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) - } +export async function handler(options) { + const { handler } = await import('./baremetalHandler.js') + return handler(options) } diff --git a/packages/cli/src/commands/setup/deploy/providers/baremetalHandler.js b/packages/cli/src/commands/setup/deploy/providers/baremetalHandler.js new file mode 100644 index 000000000000..7de6344b8ae9 --- /dev/null +++ b/packages/cli/src/commands/setup/deploy/providers/baremetalHandler.js @@ -0,0 +1,63 @@ +import path from 'path' + +import { Listr } from 'listr2' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' +import { errorTelemetry } from '@redwoodjs/telemetry' + +import { addPackagesTask, getPaths, printSetupNotes } from '../../../../lib' +import c from '../../../../lib/colors' +import { addFilesTask } from '../helpers' +import { DEPLOY, ECOSYSTEM, MAINTENANCE } from '../templates/baremetal' + +export const configFilename = 'deploy.toml' + +const files = [ + { + path: path.join(getPaths().base, configFilename), + content: DEPLOY, + }, + { + path: path.join(getPaths().base, 'ecosystem.config.js'), + content: ECOSYSTEM, + }, + { + path: path.join(getPaths().web.src, 'maintenance.html'), + content: MAINTENANCE, + }, +] + +const notes = [ + 'You are almost ready to go BAREMETAL!', + '', + 'See https://redwoodjs.com/docs/deploy/baremetal for the remaining', + 'config and setup required before you can perform your first deploy.', +] + +export async function handler({ force }) { + recordTelemetryAttributes({ + command: 'setup deploy baremetal', + force, + }) + const tasks = new Listr( + [ + addPackagesTask({ + packages: ['node-ssh'], + devDependency: true, + }), + addFilesTask({ + files, + force, + }), + printSetupNotes(notes), + ], + { rendererOptions: { collapseSubtasks: false } } + ) + try { + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/deploy/providers/edgio.js b/packages/cli/src/commands/setup/deploy/providers/edgio.js deleted file mode 100644 index c7e4038db751..000000000000 --- a/packages/cli/src/commands/setup/deploy/providers/edgio.js +++ /dev/null @@ -1,75 +0,0 @@ -import fs from 'fs-extra' -import { Listr } from 'listr2' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { errorTelemetry } from '@redwoodjs/telemetry' - -import { addPackagesTask, getPaths, printSetupNotes } from '../../../../lib' -import c from '../../../../lib/colors' -import { - ERR_MESSAGE_MISSING_CLI, - ERR_MESSAGE_NOT_INITIALIZED, -} from '../../../deploy/edgio' -import { preRequisiteCheckTask } from '../helpers' - -export const command = 'edgio' -export const description = 'Setup Edgio deploy' - -const notes = [ - 'You are almost ready to deploy to Edgio!', - '', - 'See https://redwoodjs.com/docs/deploy#edgio-deploy for the remaining', - 'config and setup required before you can perform your first deploy.', -] - -const prismaBinaryTargetAdditions = () => { - const content = fs.readFileSync(getPaths().api.dbSchema).toString() - - if (!content.includes('rhel-openssl-1.0.x')) { - const result = content.replace( - /binaryTargets =.*\n/, - `binaryTargets = ["native", "rhel-openssl-1.0.x"]\n` - ) - - fs.writeFileSync(getPaths().api.dbSchema, result) - } -} - -export const handler = async () => { - recordTelemetryAttributes({ - command: 'setup deploy edgio', - }) - const tasks = new Listr( - [ - addPackagesTask({ - packages: ['@edgio/cli'], - devDependency: true, - }), - preRequisiteCheckTask([ - { - title: 'Checking if Edgio is installed...', - command: ['yarn', ['edgio', '--version']], - errorMessage: ERR_MESSAGE_MISSING_CLI, - }, - { - title: 'Initializing with Edgio', - command: ['yarn', ['edgio', 'init']], - errorMessage: ERR_MESSAGE_NOT_INITIALIZED, - }, - ]), - { - title: 'Adding necessary Prisma binaries...', - task: () => prismaBinaryTargetAdditions(), - }, - printSetupNotes(notes), - ], - { rendererOptions: { collapseSubtasks: false } } - ) - try { - await tasks.run() - } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) - } -} diff --git a/packages/cli/src/commands/setup/deploy/providers/flightcontrol.js b/packages/cli/src/commands/setup/deploy/providers/flightcontrol.js index 17bd304dc766..b06609223284 100644 --- a/packages/cli/src/commands/setup/deploy/providers/flightcontrol.js +++ b/packages/cli/src/commands/setup/deploy/providers/flightcontrol.js @@ -1,293 +1,8 @@ -// import terminalLink from 'terminal-link' -import { EOL } from 'os' -import path from 'path' - -import { getSchema, getConfig } from '@prisma/internals' -import fs from 'fs-extra' -import { Listr } from 'listr2' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { errorTelemetry } from '@redwoodjs/telemetry' - -import { getPaths, writeFilesTask, printSetupNotes } from '../../../../lib' -import c from '../../../../lib/colors' -import { updateApiURLTask } from '../helpers' -import { - flightcontrolConfig, - databaseEnvVariables, - postgresDatabaseService, - mysqlDatabaseService, -} from '../templates/flightcontrol' - export const command = 'flightcontrol' export const alias = 'fc' export const description = 'Setup Flightcontrol deploy' -export const getFlightcontrolJson = async (database) => { - if (database === 'none') { - return { - path: path.join(getPaths().base, 'flightcontrol.json'), - content: flightcontrolConfig, - } - } - if (!fs.existsSync(path.join(getPaths().base, 'api/db/schema.prisma'))) { - throw new Error("Could not find prisma schema at 'api/db/schema.prisma'") - } - - const schema = await getSchema( - path.join(getPaths().base, 'api/db/schema.prisma') - ) - const config = await getConfig({ datamodel: schema }) - const detectedDatabase = config.datasources[0].activeProvider - - if (detectedDatabase === database) { - let dbService - switch (database) { - case 'postgresql': - dbService = postgresDatabaseService - break - case 'mysql': - dbService = mysqlDatabaseService - break - default: - throw new Error(` - Unexpected datasource provider found: ${database}`) - } - return { - path: path.join(getPaths().base, 'flightcontrol.json'), - content: { - ...flightcontrolConfig, - environments: [ - { - ...flightcontrolConfig.environments[0], - services: [ - ...flightcontrolConfig.environments[0].services.map((service) => { - if (service.id === 'redwood-api') { - return { - ...service, - envVariables: { - ...service.envVariables, - ...databaseEnvVariables, - }, - } - } - return service - }), - dbService, - ], - }, - ], - }, - } - } else { - throw new Error(` - Prisma datasource provider is detected to be ${detectedDatabase}. - - Update your schema.prisma provider to be postgresql or mysql, then run - yarn rw prisma migrate dev - yarn rw setup deploy flightcontrol - `) - } -} - -const updateGraphQLFunction = () => { - return { - title: 'Adding CORS config to createGraphQLHandler...', - task: (_ctx) => { - const graphqlTsPath = path.join( - getPaths().base, - 'api/src/functions/graphql.ts' - ) - const graphqlJsPath = path.join( - getPaths().base, - 'api/src/functions/graphql.js' - ) - - let graphqlFunctionsPath - if (fs.existsSync(graphqlTsPath)) { - graphqlFunctionsPath = graphqlTsPath - } else if (fs.existsSync(graphqlJsPath)) { - graphqlFunctionsPath = graphqlJsPath - } else { - console.log(` - Couldn't find graphql handler in api/src/functions/graphql.js. - You'll have to add the following cors config manually: - - cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true} - `) - return - } - - const graphqlContent = fs - .readFileSync(graphqlFunctionsPath, 'utf8') - .split(EOL) - const graphqlHanderIndex = graphqlContent.findIndex((line) => - line.includes('createGraphQLHandler({') - ) - - if (graphqlHanderIndex === -1) { - console.log(` - Couldn't find graphql handler in api/src/functions/graphql.js. - You'll have to add the following cors config manually: - - cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true} - `) - return - } - - graphqlContent.splice( - graphqlHanderIndex + 1, - 0, - ' cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true },' - ) - - fs.writeFileSync(graphqlFunctionsPath, graphqlContent.join(EOL)) - }, - } -} - -const updateDbAuth = () => { - return { - title: 'Updating dbAuth cookie config (if used)...', - task: (_ctx) => { - const authTsPath = path.join(getPaths().base, 'api/src/functions/auth.ts') - const authJsPath = path.join(getPaths().base, 'api/src/functions/auth.js') - - let authFnPath - if (fs.existsSync(authTsPath)) { - authFnPath = authTsPath - } else if (fs.existsSync(authJsPath)) { - authFnPath = authJsPath - } else { - console.log(`Skipping, did not detect api/src/functions/auth.js`) - return - } - - const authContent = fs.readFileSync(authFnPath, 'utf8').split(EOL) - const sameSiteLineIndex = authContent.findIndex((line) => - line.match(/SameSite:.*,/) - ) - if (sameSiteLineIndex === -1) { - console.log(` - Couldn't find cookie SameSite config in api/src/functions/auth.js. - - You need to ensure SameSite is set to "None" - `) - return - } - authContent[ - sameSiteLineIndex - ] = ` SameSite: process.env.NODE_ENV === 'development' ? 'Strict' : 'None',` - - const dbHandlerIndex = authContent.findIndex((line) => - line.includes('new DbAuthHandler(') - ) - if (dbHandlerIndex === -1) { - console.log(` - Couldn't find DbAuthHandler in api/src/functions/auth.js. - You'll have to add the following cors config manually: - - cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true} - `) - return - } - authContent.splice( - dbHandlerIndex + 1, - 0, - ' cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true },' - ) - - fs.writeFileSync(authFnPath, authContent.join(EOL)) - }, - } -} - -const updateApp = () => { - return { - title: 'Updating App.jsx fetch config...', - task: (_ctx) => { - // TODO Can improve in the future with RW getPaths() - const appTsPath = path.join(getPaths().base, 'web/src/App.tsx') - const appJsPath = path.join(getPaths().base, 'web/src/App.jsx') - - let appPath - if (fs.existsSync(appTsPath)) { - appPath = appTsPath - } else if (fs.existsSync(appJsPath)) { - appPath = appJsPath - } else { - // TODO this should never happen. Throw instead? - console.log(`Skipping, did not detect web/src/App.jsx|tsx`) - return - } - - const appContent = fs.readFileSync(appPath, 'utf8').split(EOL) - const authLineIndex = appContent.findIndex((line) => - line.includes(' in web/src/App.js - If (and when) you use *dbAuth*, you'll have to add the following fetch config to : - - config={{ fetchConfig: { credentials: 'include' } }} - `) - // This is CORS config for cookies, which is currently only dbAuth Currently only dbAuth uses cookies and would require this config - } else if (appContent.toString().match(/dbAuth/)) { - appContent[ - authLineIndex - ] = ` -` - } - - const gqlLineIndex = appContent.findIndex((line) => - line.includes(' -` - } - - fs.writeFileSync(appPath, appContent.join(EOL)) - }, - } -} - -// We need to set the apiUrl evn var for local dev -const addToDotEnvDefaultTask = () => { - return { - title: 'Updating .env.defaults...', - skip: () => { - if (!fs.existsSync(path.resolve(getPaths().base, '.env.defaults'))) { - return ` - WARNING: could not update .env.defaults - - You'll have to add the following env var manually: - - REDWOOD_API_URL=/.redwood/functions - ` - } - }, - task: async (_ctx) => { - const env = path.resolve(getPaths().base, '.env.defaults') - const line = '\n\nREDWOOD_API_URL=/.redwood/functions\n' - - fs.appendFileSync(env, line) - }, - } -} - -export const builder = (yargs) => +export function builder(yargs) { yargs.option('database', { alias: 'd', choices: ['none', 'postgresql', 'mysql'], @@ -295,47 +10,9 @@ export const builder = (yargs) => default: 'postgresql', type: 'string', }) +} -// any notes to print out when the job is done -const notes = [ - 'You are ready to deploy to Flightcontrol!\n', - '👉 Create your project at https://app.flightcontrol.dev/signup?ref=redwood\n', - 'Check out the deployment docs at https://app.flightcontrol.dev/docs for detailed instructions\n', - "NOTE: If you are using yarn v1, remove the installCommand's from flightcontrol.json", -] - -export const handler = async ({ force, database }) => { - recordTelemetryAttributes({ - command: 'setup deploy flightcontrol', - force, - database, - }) - const tasks = new Listr( - [ - { - title: 'Adding flightcontrol.json', - task: async () => { - const fileData = await getFlightcontrolJson(database) - let files = {} - files[fileData.path] = JSON.stringify(fileData.content, null, 2) - return writeFilesTask(files, { overwriteExisting: force }) - }, - }, - updateGraphQLFunction(), - updateDbAuth(), - updateApp(), - updateApiURLTask('${REDWOOD_API_URL}'), - addToDotEnvDefaultTask(), - printSetupNotes(notes), - ], - { rendererOptions: { collapseSubtasks: false } } - ) - - try { - await tasks.run() - } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) - } +export async function handler(options) { + const { handler } = await import('./flightcontrolHandler.js') + return handler(options) } diff --git a/packages/cli/src/commands/setup/deploy/providers/flightcontrolHandler.js b/packages/cli/src/commands/setup/deploy/providers/flightcontrolHandler.js new file mode 100644 index 000000000000..df8bdf4f56e2 --- /dev/null +++ b/packages/cli/src/commands/setup/deploy/providers/flightcontrolHandler.js @@ -0,0 +1,327 @@ +import { EOL } from 'os' +import path from 'path' + +import { getSchema, getConfig } from '@prisma/internals' +import fs from 'fs-extra' +import { Listr } from 'listr2' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' +import { errorTelemetry } from '@redwoodjs/telemetry' + +import { getPaths, writeFilesTask, printSetupNotes } from '../../../../lib' +import c from '../../../../lib/colors' +import { updateApiURLTask } from '../helpers' +import { + flightcontrolConfig, + databaseEnvVariables, + postgresDatabaseService, + mysqlDatabaseService, +} from '../templates/flightcontrol' + +const getFlightcontrolJson = async (database) => { + if (database === 'none') { + return { + path: path.join(getPaths().base, 'flightcontrol.json'), + content: flightcontrolConfig, + } + } + if (!fs.existsSync(path.join(getPaths().base, 'api/db/schema.prisma'))) { + throw new Error("Could not find prisma schema at 'api/db/schema.prisma'") + } + + const schema = await getSchema( + path.join(getPaths().base, 'api/db/schema.prisma') + ) + const config = await getConfig({ datamodel: schema }) + const detectedDatabase = config.datasources[0].activeProvider + + if (detectedDatabase === database) { + let dbService + switch (database) { + case 'postgresql': + dbService = postgresDatabaseService + break + case 'mysql': + dbService = mysqlDatabaseService + break + default: + throw new Error(` + Unexpected datasource provider found: ${database}`) + } + return { + path: path.join(getPaths().base, 'flightcontrol.json'), + content: { + ...flightcontrolConfig, + environments: [ + { + ...flightcontrolConfig.environments[0], + services: [ + ...flightcontrolConfig.environments[0].services.map((service) => { + if (service.id === 'redwood-api') { + return { + ...service, + envVariables: { + ...service.envVariables, + ...databaseEnvVariables, + }, + } + } + return service + }), + dbService, + ], + }, + ], + }, + } + } else { + throw new Error(` + Prisma datasource provider is detected to be ${detectedDatabase}. + + Update your schema.prisma provider to be postgresql or mysql, then run + yarn rw prisma migrate dev + yarn rw setup deploy flightcontrol + `) + } +} + +const updateGraphQLFunction = () => { + return { + title: 'Adding CORS config to createGraphQLHandler...', + task: (_ctx) => { + const graphqlTsPath = path.join( + getPaths().base, + 'api/src/functions/graphql.ts' + ) + const graphqlJsPath = path.join( + getPaths().base, + 'api/src/functions/graphql.js' + ) + + let graphqlFunctionsPath + if (fs.existsSync(graphqlTsPath)) { + graphqlFunctionsPath = graphqlTsPath + } else if (fs.existsSync(graphqlJsPath)) { + graphqlFunctionsPath = graphqlJsPath + } else { + console.log(` + Couldn't find graphql handler in api/src/functions/graphql.js. + You'll have to add the following cors config manually: + + cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true} + `) + return + } + + const graphqlContent = fs + .readFileSync(graphqlFunctionsPath, 'utf8') + .split(EOL) + const graphqlHanderIndex = graphqlContent.findIndex((line) => + line.includes('createGraphQLHandler({') + ) + + if (graphqlHanderIndex === -1) { + console.log(` + Couldn't find graphql handler in api/src/functions/graphql.js. + You'll have to add the following cors config manually: + + cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true} + `) + return + } + + graphqlContent.splice( + graphqlHanderIndex + 1, + 0, + ' cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true },' + ) + + fs.writeFileSync(graphqlFunctionsPath, graphqlContent.join(EOL)) + }, + } +} + +const updateDbAuth = () => { + return { + title: 'Updating dbAuth cookie config (if used)...', + task: (_ctx) => { + const authTsPath = path.join(getPaths().base, 'api/src/functions/auth.ts') + const authJsPath = path.join(getPaths().base, 'api/src/functions/auth.js') + + let authFnPath + if (fs.existsSync(authTsPath)) { + authFnPath = authTsPath + } else if (fs.existsSync(authJsPath)) { + authFnPath = authJsPath + } else { + console.log(`Skipping, did not detect api/src/functions/auth.js`) + return + } + + const authContent = fs.readFileSync(authFnPath, 'utf8').split(EOL) + const sameSiteLineIndex = authContent.findIndex((line) => + line.match(/SameSite:.*,/) + ) + if (sameSiteLineIndex === -1) { + console.log(` + Couldn't find cookie SameSite config in api/src/functions/auth.js. + + You need to ensure SameSite is set to "None" + `) + return + } + authContent[ + sameSiteLineIndex + ] = ` SameSite: process.env.NODE_ENV === 'development' ? 'Strict' : 'None',` + + const dbHandlerIndex = authContent.findIndex((line) => + line.includes('new DbAuthHandler(') + ) + if (dbHandlerIndex === -1) { + console.log(` + Couldn't find DbAuthHandler in api/src/functions/auth.js. + You'll have to add the following cors config manually: + + cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true} + `) + return + } + authContent.splice( + dbHandlerIndex + 1, + 0, + ' cors: { origin: process.env.REDWOOD_WEB_URL, credentials: true },' + ) + + fs.writeFileSync(authFnPath, authContent.join(EOL)) + }, + } +} + +const updateApp = () => { + return { + title: 'Updating App.jsx fetch config...', + task: (_ctx) => { + // TODO Can improve in the future with RW getPaths() + const appTsPath = path.join(getPaths().base, 'web/src/App.tsx') + const appJsPath = path.join(getPaths().base, 'web/src/App.jsx') + + let appPath + if (fs.existsSync(appTsPath)) { + appPath = appTsPath + } else if (fs.existsSync(appJsPath)) { + appPath = appJsPath + } else { + // TODO this should never happen. Throw instead? + console.log(`Skipping, did not detect web/src/App.jsx|tsx`) + return + } + + const appContent = fs.readFileSync(appPath, 'utf8').split(EOL) + const authLineIndex = appContent.findIndex((line) => + line.includes(' in web/src/App.js + If (and when) you use *dbAuth*, you'll have to add the following fetch config to : + + config={{ fetchConfig: { credentials: 'include' } }} + `) + // This is CORS config for cookies, which is currently only dbAuth Currently only dbAuth uses cookies and would require this config + } else if (appContent.toString().match(/dbAuth/)) { + appContent[ + authLineIndex + ] = ` +` + } + + const gqlLineIndex = appContent.findIndex((line) => + line.includes(' +` + } + + fs.writeFileSync(appPath, appContent.join(EOL)) + }, + } +} + +// We need to set the apiUrl evn var for local dev +const addToDotEnvDefaultTask = () => { + return { + title: 'Updating .env.defaults...', + skip: () => { + if (!fs.existsSync(path.resolve(getPaths().base, '.env.defaults'))) { + return ` + WARNING: could not update .env.defaults + + You'll have to add the following env var manually: + + REDWOOD_API_URL=/.redwood/functions + ` + } + }, + task: async (_ctx) => { + const env = path.resolve(getPaths().base, '.env.defaults') + const line = '\n\nREDWOOD_API_URL=/.redwood/functions\n' + + fs.appendFileSync(env, line) + }, + } +} + +// any notes to print out when the job is done +const notes = [ + 'You are ready to deploy to Flightcontrol!\n', + '👉 Create your project at https://app.flightcontrol.dev/signup?ref=redwood\n', + 'Check out the deployment docs at https://app.flightcontrol.dev/docs for detailed instructions\n', + "NOTE: If you are using yarn v1, remove the installCommand's from flightcontrol.json", +] + +export const handler = async ({ force, database }) => { + recordTelemetryAttributes({ + command: 'setup deploy flightcontrol', + force, + database, + }) + const tasks = new Listr( + [ + { + title: 'Adding flightcontrol.json', + task: async () => { + const fileData = await getFlightcontrolJson(database) + let files = {} + files[fileData.path] = JSON.stringify(fileData.content, null, 2) + return writeFilesTask(files, { overwriteExisting: force }) + }, + }, + updateGraphQLFunction(), + updateDbAuth(), + updateApp(), + updateApiURLTask('${REDWOOD_API_URL}'), + addToDotEnvDefaultTask(), + printSetupNotes(notes), + ], + { rendererOptions: { collapseSubtasks: false } } + ) + + try { + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/deploy/providers/netlify.js b/packages/cli/src/commands/setup/deploy/providers/netlify.js index 929fdc7e44e9..e136c941e584 100644 --- a/packages/cli/src/commands/setup/deploy/providers/netlify.js +++ b/packages/cli/src/commands/setup/deploy/providers/netlify.js @@ -1,49 +1,7 @@ -// import terminalLink from 'terminal-link' -import path from 'path' - -import { Listr } from 'listr2' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { errorTelemetry } from '@redwoodjs/telemetry' - -import { getPaths, printSetupNotes } from '../../../../lib' -import c from '../../../../lib/colors' -import { addFilesTask, updateApiURLTask } from '../helpers' -import { NETLIFY_TOML } from '../templates/netlify' - export const command = 'netlify' export const description = 'Setup Netlify deploy' -const files = [ - { - path: path.join(getPaths().base, 'netlify.toml'), - content: NETLIFY_TOML, - }, -] - -const notes = [ - 'You are ready to deploy to Netlify!', - 'See: https://redwoodjs.com/docs/deploy/netlify', -] - -export const handler = async ({ force }) => { - recordTelemetryAttributes({ - command: 'setup deploy netlify', - force, - }) - const tasks = new Listr( - [ - updateApiURLTask('/.netlify/functions'), - addFilesTask({ files, force }), - printSetupNotes(notes), - ], - { rendererOptions: { collapseSubtasks: false } } - ) - try { - await tasks.run() - } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) - } +export async function handler(options) { + const { handler } = await import('./netlifyHandler.js') + return handler(options) } diff --git a/packages/cli/src/commands/setup/deploy/providers/netlifyHandler.js b/packages/cli/src/commands/setup/deploy/providers/netlifyHandler.js new file mode 100644 index 000000000000..6a438ad81bbc --- /dev/null +++ b/packages/cli/src/commands/setup/deploy/providers/netlifyHandler.js @@ -0,0 +1,45 @@ +import path from 'path' + +import { Listr } from 'listr2' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' +import { errorTelemetry } from '@redwoodjs/telemetry' + +import { getPaths, printSetupNotes } from '../../../../lib' +import c from '../../../../lib/colors' +import { addFilesTask, updateApiURLTask } from '../helpers' +import { NETLIFY_TOML } from '../templates/netlify' + +const files = [ + { + path: path.join(getPaths().base, 'netlify.toml'), + content: NETLIFY_TOML, + }, +] + +const notes = [ + 'You are ready to deploy to Netlify!', + 'See: https://redwoodjs.com/docs/deploy/netlify', +] + +export const handler = async ({ force }) => { + recordTelemetryAttributes({ + command: 'setup deploy netlify', + force, + }) + const tasks = new Listr( + [ + updateApiURLTask('/.netlify/functions'), + addFilesTask({ files, force }), + printSetupNotes(notes), + ], + { rendererOptions: { collapseSubtasks: false } } + ) + try { + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/deploy/providers/render.js b/packages/cli/src/commands/setup/deploy/providers/render.js index e54fac390183..c1d55b57e52e 100644 --- a/packages/cli/src/commands/setup/deploy/providers/render.js +++ b/packages/cli/src/commands/setup/deploy/providers/render.js @@ -1,70 +1,6 @@ -// import terminalLink from 'terminal-link' -import path from 'path' - -import { getSchema, getConfig } from '@prisma/internals' -import fs from 'fs-extra' -import { Listr } from 'listr2' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { errorTelemetry } from '@redwoodjs/telemetry' - -import { getPaths, writeFilesTask, printSetupNotes } from '../../../../lib' -import c from '../../../../lib/colors' -import { addFilesTask, updateApiURLTask } from '../helpers' -import { - POSTGRES_YAML, - RENDER_HEALTH_CHECK, - RENDER_YAML, - SQLITE_YAML, -} from '../templates/render' - export const command = 'render' export const description = 'Setup Render deploy' -export const getRenderYamlContent = async (database) => { - if (database === 'none') { - return { - path: path.join(getPaths().base, 'render.yaml'), - content: RENDER_YAML(''), - } - } - if (!fs.existsSync('api/db/schema.prisma')) { - throw new Error("Could not find prisma schema at 'api/db/schema.prisma'") - } - - const schema = await getSchema('api/db/schema.prisma') - const config = await getConfig({ datamodel: schema }) - const detectedDatabase = config.datasources[0].activeProvider - - if (detectedDatabase === database) { - switch (database) { - case 'postgresql': - return { - path: path.join(getPaths().base, 'render.yaml'), - content: RENDER_YAML(POSTGRES_YAML), - } - case 'sqlite': - return { - path: path.join(getPaths().base, 'render.yaml'), - content: RENDER_YAML(SQLITE_YAML), - } - default: - throw new Error(` - Unexpected datasource provider found: ${database}`) - } - } else { - throw new Error(` - Prisma datasource provider is detected to be ${detectedDatabase}. - - Option 1: Update your schema.prisma provider to be ${database}, then run - yarn rw prisma migrate dev - yarn rw setup deploy render --database ${database} - - Option 2: Rerun setup deploy command with current schema.prisma provider: - yarn rw setup deploy render --database ${detectedDatabase}`) - } -} - export const builder = (yargs) => yargs.option('database', { alias: 'd', @@ -74,54 +10,7 @@ export const builder = (yargs) => type: 'string', }) -// any notes to print out when the job is done -const notes = [ - 'You are ready to deploy to Render!\n', - 'Go to https://dashboard.render.com/iacs to create your account and deploy to Render', - 'Check out the deployment docs at https://render.com/docs/deploy-redwood for detailed instructions', - 'Note: After first deployment to Render update the rewrite rule destination in `./render.yaml`', -] - -const additionalFiles = [ - { - path: path.join(getPaths().base, 'api/src/functions/healthz.js'), - content: RENDER_HEALTH_CHECK, - }, -] - -export const handler = async ({ force, database }) => { - recordTelemetryAttributes({ - command: 'setup deploy render', - force, - database, - }) - const tasks = new Listr( - [ - { - title: 'Adding render.yaml', - task: async () => { - const fileData = await getRenderYamlContent(database) - let files = {} - files[fileData.path] = fileData.content - return writeFilesTask(files, { overwriteExisting: force }) - }, - }, - updateApiURLTask('/.redwood/functions'), - // Add health check api function - addFilesTask({ - files: additionalFiles, - force, - }), - printSetupNotes(notes), - ], - { rendererOptions: { collapseSubtasks: false } } - ) - - try { - await tasks.run() - } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) - } +export async function handler(options) { + const { handler } = await import('./renderHandler.js') + return handler(options) } diff --git a/packages/cli/src/commands/setup/deploy/providers/renderHandler.js b/packages/cli/src/commands/setup/deploy/providers/renderHandler.js new file mode 100644 index 000000000000..0ad9156eb049 --- /dev/null +++ b/packages/cli/src/commands/setup/deploy/providers/renderHandler.js @@ -0,0 +1,115 @@ +import path from 'path' + +import { getSchema, getConfig } from '@prisma/internals' +import fs from 'fs-extra' +import { Listr } from 'listr2' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' +import { errorTelemetry } from '@redwoodjs/telemetry' + +import { getPaths, writeFilesTask, printSetupNotes } from '../../../../lib' +import c from '../../../../lib/colors' +import { addFilesTask, updateApiURLTask } from '../helpers' +import { + POSTGRES_YAML, + RENDER_HEALTH_CHECK, + RENDER_YAML, + SQLITE_YAML, +} from '../templates/render' + +const getRenderYamlContent = async (database) => { + if (database === 'none') { + return { + path: path.join(getPaths().base, 'render.yaml'), + content: RENDER_YAML(''), + } + } + + if (!fs.existsSync('api/db/schema.prisma')) { + throw new Error("Could not find prisma schema at 'api/db/schema.prisma'") + } + + const schema = await getSchema('api/db/schema.prisma') + const config = await getConfig({ datamodel: schema }) + const detectedDatabase = config.datasources[0].activeProvider + + if (detectedDatabase === database) { + switch (database) { + case 'postgresql': + return { + path: path.join(getPaths().base, 'render.yaml'), + content: RENDER_YAML(POSTGRES_YAML), + } + case 'sqlite': + return { + path: path.join(getPaths().base, 'render.yaml'), + content: RENDER_YAML(SQLITE_YAML), + } + default: + throw new Error(` + Unexpected datasource provider found: ${database}`) + } + } else { + throw new Error(` + Prisma datasource provider is detected to be ${detectedDatabase}. + + Option 1: Update your schema.prisma provider to be ${database}, then run + yarn rw prisma migrate dev + yarn rw setup deploy render --database ${database} + + Option 2: Rerun setup deploy command with current schema.prisma provider: + yarn rw setup deploy render --database ${detectedDatabase}`) + } +} + +// any notes to print out when the job is done +const notes = [ + 'You are ready to deploy to Render!\n', + 'Go to https://dashboard.render.com/iacs to create your account and deploy to Render', + 'Check out the deployment docs at https://render.com/docs/deploy-redwood for detailed instructions', + 'Note: After first deployment to Render update the rewrite rule destination in `./render.yaml`', +] + +const additionalFiles = [ + { + path: path.join(getPaths().base, 'api/src/functions/healthz.js'), + content: RENDER_HEALTH_CHECK, + }, +] + +export async function handler({ force, database }) { + recordTelemetryAttributes({ + command: 'setup deploy render', + force, + database, + }) + const tasks = new Listr( + [ + { + title: 'Adding render.yaml', + task: async () => { + const fileData = await getRenderYamlContent(database) + let files = {} + files[fileData.path] = fileData.content + return writeFilesTask(files, { overwriteExisting: force }) + }, + }, + updateApiURLTask('/.redwood/functions'), + // Add health check api function + addFilesTask({ + files: additionalFiles, + force, + }), + printSetupNotes(notes), + ], + { rendererOptions: { collapseSubtasks: false } } + ) + + try { + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/deploy/providers/serverless.js b/packages/cli/src/commands/setup/deploy/providers/serverless.js deleted file mode 100644 index 825a4d75ea54..000000000000 --- a/packages/cli/src/commands/setup/deploy/providers/serverless.js +++ /dev/null @@ -1,153 +0,0 @@ -// import terminalLink from 'terminal-link' -import path from 'path' - -import fs from 'fs-extra' -import { Listr } from 'listr2' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { errorTelemetry } from '@redwoodjs/telemetry' - -import { addPackagesTask, getPaths, printSetupNotes } from '../../../../lib' -import c from '../../../../lib/colors' -import { addToGitIgnoreTask, addToDotEnvTask, addFilesTask } from '../helpers' -import { SERVERLESS_API_YML } from '../templates/serverless/api' -import { SERVERLESS_WEB_YML } from '../templates/serverless/web' - -export const command = 'serverless' -export const description = - '[DEPRECATED]\n' + - 'Setup Serverless Framework AWS deploy\n' + - 'For more information:\n' + - 'https://redwoodjs.com/docs/deploy/serverless' - -export const aliases = ['aws-serverless'] - -export const notes = [ - c.error('DEPRECATED option not officially supported'), - '', - 'For more information:', - 'https://redwoodjs.com/docs/deploy/serverless', - '', - '', - c.green("You're almost ready to deploy using the Serverless framework!"), - '', - '• See https://redwoodjs.com/docs/deploy#serverless-deploy for more info. If you ', - ' want to give it a shot, open your `.env` file and add your AWS credentials,', - ' then run: ', - '', - ' yarn rw deploy serverless --first-run', - '', - ' For subsequent deploys you can just run `yarn rw deploy serverless`.', - '', - '• If you want to use the Serverless Dashboard to manage your app, plug in', - ' the values for `org` and `app` in `web/serverless.yml` and `api/serverless.yml`', - '', - "• If you haven't already, familiarize yourself with the docs for your", - ' preferred provider: https://www.serverless.com/framework/docs/providers', -] - -const projectDevPackages = [ - 'serverless', - 'serverless-lift', - '@vercel/nft', - 'archiver', - 'fs-extra', -] - -const files = [ - { - path: path.join(getPaths().api.base, 'serverless.yml'), - content: SERVERLESS_API_YML, - }, - { - path: path.join(getPaths().web.base, 'serverless.yml'), - content: SERVERLESS_WEB_YML, - }, -] - -const prismaBinaryTargetAdditions = () => { - const content = fs.readFileSync(getPaths().api.dbSchema).toString() - - if (!content.includes('rhel-openssl-1.0.x')) { - const result = content.replace( - /binaryTargets =.*\n/, - `binaryTargets = ["native", "rhel-openssl-1.0.x"]\n` - ) - - fs.writeFileSync(getPaths().api.dbSchema, result) - } -} - -// updates the api_url to use an environment variable. -const updateRedwoodTomlTask = () => { - return { - title: 'Updating redwood.toml apiUrl...', - task: () => { - const configPath = path.join(getPaths().base, 'redwood.toml') - const content = fs.readFileSync(configPath).toString() - - const newContent = content.replace( - /apiUrl.*?\n/m, - 'apiUrl = "${API_URL:/api}" # Set API_URL in production to the Serverless deploy endpoint of your api service, see https://redwoodjs.com/docs/deploy/serverless-deploy\n' - ) - fs.writeFileSync(configPath, newContent) - }, - } -} - -export const handler = async ({ force }) => { - recordTelemetryAttributes({ - command: 'setup deploy serverless', - force, - }) - const [serverless, serverlessLift, ...rest] = projectDevPackages - - const tasks = new Listr( - [ - addPackagesTask({ - packages: [serverless, ...rest], - devDependency: true, - }), - addPackagesTask({ - packages: [serverless, serverlessLift], - side: 'web', - devDependency: true, - }), - addPackagesTask({ - packages: [serverless], - side: 'api', - devDependency: true, - }), - addFilesTask({ - files, - force, - }), - updateRedwoodTomlTask(), - addToGitIgnoreTask({ - paths: ['.serverless'], - }), - addToDotEnvTask({ - lines: [ - 'AWS_ACCESS_KEY_ID=', - 'AWS_SECRET_ACCESS_KEY=', - ], - }), - { - title: 'Adding necessary Prisma binaries...', - task: () => prismaBinaryTargetAdditions(), - }, - printSetupNotes(notes), - ], - { - exitOnError: true, - rendererOptions: { collapseSubtasks: false }, - } - ) - try { - await tasks.run() - } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) - } -} diff --git a/packages/cli/src/commands/setup/deploy/providers/vercel.js b/packages/cli/src/commands/setup/deploy/providers/vercel.js index d648dab3c4f2..857427af386c 100644 --- a/packages/cli/src/commands/setup/deploy/providers/vercel.js +++ b/packages/cli/src/commands/setup/deploy/providers/vercel.js @@ -1,33 +1,7 @@ -// import terminalLink from 'terminal-link' -import { Listr } from 'listr2' - -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { errorTelemetry } from '@redwoodjs/telemetry' - -import { printSetupNotes } from '../../../../lib' -import c from '../../../../lib/colors' -import { updateApiURLTask } from '../helpers' - export const command = 'vercel' export const description = 'Setup Vercel deploy' -const notes = [ - 'You are ready to deploy to Vercel!', - 'See: https://redwoodjs.com/docs/deploy#vercel-deploy', -] - -export const handler = async () => { - recordTelemetryAttributes({ - command: 'setup deploy vercel', - }) - const tasks = new Listr([updateApiURLTask('/api'), printSetupNotes(notes)], { - rendererOptions: { collapseSubtasks: false }, - }) - try { - await tasks.run() - } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) - } +export async function handler(options) { + const { handler } = await import('./vercelHandler.js') + return handler(options) } diff --git a/packages/cli/src/commands/setup/deploy/providers/vercelHandler.js b/packages/cli/src/commands/setup/deploy/providers/vercelHandler.js new file mode 100644 index 000000000000..b7d037ac6d3a --- /dev/null +++ b/packages/cli/src/commands/setup/deploy/providers/vercelHandler.js @@ -0,0 +1,31 @@ +import { Listr } from 'listr2' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' +import { errorTelemetry } from '@redwoodjs/telemetry' + +import { printSetupNotes } from '../../../../lib' +import c from '../../../../lib/colors' +import { updateApiURLTask } from '../helpers' + +const notes = [ + 'You are ready to deploy to Vercel!', + 'See: https://redwoodjs.com/docs/deploy#vercel-deploy', +] + +export const handler = async () => { + recordTelemetryAttributes({ + command: 'setup deploy vercel', + }) + + const tasks = new Listr([updateApiURLTask('/api'), printSetupNotes(notes)], { + rendererOptions: { collapseSubtasks: false }, + }) + + try { + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/deploy/templates/serverless/api.js b/packages/cli/src/commands/setup/deploy/templates/serverless/api.js deleted file mode 100644 index 2772c29beee1..000000000000 --- a/packages/cli/src/commands/setup/deploy/templates/serverless/api.js +++ /dev/null @@ -1,85 +0,0 @@ -import path from 'path' - -import fs from 'fs-extra' - -import { getPaths } from '../../../../../lib' - -export const PROJECT_NAME = path.basename(getPaths().base) - -export const SERVERLESS_API_YML = `# See the full yml reference at https://www.serverless.com/framework/docs/providers/aws/guide/serverless.yml/ -service: ${PROJECT_NAME}-api - -# Uncomment \`org\` and \`app\` and enter manually if you want to integrate your -# deployment with the Serverless dashboard, or run \`yarn serverless\` in ./api to be -# prompted to connect to an app and these will be filled in for you. -# See https://www.serverless.com/framework/docs/dashboard/ for more details. -# org: your-org -# app: your-app - -useDotenv: true - -provider: - name: aws - runtime: nodejs18.x - region: us-east-1 # AWS region where the service will be deployed, defaults to N. Virginia - httpApi: # HTTP API is used by default. To learn about the available options in API Gateway, see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html - cors: - allowedOrigins: - - '*' # This is the default value. You can remove this line if you want to restrict the CORS to a specific origin. - # allowCredentials: true # allowCredentials should only be used when allowedOrigins doesn't include '*' - allowedHeaders: - - authorization - - auth-provider - - content-type - - X-Amz-Date - - X-Api-Key - - X-Amz-Security-Token - - X-Amz-User-Agent - payload: '1.0' - stackTags: - source: serverless - name: Redwood Lambda API with HTTP API Gateway - tags: - name: Redwood Lambda API with HTTP API Gateway - environment: - # Add environment variables here, either in the form - # VARIABLE_NAME: \${env:VARIABLE_NAME} for vars in your local environment, or - # VARIABLE_NAME: \${param:VARIABLE_NAME} for vars from the Serverless dashboard - -package: - individually: true - patterns: - - "!node_modules/.prisma/client/libquery_engine-*" - - "node_modules/.prisma/client/libquery_engine-rhel-*" - - "!node_modules/prisma/libquery_engine-*" - - "!node_modules/@prisma/engines/**" - -${ - fs.existsSync(path.resolve(getPaths().api.functions)) - ? `functions: - ${fs - .readdirSync(path.resolve(getPaths().api.functions)) - .map((file) => { - const basename = path.parse(file).name - return `${basename}: - description: ${basename} function deployed on AWS Lambda - package: - artifact: dist/zipball/${basename}.zip - memorySize: 1024 # in megabytes - timeout: 25 # seconds (max: 900 [15 minutes]) - tags: # tags for this specific lambda function - endpoint: /${basename} - handler: ${basename}.handler - events: - - httpApi: # if a function should be limited to only GET or POST you can remove one or the other here - path: /${basename} - method: GET - - httpApi: - path: /${basename} - method: POST -` - }) - .join(' ')}` - : '' -} -` diff --git a/packages/cli/src/commands/setup/deploy/templates/serverless/web.js b/packages/cli/src/commands/setup/deploy/templates/serverless/web.js deleted file mode 100644 index 80972e08474a..000000000000 --- a/packages/cli/src/commands/setup/deploy/templates/serverless/web.js +++ /dev/null @@ -1,31 +0,0 @@ -import path from 'path' - -import { getPaths } from '../../../../../lib' - -export const PROJECT_NAME = path.basename(getPaths().base) - -export const SERVERLESS_WEB_YML = `# See the full yml reference at https://www.serverless.com/framework/docs/providers/aws/guide/serverless.yml/ -service: ${PROJECT_NAME}-web - -# Uncomment \`org\` and \`app\` and enter manually if you want to integrate your -# deployment with the Serverless dashboard, or run \`yarn serverless\` in ./web to be -# prompted to connect to an app and these will be filled in for you. -# See https://www.serverless.com/framework/docs/dashboard/ for more details. -# org: your-org -# app: your-app - -useDotenv: true - -plugins: - - serverless-lift - -constructs: - web: - type: static-website - path: dist - -provider: - name: aws - runtime: nodejs18.x - region: us-east-1 # AWS region where the service will be deployed, defaults to N. Virgina -` diff --git a/packages/cli/src/commands/setup/ui/ui.js b/packages/cli/src/commands/setup/ui/ui.js index b98a629d0233..6ef61b5ab397 100644 --- a/packages/cli/src/commands/setup/ui/ui.js +++ b/packages/cli/src/commands/setup/ui/ui.js @@ -1,14 +1,21 @@ import terminalLink from 'terminal-link' +import * as chakraUICommand from './libraries/chakra-ui' +import * as mantineCommand from './libraries/mantine' +import * as tailwindCSSCommand from './libraries/tailwindcss' + export const command = 'ui ' export const description = 'Set up a UI design or style library' -export const builder = (yargs) => + +export function builder(yargs) { yargs - .commandDir('./libraries') - .demandCommand() + .command(chakraUICommand) + .command(mantineCommand) + .command(tailwindCSSCommand) .epilogue( `Also see the ${terminalLink( 'Redwood CLI Reference', 'https://redwoodjs.com/docs/cli-commands#setup-ui' )}` ) +} diff --git a/tasks/generateDependencyGraph.mjs b/tasks/generateDependencyGraph.mjs index 4c486086ef4c..18092241a9ff 100644 --- a/tasks/generateDependencyGraph.mjs +++ b/tasks/generateDependencyGraph.mjs @@ -2,20 +2,19 @@ /* eslint-env node */ // @ts-check -import { execSync } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' -import chalk from 'chalk' -import { default as enquirer } from 'enquirer' - -const rootDir = fileURLToPath(new URL('../', import.meta.url)) -const DEPENDENCY_CRUISER_CONFIG_FILE = '.dependency-cruiser.mjs' -const globalConfigPath = path.join(rootDir, DEPENDENCY_CRUISER_CONFIG_FILE) +import prompts from 'prompts' +import { chalk, fs, path, $ } from 'zx' async function main() { + $.verbose = false + + const rootDir = fileURLToPath(new URL('../', import.meta.url)) + const DEPENDENCY_CRUISER_CONFIG_FILE = '.dependency-cruiser.mjs' + const globalConfigPath = path.join(rootDir, DEPENDENCY_CRUISER_CONFIG_FILE) + const { positionals, values } = parseArgs({ allowPositionals: true, options: { @@ -29,56 +28,55 @@ async function main() { let [targetDir] = positionals - const packages = execSync('yarn workspaces list --json', { - encoding: 'utf-8', - }) + const choices = (await $`yarn workspaces list --json`).stdout .trim() .split('\n') .map(JSON.parse) .filter(({ name }) => name) - .flatMap(({ location }) => { + .flatMap(({ name, location }) => { const srcPath = path.join(rootDir, location, 'src') const distPath = path.join(rootDir, location, 'dist') - return [srcPath, distPath] + return [ + { title: `${name} (src)`, value: srcPath }, + { title: `${name} (dist)`, value: distPath }, + ] }) if (!targetDir) { - const res = await enquirer.prompt({ - type: 'select', - name: 'targetDir', - message: 'Choose a target directory', - // Unfortunately we exceed the terminal's height with all our packages - // and enquirer doesn't handle it too well. - // But showing choices gives users an idea of how it works. - choices: [...packages.slice(0, 10), '...'], - }) + const res = await prompts( + { + type: 'autocomplete', + name: 'targetDir', + message: 'Choose a package', + choices, + async suggest(input, choices) { + return Promise.resolve( + choices.filter(({ title }) => title.includes(input)) + ) + }, + }, + { + onCancel: () => { + process.exit(1) + }, + } + ) targetDir = res.targetDir } const { dir: packageDir, base } = path.parse(targetDir) - const localConfigPath = path.join(packageDir, DEPENDENCY_CRUISER_CONFIG_FILE) let configPath = globalConfigPath + const localConfigPath = path.join(packageDir, DEPENDENCY_CRUISER_CONFIG_FILE) if (fs.existsSync(localConfigPath)) { configPath = localConfigPath } - const depcruiseCommand = [ - 'depcruise', - targetDir, - '--config', - configPath, - '--output-type dot', - '--exclude "src/__tests__"', - ].join(' ') - const outputPath = path.join(packageDir, `./dependencyGraph.${base}.svg`) - const dotCommand = ['dot', '-T svg', `-o ${outputPath}`].join(' ') - - execSync(`${depcruiseCommand} | ${dotCommand}`) + await $`yarn depcruise ${targetDir} --config ${configPath} --output-type dot --exclude "src/__tests__" | dot -T svg -o ${outputPath}` console.log( `Wrote ${chalk.magenta(base)} dependency graph to ${chalk.magenta( @@ -88,7 +86,7 @@ async function main() { if (values.open) { console.log(`Opening ${chalk.magenta(outputPath)}...`) - execSync(`open ${outputPath}`) + await $`open ${outputPath}` } }