Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <command> --help for more information about a command.
Expand Down
Binary file modified bun.lockb
Binary file not shown.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 98 additions & 0 deletions src/commands/logs.mjs
Original file line number Diff line number Diff line change
@@ -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...`)
},
})
2 changes: 1 addition & 1 deletion src/commands/manage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
}
}

Expand Down
16 changes: 5 additions & 11 deletions src/commands/open.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}\`.`)
Expand All @@ -67,6 +61,6 @@ export default defineCommand({

open(url)

consola.success(`Project \`${url}\` opened in the browser.`)
consola.success(`\`${url}\` opened in the browser.`)
},
})
2 changes: 2 additions & 0 deletions src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -40,6 +41,7 @@ const main = defineCommand({
manage,
login,
logout,
logs,
whoami
},
})
Expand Down
15 changes: 15 additions & 0 deletions src/utils/git.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
1 change: 1 addition & 0 deletions src/utils/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './data.mjs'
export * from './deploy.mjs'
export * from './git.mjs'
export * from './poll.mjs'
export * from './logs.mjs'
128 changes: 128 additions & 0 deletions src/utils/logs.mjs
Original file line number Diff line number Diff line change
@@ -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))
})
}
}