diff --git a/apps/basic/20260130-000000/index.tsx b/apps/basic/20260130-000000/index.tsx
index 301eab6..bd40004 100644
--- a/apps/basic/20260130-000000/index.tsx
+++ b/apps/basic/20260130-000000/index.tsx
@@ -4,6 +4,12 @@ const app = new Hype
app.get('/', c => c.html(
Hi there!
))
+// Test crash - remove after testing
+setTimeout(() => {
+ console.log('About to crash...')
+ throw new Error('Test crash!')
+}, 3000)
+
const apps = () => {
}
diff --git a/src/cli/commands/crashes.ts b/src/cli/commands/crashes.ts
new file mode 100644
index 0000000..c5171ae
--- /dev/null
+++ b/src/cli/commands/crashes.ts
@@ -0,0 +1,90 @@
+import color from 'kleur'
+import { get, makeUrl } from '../http'
+import { resolveAppName } from '../name'
+
+interface CrashSummary {
+ date: string
+ exitCode: string
+ filename: string
+ time: string
+ uptime: string
+}
+
+const parseCrashFilename = (filename: string): { date: string, time: string } => {
+ // Format: YYYY-MM-DD-HHMMSS.txt
+ const match = filename.match(/^(\d{4}-\d{2}-\d{2})-(\d{2})(\d{2})(\d{2})\.txt$/)
+ if (!match) return { date: filename, time: '' }
+ const [, date, h, m, s] = match
+ return { date: date!, time: `${h}:${m}:${s}` }
+}
+
+const parseCrashReport = (content: string): { exitCode: string, uptime: string } => {
+ const lines = content.split('\n')
+ let exitCode = 'unknown'
+ let uptime = 'unknown'
+
+ for (const line of lines) {
+ if (line.startsWith('Exit Code: ')) {
+ exitCode = line.slice('Exit Code: '.length)
+ } else if (line.startsWith('Uptime: ')) {
+ uptime = line.slice('Uptime: '.length)
+ }
+ }
+
+ return { exitCode, uptime }
+}
+
+export async function crashesApp(arg: string | undefined) {
+ const name = resolveAppName(arg)
+ if (!name) return
+
+ const files = await get(`/api/apps/${name}/crashes`)
+ if (!files) {
+ console.error(`App not found: ${name}`)
+ return
+ }
+
+ if (files.length === 0) {
+ console.log('No crash reports found')
+ return
+ }
+
+ console.log(color.bold(`Crash history for ${name}:`))
+ console.log()
+
+ // Fetch summary info for each crash
+ const crashes: CrashSummary[] = []
+ for (const filename of files.slice(0, 10)) { // Show last 10 crashes
+ const content = await fetchCrashContent(name, filename)
+ const { date, time } = parseCrashFilename(filename)
+ const { exitCode, uptime } = content
+ ? parseCrashReport(content)
+ : { exitCode: 'unknown', uptime: 'unknown' }
+
+ crashes.push({ filename, date, time, exitCode, uptime })
+ }
+
+ for (const crash of crashes) {
+ const exitColor = crash.exitCode === '0' ? color.green : color.red
+ console.log(
+ ` ${color.gray(crash.date)} ${color.gray(crash.time)} ` +
+ `exit ${exitColor(crash.exitCode)} ` +
+ `uptime ${color.cyan(crash.uptime)}`
+ )
+ }
+
+ if (files.length > 10) {
+ console.log()
+ console.log(color.gray(` ... and ${files.length - 10} more`))
+ }
+}
+
+async function fetchCrashContent(appName: string, filename: string): Promise {
+ try {
+ const res = await fetch(makeUrl(`/api/apps/${appName}/crashes/${filename}`))
+ if (!res.ok) return null
+ return await res.text()
+ } catch {
+ return null
+ }
+}
diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts
index a79022f..c59e4e4 100644
--- a/src/cli/commands/index.ts
+++ b/src/cli/commands/index.ts
@@ -1,3 +1,4 @@
+export { crashesApp } from './crashes'
export { logApp } from './logs'
export {
configShow,
diff --git a/src/cli/setup.ts b/src/cli/setup.ts
index 6eaaf6e..086cdfb 100644
--- a/src/cli/setup.ts
+++ b/src/cli/setup.ts
@@ -4,6 +4,7 @@ import color from 'kleur'
import {
cleanApp,
configShow,
+ crashesApp,
diffApp,
getApp,
infoApp,
@@ -106,6 +107,12 @@ program
.option('-g, --grep ', 'filter logs by pattern')
.action(logApp)
+program
+ .command('crashes')
+ .description('Show crash history for an app')
+ .argument('[name]', 'app name (uses current directory if omitted)')
+ .action(crashesApp)
+
program
.command('open')
.description('Open an app in browser')
diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts
index 1d6d5bd..af69f22 100644
--- a/src/server/api/apps.ts
+++ b/src/server/api/apps.ts
@@ -1,4 +1,4 @@
-import { APPS_DIR, allApps, getLogDates, onChange, readLogs, registerApp, renameApp, startApp, stopApp, updateAppIcon } from '$apps'
+import { APPS_DIR, allApps, getCrashFiles, getLogDates, onChange, readCrashReport, readLogs, registerApp, renameApp, startApp, stopApp, updateAppIcon } from '$apps'
import type { App as BackendApp } from '$apps'
import type { App as SharedApp } from '@types'
import { generateTemplates, type TemplateType } from '%templates'
@@ -86,6 +86,31 @@ router.get('/:app/logs/dates', c => {
return c.json(getLogDates(appName))
})
+router.get('/:app/crashes', c => {
+ const appName = c.req.param('app')
+ if (!appName) return c.json({ error: 'App not found' }, 404)
+
+ const app = allApps().find(a => a.name === appName)
+ if (!app) return c.json({ error: 'App not found' }, 404)
+
+ const files = getCrashFiles(appName)
+ return c.json(files)
+})
+
+router.get('/:app/crashes/:filename', c => {
+ const appName = c.req.param('app')
+ const filename = c.req.param('filename')
+ if (!appName || !filename) return c.json({ error: 'App not found' }, 404)
+
+ const app = allApps().find(a => a.name === appName)
+ if (!app) return c.json({ error: 'App not found' }, 404)
+
+ const content = readCrashReport(appName, filename)
+ if (content === null) return c.json({ error: 'Crash report not found' }, 404)
+
+ return c.text(content)
+})
+
router.post('/', async c => {
let body: { name?: string, template?: TemplateType, tool?: boolean }
try {
diff --git a/src/server/apps.ts b/src/server/apps.ts
index a540561..376e3a5 100644
--- a/src/server/apps.ts
+++ b/src/server/apps.ts
@@ -55,6 +55,16 @@ export const runApps = () =>
export const runningApps = (): App[] =>
allApps().filter(a => a.state === 'running')
+export function getCrashFiles(appName: string): string[] {
+ const dir = crashDir(appName)
+ if (!existsSync(dir)) return []
+
+ return readdirSync(dir)
+ .filter(f => f.endsWith('.txt'))
+ .sort()
+ .reverse()
+}
+
export function getLogDates(appName: string): string[] {
const dir = logDir(appName)
if (!existsSync(dir)) return []
@@ -66,6 +76,12 @@ export function getLogDates(appName: string): string[] {
.reverse()
}
+export function readCrashReport(appName: string, filename: string): string | null {
+ const file = join(crashDir(appName), filename)
+ if (!existsSync(file)) return null
+ return readFileSync(file, 'utf-8')
+}
+
export function readLogs(appName: string, date?: string, tail?: number): string[] {
const file = logFile(appName, date ?? formatLogDate())
if (!existsSync(file)) return []
@@ -270,15 +286,34 @@ const info = (app: App, ...msg: string[]) => {
app.logs?.push({ time: Date.now(), text: msg.join(' ') })
}
+const crashDir = (appName: string) =>
+ join(APPS_DIR, appName, 'crashes')
+
+const formatCrashTimestamp = (date: Date = new Date()) => {
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
+}
+
+const formatUptime = (ms: number): string => {
+ const seconds = Math.floor(ms / 1000) % 60
+ const minutes = Math.floor(ms / 60000) % 60
+ const hours = Math.floor(ms / 3600000)
+ const parts: string[] = []
+ if (hours > 0) parts.push(`${hours}h`)
+ if (minutes > 0) parts.push(`${minutes}m`)
+ parts.push(`${seconds}s`)
+ return parts.join(' ')
+}
+
+const isApp = (dir: string): boolean =>
+ !loadApp(dir).error
+
const logDir = (appName: string) =>
join(APPS_DIR, appName, 'logs')
const logFile = (appName: string, date: string = formatLogDate()) =>
join(logDir(appName), `${date}.log`)
-const isApp = (dir: string): boolean =>
- !loadApp(dir).error
-
const update = () => {
setApps(allApps())
_listeners.forEach(cb => cb())
@@ -302,6 +337,14 @@ function discoverApps() {
update()
}
+function ensureCrashDir(appName: string): string {
+ const dir = crashDir(appName)
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true })
+ }
+ return dir
+}
+
function ensureLogDir(appName: string): string {
const dir = logDir(appName)
if (!existsSync(dir)) {
@@ -471,6 +514,36 @@ function rotateLogs() {
}
}
+function writeCrashReport(app: App, exitCode: number | null, signal: string | null) {
+ const now = new Date()
+ const uptime = app.started ? Date.now() - app.started : 0
+
+ // Get last 20 log lines
+ const logs = app.logs ?? []
+ const lastLogs = logs.slice(-20)
+
+ const lines: string[] = [
+ `App: ${app.name}`,
+ `Time: ${now.toISOString()}`,
+ `Exit Code: ${exitCode}`,
+ `Signal: ${signal}`,
+ `Uptime: ${formatUptime(uptime)}`,
+ `Restart Attempt: ${app.restartAttempts ?? 0}`,
+ '',
+ '--- Last 20 log lines ---',
+ ]
+
+ for (const log of lastLogs) {
+ const timestamp = new Date(log.time).toISOString()
+ lines.push(`[${timestamp}] ${log.text}`)
+ }
+
+ ensureCrashDir(app.name)
+ const filename = `${formatCrashTimestamp(now)}.txt`
+ const filepath = join(crashDir(app.name), filename)
+ writeFileSync(filepath, lines.join('\n') + '\n')
+}
+
function writeLogLine(appName: string, streamType: 'stdout' | 'stderr' | 'system', text: string) {
ensureLogDir(appName)
const timestamp = new Date().toISOString()
@@ -565,6 +638,11 @@ async function runApp(dir: string, port: number) {
const msg = `Exited with code ${code}`
app.logs?.push({ time: Date.now(), text: msg })
writeLogLine(dir, 'system', msg)
+
+ // Write crash report only for actual crashes (non-zero exit, not manually stopped)
+ if (!app.manuallyStopped) {
+ writeCrashReport(app, code, null)
+ }
} else {
app.logs?.push({ time: Date.now(), text: 'Stopped' })
writeLogLine(dir, 'system', 'Stopped')