diff --git a/README.md b/README.md index 2d9c0e5..c00531a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ npx nuxthub ## Usage ```bash -USAGE nuxthub init|deploy|link|unlink|open|manage|login|logout|whoami +USAGE nuxthub init|deploy|link|unlink|open|manage|login|logout|logs|whoami COMMANDS @@ -31,6 +31,7 @@ COMMANDS manage Open in browser the NuxtHub URL for a linked project. login Authenticate with NuxtHub. logout Logout the current authenticated user. + logs Display the logs of a deployment. whoami Shows the username of the currently logged in user. Use nuxthub --help for more information about a command. diff --git a/bun.lockb b/bun.lockb index 932f3b4..483b1d8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 55c2f40..6691370 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,11 @@ "pathe": "^1.1.2", "pretty-bytes": "^6.1.1", "rc9": "^2.1.1", + "signal-exit": "^4.1.0", "ufo": "^1.4.0", "unstorage": "^1.10.1", - "update-notifier": "^7.0.0" + "update-notifier": "^7.0.0", + "ws": "^8.16.0" }, "devDependencies": { "@nuxt/eslint-config": "^0.2.0", diff --git a/src/commands/logs.mjs b/src/commands/logs.mjs new file mode 100644 index 0000000..a034a66 --- /dev/null +++ b/src/commands/logs.mjs @@ -0,0 +1,98 @@ +import { consola } from 'consola' +import { colors } from 'consola/utils' +import ora from 'ora' +import { onExit } from 'signal-exit' +import { setTimeout } from 'timers/promises' +import { defineCommand, runCommand } from 'citty' +import { isCancel, confirm } from '@clack/prompts' +import { fetchUser, projectPath, fetchProject, getProjectEnv, connectLogs, createLogs, deleteLogs, printFormattedLog } from '../utils/index.mjs' +import login from './login.mjs' +import link from './link.mjs' + +export default defineCommand({ + meta: { + name: 'logs', + description: 'Display the logs of a deployment.', + }, + args: { + production: { + type: 'boolean', + description: 'Display the logs of the production deployment.', + default: false + }, + preview: { + type: 'boolean', + description: 'Display the logs of the latest preview deployment.', + default: false + } + }, + async setup({ args }) { + let user = await fetchUser() + if (!user) { + consola.info('Please login to deploy your project.') + await runCommand(login, {}) + user = await fetchUser() + } + + let project = await fetchProject() + if (!project) { + consola.warn(`${colors.blue(projectPath())} is not linked to any NuxtHub project.`) + + const shouldLink = await confirm({ + message: 'Do you want to link it to a project?', + initialValue: false + }) + if (!shouldLink || isCancel(shouldLink)) { + return + } + await runCommand(link, {}) + project = await fetchProject() + if (!project) { + return consola.error('Could not fetch the project, please try again.') + } + } + const env = getProjectEnv(project, args) + const envColored = env === 'production' ? colors.green(env) : colors.yellow(env) + const url = (env === 'production' ? project.url : project.previewUrl) + if (!url) { + consola.info(`No deployment found for ${envColored} environment.`) + return consola.info(`Please run \`nuxthub deploy --${env}\` to deploy your project.`) + } + consola.success(`Linked to ${colors.blue(project.slug)} project available at \`${url}\``) + + const spinner = ora(`Connecting to ${envColored} deployment...`).start() + + const logs = await createLogs(project.slug, project.teamSlug, env) + + const socket = connectLogs(logs.url) + + const onCloseSocket = async () => { + socket.terminate() + await deleteLogs(project.slug, project.teamSlug, env, logs.id) + } + + onExit(onCloseSocket) + socket.on('close', onCloseSocket) + + socket.on('message', (data) => { + printFormattedLog(data) + }) + + while (socket.readyState !== socket.OPEN) { + switch (socket.readyState) { + case socket.CONNECTING: + await setTimeout(100) + break + case socket.CLOSING: + await setTimeout(100) + break + case socket.CLOSED: + consola.error('Connection to deployment closed unexpectedly.') + await onCloseSocket() + process.exit(1) + } + } + + spinner.succeed(`Connected to ${envColored} deployment waiting for logs...`) + }, +}) diff --git a/src/commands/manage.mjs b/src/commands/manage.mjs index b245982..3ff6698 100644 --- a/src/commands/manage.mjs +++ b/src/commands/manage.mjs @@ -34,7 +34,7 @@ export default defineCommand({ await runCommand(link, {}) project = await fetchProject() if (!project) { - return console.log('project is null') + return console.error('Could not fetch the project, please try again.') } } diff --git a/src/commands/open.mjs b/src/commands/open.mjs index 66f78bc..a28ab22 100644 --- a/src/commands/open.mjs +++ b/src/commands/open.mjs @@ -2,7 +2,7 @@ import { consola } from 'consola' import { colors } from 'consola/utils' import { isCancel, confirm } from '@clack/prompts' import { defineCommand, runCommand } from 'citty' -import { fetchUser, projectPath, fetchProject, gitInfo } from '../utils/index.mjs' +import { fetchUser, projectPath, fetchProject, getProjectEnv } from '../utils/index.mjs' import open from 'open' import login from './login.mjs' import link from './link.mjs' @@ -45,20 +45,14 @@ export default defineCommand({ await runCommand(link, {}) project = await fetchProject() if (!project) { - return console.log('project is null') + return console.error('Could not fetch the project, please try again.') } } // Get the environment based on branch - let env = 'production' - if (args.preview) { - env = 'preview' - } else if (!args.production && !args.preview) { - const git = gitInfo() - // Guess the env based on the branch - env = (git.branch === project.productionBranch) ? 'production' : 'preview' - } + const env = getProjectEnv(project, args) const envColored = env === 'production' ? colors.green(env) : colors.yellow(env) const url = (env === 'production' ? project.url : project.previewUrl) + consola.info(`Opening ${envColored} URL of ${colors.blue(project.slug)} in the browser...`) if (!url) { consola.info(`Project ${colors.blue(project.slug)} does not have a ${envColored} URL, please run \`nuxthub deploy --${env}\`.`) @@ -67,6 +61,6 @@ export default defineCommand({ open(url) - consola.success(`Project \`${url}\` opened in the browser.`) + consola.success(`\`${url}\` opened in the browser.`) }, }) diff --git a/src/index.mjs b/src/index.mjs index 64c9e5c..81ff150 100755 --- a/src/index.mjs +++ b/src/index.mjs @@ -11,6 +11,7 @@ import link from './commands/link.mjs' import unlink from './commands/unlink.mjs' import login from './commands/login.mjs' import logout from './commands/logout.mjs' +import logs from './commands/logs.mjs' import whoami from './commands/whoami.mjs' import deploy from './commands/deploy.mjs' import open from './commands/open.mjs' @@ -40,6 +41,7 @@ const main = defineCommand({ manage, login, logout, + logs, whoami }, }) diff --git a/src/utils/git.mjs b/src/utils/git.mjs index d318e92..bb65147 100644 --- a/src/utils/git.mjs +++ b/src/utils/git.mjs @@ -29,3 +29,18 @@ export function gitInfo() { } return git } + +export function getProjectEnv(project, args) { + if (args.production) { + return 'production' + } + if (args.preview) { + return 'preview' + } + // Guess from git branch + const git = gitInfo() + if (!git.branch || git.branch === project?.productionBranch) { + return 'production' + } + return 'preview' +} diff --git a/src/utils/index.mjs b/src/utils/index.mjs index f69192e..03b2556 100644 --- a/src/utils/index.mjs +++ b/src/utils/index.mjs @@ -3,3 +3,4 @@ export * from './data.mjs' export * from './deploy.mjs' export * from './git.mjs' export * from './poll.mjs' +export * from './logs.mjs' diff --git a/src/utils/logs.mjs b/src/utils/logs.mjs new file mode 100644 index 0000000..fdbebe3 --- /dev/null +++ b/src/utils/logs.mjs @@ -0,0 +1,128 @@ +import { consola } from 'consola' +import { colors } from 'consola/utils' +import WebSocket from 'ws' +import { $api } from './data.mjs' + +const CF_LOG_OUTCOMES = { + ok: 'OK', + canceled: 'Canceled', + exceededCpu: 'Exceeded CPU Limit', + exceededMemory: 'Exceeded Memory Limit', + exception: 'Exception Thrown', + unknown: 'Unknown' +} + + +export async function createLogs(projectSlug, teamSlug, env) { + return await $api(`/teams/${teamSlug}/projects/${projectSlug}/${env}/logs`) +} + +export async function deleteLogs(projectSlug, teamSlug, env, id) { + return await $api(`/teams/${teamSlug}/projects/${projectSlug}/${env}/logs/${id}`, { + method: 'DELETE' + }) +} + +export function connectLogs(url, debug = false) { + const tail = new WebSocket(url, 'trace-v1', { + headers: { + 'Sec-WebSocket-Protocol': 'trace-v1', // needs to be `trace-v1` to be accepted + 'User-Agent': `nuxt-hub/${11}`, + }, + }) + + // send filters when we open up + tail.on('open', () => { + tail.send( + JSON.stringify({ debug: debug }), + { binary: false, compress: false, mask: false, fin: true }, + (err) => { + if (err) { + throw err + } + } + ) + }) + + return tail +} + +export function printFormattedLog(log) { + log = JSON.parse(log.toString()) + const outcome = CF_LOG_OUTCOMES[log.outcome] || CF_LOG_OUTCOMES.unknown + + if ('request' in log.event) { + // Request + const { request: { method, url }, response: { status } } = log.event + const statusColored = status >= 500 ? colors.red(status) : status >= 400 ? colors.yellow(status) : colors.green(status) + // const datetime = new Date(log.eventTimestamp).toLocaleString() + const path = new URL(url).pathname + + consola.log( + url + ? `${method.toUpperCase().padStart(6, ' ')} ${statusColored} ${path}` + : `[missing request] - ${outcome}` + ) + } else if ('cron' in log.event) { + // Cron + const cronPattern = log.event.cron + const datetime = new Date(log.event.scheduledTime).toLocaleString() + const outcome = log.outcome + + consola.log(`"${cronPattern}" @${datetime} - ${outcome}`) + } else if ('mailFrom' in log.event) { + // Email + const datetime = new Date(log.eventTimestamp).toLocaleString() + const mailFrom = log.event.mailFrom + const rcptTo = log.event.rcptTo + const rawSize = log.event.rawSize + + consola.log(`Email from:${mailFrom} to:${rcptTo} size:${rawSize} @${datetime} - ${outcome}`) + } else if ('scheduledTime' in log.event && !('cron' in log.event)) { + // Alarm + const datetime = new Date(log.event.scheduledTime).toLocaleString() + consola.log(`Alarm @${datetime} - ${outcome}`) + } else if ('consumedEvents' in log.event) { + // Tail Event + const datetime = new Date(log.eventTimestamp).toLocaleString() + const tailedScripts = new Set( + log.event.consumedEvents + .map((consumedEvent) => consumedEvent.scriptName) + .filter((scriptName) => !!scriptName) + ) + + consola.log(`Tailing ${Array.from(tailedScripts).join(',')} - ${outcome} @${datetime}`) + } else if ('message' in log.event && 'type' in log.event) { + // Tail Info + if (log.event.type === 'overload') { + consola.log(log.event.message) + } else if (log.event.type === 'overload-stop') { + consola.log(log.event.message) + } + } else if ('queue' in log.event) { + // Queue + const datetime = new Date(log.eventTimestamp).toLocaleString() + const queueName = log.event.queue + const batchSize = log.event.batchSize + const batchSizeMsg = `${batchSize} message${batchSize !== 1 ? 's' : ''}` + + consola.log(`Queue ${queueName} (${batchSizeMsg}) - ${outcome} @${datetime}`) + } else { + // Unknown event type + const datetime = new Date(log.eventTimestamp).toLocaleString() + consola.log(`Unknown Event - ${outcome} @${datetime}`) + } + + // Print console logs and exceptions + if (log.logs.length > 0) { + log.logs.forEach(({ level, message }) => { + consola[level](...message) + }) + } + + if (log.exceptions.length > 0) { + log.exceptions.forEach(({ name, message }) => { + consola.error(colors.red(`${name}:`, message)) + }) + } +}