Compare commits
No commits in common. "86a91469befcb30d1411cc9736f158593174db62" and "d4e8975200b6864f255d81974d695012825465eb" have entirely different histories.
86a91469be
...
d4e8975200
|
|
@ -190,111 +190,13 @@ const CancelButton = define('CancelButton', {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const BackLink = define('BackLink', {
|
|
||||||
base: 'a',
|
|
||||||
fontSize: '13px',
|
|
||||||
color: theme('colors-textMuted'),
|
|
||||||
textDecoration: 'none',
|
|
||||||
states: {
|
|
||||||
':hover': { color: theme('colors-text') },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const DetailHeader = define('DetailHeader', {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '12px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const DetailTitle = define('DetailTitle', {
|
|
||||||
base: 'h1',
|
|
||||||
fontFamily: theme('fonts-mono'),
|
|
||||||
fontSize: '18px',
|
|
||||||
fontWeight: 600,
|
|
||||||
margin: 0,
|
|
||||||
flex: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const DetailMeta = define('DetailMeta', {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
fontSize: '13px',
|
|
||||||
color: theme('colors-textMuted'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const MetaItem = define('MetaItem', {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '6px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const MetaLabel = define('MetaLabel', {
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme('colors-text'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const OutputSection = define('OutputSection', {
|
|
||||||
marginTop: '20px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const OutputLabel = define('OutputLabel', {
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 500,
|
|
||||||
marginBottom: '8px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const OutputBlock = define('OutputBlock', {
|
|
||||||
base: 'pre',
|
|
||||||
fontFamily: theme('fonts-mono'),
|
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
padding: '12px',
|
|
||||||
backgroundColor: theme('colors-bgElement'),
|
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
overflowX: 'auto',
|
|
||||||
overflowY: 'auto',
|
|
||||||
maxHeight: '60vh',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
margin: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const ErrorBlock = define('ErrorBlock', {
|
|
||||||
base: 'pre',
|
|
||||||
fontFamily: theme('fonts-mono'),
|
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
padding: '12px',
|
|
||||||
backgroundColor: theme('colors-bgElement'),
|
|
||||||
border: `1px solid ${theme('colors-error')}`,
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
color: theme('colors-error'),
|
|
||||||
overflowX: 'auto',
|
|
||||||
overflowY: 'auto',
|
|
||||||
maxHeight: '60vh',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
margin: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const StatusBadge = define('StatusBadge', {
|
|
||||||
base: 'span',
|
|
||||||
fontSize: '12px',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '9999px',
|
|
||||||
fontWeight: 500,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
function Layout({ title, children, refresh }: { title: string; children: Child; refresh?: boolean }) {
|
function Layout({ title, children }: { title: string; children: Child }) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
{refresh && <meta http-equiv="refresh" content="2" />}
|
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -353,10 +255,9 @@ app.get('/', async c => {
|
||||||
invalid.sort((a, b) => a.id.localeCompare(b.id))
|
invalid.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
const hasAny = jobs.length > 0 || invalid.length > 0
|
const hasAny = jobs.length > 0 || invalid.length > 0
|
||||||
const anyRunning = jobs.some(j => j.state === 'running')
|
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title="Cron Jobs" refresh={anyRunning}>
|
<Layout title="Cron Jobs">
|
||||||
<ActionRow>
|
<ActionRow>
|
||||||
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
|
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
|
||||||
</ActionRow>
|
</ActionRow>
|
||||||
|
|
@ -371,11 +272,7 @@ app.get('/', async c => {
|
||||||
{jobs.map(job => (
|
{jobs.map(job => (
|
||||||
<JobItem>
|
<JobItem>
|
||||||
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
||||||
<JobName>
|
<JobName>{job.app}/{job.name}</JobName>
|
||||||
<a href={`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
|
||||||
{job.app}/{job.name}
|
|
||||||
</a>
|
|
||||||
</JobName>
|
|
||||||
<Schedule>{job.schedule}</Schedule>
|
<Schedule>{job.schedule}</Schedule>
|
||||||
<Time title="Last run">{formatRelative(job.lastRun)}</Time>
|
<Time title="Last run">{formatRelative(job.lastRun)}</Time>
|
||||||
<Time title="Next run">{formatRelative(job.nextRun)}</Time>
|
<Time title="Next run">{formatRelative(job.nextRun)}</Time>
|
||||||
|
|
@ -399,81 +296,6 @@ app.get('/', async c => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function statusBadgeStyle(job: CronJob): Record<string, string> {
|
|
||||||
if (job.state === 'running') return { backgroundColor: theme('colors-statusRunning'), color: 'white' }
|
|
||||||
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return { backgroundColor: theme('colors-error'), color: 'white' }
|
|
||||||
return { backgroundColor: theme('colors-bgElement'), color: theme('colors-textMuted') }
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(job: CronJob): string {
|
|
||||||
if (job.state === 'running') return 'running'
|
|
||||||
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return `exit ${job.lastExitCode}`
|
|
||||||
if (job.lastRun) return 'ok'
|
|
||||||
return 'idle'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(ms?: number): string {
|
|
||||||
if (!ms) return '-'
|
|
||||||
if (ms < 1000) return `${ms}ms`
|
|
||||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
|
||||||
return `${Math.round(ms / 60000)}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/job/:app/:name', async c => {
|
|
||||||
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
|
||||||
const job = getJob(id)
|
|
||||||
const appFilter = c.req.query('app')
|
|
||||||
const backUrl = appFilter ? `/?app=${appFilter}` : '/'
|
|
||||||
|
|
||||||
if (!job) {
|
|
||||||
return c.html(
|
|
||||||
<Layout title="Job Not Found">
|
|
||||||
<BackLink href={backUrl}>← Back</BackLink>
|
|
||||||
<EmptyState>Job not found: {id}</EmptyState>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.html(
|
|
||||||
<Layout title={`${job.app}/${job.name}`} refresh={job.state === 'running'}>
|
|
||||||
<BackLink href={backUrl}>← Back</BackLink>
|
|
||||||
<DetailHeader>
|
|
||||||
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
|
||||||
<DetailTitle>{job.app}/{job.name}</DetailTitle>
|
|
||||||
<StatusBadge style={statusBadgeStyle(job)}>{statusLabel(job)}</StatusBadge>
|
|
||||||
<form method="post" action={`/run/${job.app}/${job.name}?return=detail&app=${appFilter || ''}`}>
|
|
||||||
<RunButton type="submit" disabled={job.state === 'running'}>
|
|
||||||
{job.state === 'running' ? 'Running...' : 'Run Now'}
|
|
||||||
</RunButton>
|
|
||||||
</form>
|
|
||||||
</DetailHeader>
|
|
||||||
<DetailMeta>
|
|
||||||
<MetaItem><MetaLabel>Schedule</MetaLabel> {job.schedule}</MetaItem>
|
|
||||||
<MetaItem><MetaLabel>Last run</MetaLabel> {formatRelative(job.lastRun)}</MetaItem>
|
|
||||||
<MetaItem><MetaLabel>Duration</MetaLabel> {formatDuration(job.lastDuration)}</MetaItem>
|
|
||||||
<MetaItem><MetaLabel>Next run</MetaLabel> {formatRelative(job.nextRun)}</MetaItem>
|
|
||||||
</DetailMeta>
|
|
||||||
{job.lastError && (
|
|
||||||
<OutputSection>
|
|
||||||
<OutputLabel>Error</OutputLabel>
|
|
||||||
<ErrorBlock>{job.lastError}</ErrorBlock>
|
|
||||||
</OutputSection>
|
|
||||||
)}
|
|
||||||
{job.lastOutput && (
|
|
||||||
<OutputSection>
|
|
||||||
<OutputLabel>Output</OutputLabel>
|
|
||||||
<OutputBlock>{job.lastOutput}</OutputBlock>
|
|
||||||
</OutputSection>
|
|
||||||
)}
|
|
||||||
{!job.lastError && !job.lastOutput && job.lastRun && (
|
|
||||||
<OutputSection>
|
|
||||||
<EmptyState>No output</EmptyState>
|
|
||||||
</OutputSection>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/new', async c => {
|
app.get('/new', async c => {
|
||||||
const appName = c.req.query('app') || ''
|
const appName = c.req.query('app') || ''
|
||||||
|
|
||||||
|
|
@ -565,14 +387,9 @@ app.post('/run/:app/:name', async c => {
|
||||||
return c.redirect('/?error=not-found')
|
return c.redirect('/?error=not-found')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget so the redirect happens immediately
|
await executeJob(job, broadcast)
|
||||||
executeJob(job, broadcast)
|
|
||||||
|
|
||||||
const returnTo = c.req.query('return')
|
|
||||||
const appFilter = c.req.query('app')
|
const appFilter = c.req.query('app')
|
||||||
if (returnTo === 'detail') {
|
|
||||||
return c.redirect(`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`)
|
|
||||||
}
|
|
||||||
return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
|
return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -621,7 +438,6 @@ async function rediscover() {
|
||||||
job.lastDuration = old.lastDuration
|
job.lastDuration = old.lastDuration
|
||||||
job.lastExitCode = old.lastExitCode
|
job.lastExitCode = old.lastExitCode
|
||||||
job.lastError = old.lastError
|
job.lastError = old.lastError
|
||||||
job.lastOutput = old.lastOutput
|
|
||||||
job.nextRun = old.nextRun
|
job.nextRun = old.nextRun
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { CronJob } from './schedules'
|
||||||
import { getNextRun } from './scheduler'
|
import { getNextRun } from './scheduler'
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
const RUNNER = join(import.meta.dir, 'runner.ts')
|
|
||||||
|
|
||||||
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
|
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
|
||||||
if (job.state === 'disabled') return
|
if (job.state === 'disabled') return
|
||||||
|
|
@ -15,7 +14,7 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
|
||||||
const cwd = join(APPS_DIR, job.app, 'current')
|
const cwd = join(APPS_DIR, job.app, 'current')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
|
const proc = Bun.spawn(['bun', 'run', job.file], {
|
||||||
cwd,
|
cwd,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
|
|
@ -32,7 +31,6 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
|
||||||
job.lastDuration = Date.now() - job.lastRun
|
job.lastDuration = Date.now() - job.lastRun
|
||||||
job.lastExitCode = code
|
job.lastExitCode = code
|
||||||
job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined
|
job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined
|
||||||
job.lastOutput = stdout || undefined
|
|
||||||
job.state = 'idle'
|
job.state = 'idle'
|
||||||
job.nextRun = getNextRun(job.id)
|
job.nextRun = getNextRun(job.id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
export {}
|
|
||||||
|
|
||||||
Error.stackTraceLimit = 50
|
|
||||||
|
|
||||||
const file = process.argv[2]!
|
|
||||||
const { default: fn } = await import(file)
|
|
||||||
try {
|
|
||||||
await fn()
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error) {
|
|
||||||
console.error(e.stack || e.message)
|
|
||||||
} else {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
@ -17,7 +17,6 @@ export type CronJob = {
|
||||||
lastDuration?: number
|
lastDuration?: number
|
||||||
lastExitCode?: number
|
lastExitCode?: number
|
||||||
lastError?: string
|
lastError?: string
|
||||||
lastOutput?: string
|
|
||||||
nextRun?: number
|
nextRun?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type { Manifest } from '@types'
|
import type { Manifest } from '@types'
|
||||||
import { loadGitignore } from '@gitignore'
|
import { loadGitignore } from '@gitignore'
|
||||||
import { generateManifest } from '%sync'
|
import { computeHash, generateManifest } from '%sync'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { diffLines } from 'diff'
|
import { diffLines } from 'diff'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
|
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
|
||||||
import { confirm, prompt } from '../prompts'
|
import { confirm, prompt } from '../prompts'
|
||||||
|
|
@ -64,7 +64,7 @@ export async function getApp(name: string) {
|
||||||
console.log(color.green(`✓ Downloaded ${name}`))
|
console.log(color.green(`✓ Downloaded ${name}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pushApp(options: { quiet?: boolean } = {}) {
|
export async function pushApp() {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error(notAppError())
|
||||||
return
|
return
|
||||||
|
|
@ -98,16 +98,10 @@ export async function pushApp(options: { quiet?: boolean } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files to delete (exist on server but not locally)
|
// Note: We don't delete files in versioned deployments - new version is separate directory
|
||||||
const toDelete: string[] = []
|
|
||||||
for (const file of remoteFiles) {
|
|
||||||
if (!localFiles.has(file)) {
|
|
||||||
toDelete.push(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toUpload.length === 0 && toDelete.length === 0) {
|
if (toUpload.length === 0) {
|
||||||
if (!options.quiet) console.log('Already up to date')
|
console.log('Already up to date')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,20 +141,7 @@ export async function pushApp(options: { quiet?: boolean } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Delete files that no longer exist locally
|
// 3. Activate new version (updates symlink and restarts app)
|
||||||
if (toDelete.length > 0) {
|
|
||||||
console.log(`Deleting ${toDelete.length} files...`)
|
|
||||||
for (const file of toDelete) {
|
|
||||||
const success = await del(`/api/sync/apps/${appName}/files/${file}?version=${version}`)
|
|
||||||
if (success) {
|
|
||||||
console.log(` ${color.red('✗')} ${file}`)
|
|
||||||
} else {
|
|
||||||
console.log(` ${color.red('✗')} ${file} (failed)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Activate new version (updates symlink and restarts app)
|
|
||||||
type ActivateResponse = { ok: boolean }
|
type ActivateResponse = { ok: boolean }
|
||||||
const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${version}`)
|
const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${version}`)
|
||||||
if (!activateRes?.ok) {
|
if (!activateRes?.ok) {
|
||||||
|
|
@ -171,7 +152,7 @@ export async function pushApp(options: { quiet?: boolean } = {}) {
|
||||||
console.log(color.green(`✓ Deployed and activated version ${version}`))
|
console.log(color.green(`✓ Deployed and activated version ${version}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}) {
|
export async function pullApp(options: { force?: boolean } = {}) {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error(notAppError())
|
||||||
return
|
return
|
||||||
|
|
@ -207,7 +188,7 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}
|
||||||
const toDelete = localOnly
|
const toDelete = localOnly
|
||||||
|
|
||||||
if (toDownload.length === 0 && toDelete.length === 0) {
|
if (toDownload.length === 0 && toDelete.length === 0) {
|
||||||
if (!options.quiet) console.log('Already up to date')
|
console.log('Already up to date')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,32 +379,39 @@ export async function syncApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const appName = getAppName()
|
const appName = getAppName()
|
||||||
|
const gitignore = loadGitignore(process.cwd())
|
||||||
|
const localHashes = new Map<string, string>()
|
||||||
|
|
||||||
// Verify app exists on server
|
// Initialize local hashes
|
||||||
const result = await getManifest(appName)
|
const manifest = generateManifest(process.cwd(), appName)
|
||||||
if (result === null) return
|
for (const [path, info] of Object.entries(manifest.files)) {
|
||||||
if (!result.exists) {
|
localHashes.set(path, info.hash)
|
||||||
console.error(`App ${color.bold(appName)} doesn't exist on server. Run ${color.bold('toes push')} first.`)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Syncing ${color.bold(appName)}...`)
|
console.log(`Syncing ${color.bold(appName)}...`)
|
||||||
|
|
||||||
// Initial sync: pull remote changes, then push local changes
|
// Watch local files
|
||||||
await pullApp({ force: true, quiet: true })
|
const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => {
|
||||||
await pushApp({ quiet: true })
|
|
||||||
|
|
||||||
const gitignore = loadGitignore(process.cwd())
|
|
||||||
|
|
||||||
// Watch local files with debounce → push
|
|
||||||
let pushTimer: Timer | null = null
|
|
||||||
const watcher = watch(process.cwd(), { recursive: true }, (_event, filename) => {
|
|
||||||
if (!filename || gitignore.shouldExclude(filename)) return
|
if (!filename || gitignore.shouldExclude(filename)) return
|
||||||
if (pushTimer) clearTimeout(pushTimer)
|
|
||||||
pushTimer = setTimeout(() => pushApp({ quiet: true }), 500)
|
const fullPath = join(process.cwd(), filename)
|
||||||
|
|
||||||
|
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
|
||||||
|
const content = readFileSync(fullPath)
|
||||||
|
const hash = computeHash(content)
|
||||||
|
if (localHashes.get(filename) !== hash) {
|
||||||
|
localHashes.set(filename, hash)
|
||||||
|
await put(`/api/sync/apps/${appName}/files/${filename}`, content)
|
||||||
|
console.log(` ${color.green('↑')} ${filename}`)
|
||||||
|
}
|
||||||
|
} else if (!existsSync(fullPath)) {
|
||||||
|
localHashes.delete(filename)
|
||||||
|
await del(`/api/sync/apps/${appName}/files/${filename}`)
|
||||||
|
console.log(` ${color.red('✗')} ${filename}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Connect to SSE for remote changes → pull
|
// Connect to SSE for remote changes
|
||||||
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
|
|
@ -445,12 +433,11 @@ export async function syncApp() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` Connected, watching for changes...`)
|
console.log(` Connected to server, watching for changes...`)
|
||||||
|
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
let pullTimer: Timer | null = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -463,13 +450,30 @@ export async function syncApp() {
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.startsWith('data: ')) continue
|
if (!line.startsWith('data: ')) continue
|
||||||
if (pullTimer) clearTimeout(pullTimer)
|
const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete', path: string, hash?: string }
|
||||||
pullTimer = setTimeout(() => pullApp({ force: true, quiet: true }), 500)
|
|
||||||
|
if (event.type === 'change') {
|
||||||
|
// Skip if we already have this version (handles echo from our own changes)
|
||||||
|
if (localHashes.get(event.path) === event.hash) continue
|
||||||
|
const content = await download(`/api/sync/apps/${appName}/files/${event.path}`)
|
||||||
|
if (content) {
|
||||||
|
const fullPath = join(process.cwd(), event.path)
|
||||||
|
mkdirSync(dirname(fullPath), { recursive: true })
|
||||||
|
writeFileSync(fullPath, content)
|
||||||
|
localHashes.set(event.path, event.hash!)
|
||||||
|
console.log(` ${color.green('↓')} ${event.path}`)
|
||||||
|
}
|
||||||
|
} else if (event.type === 'delete') {
|
||||||
|
const fullPath = join(process.cwd(), event.path)
|
||||||
|
if (existsSync(fullPath)) {
|
||||||
|
unlinkSync(fullPath)
|
||||||
|
localHashes.delete(event.path)
|
||||||
|
console.log(` ${color.red('✗')} ${event.path} (remote)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (pushTimer) clearTimeout(pushTimer)
|
|
||||||
if (pullTimer) clearTimeout(pullTimer)
|
|
||||||
watcher.close()
|
watcher.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
src/cli/setup.ts
126
src/cli/setup.ts
|
|
@ -1,7 +1,6 @@
|
||||||
import { program } from 'commander'
|
import { program } from 'commander'
|
||||||
|
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
|
|
||||||
import pkg from '../../package.json'
|
import pkg from '../../package.json'
|
||||||
import {
|
import {
|
||||||
cleanApp,
|
cleanApp,
|
||||||
|
|
@ -38,15 +37,17 @@ program
|
||||||
.version(`v${pkg.version}`, '-v, --version')
|
.version(`v${pkg.version}`, '-v, --version')
|
||||||
.addHelpText('beforeAll', (ctx) => {
|
.addHelpText('beforeAll', (ctx) => {
|
||||||
if (ctx.command === program) {
|
if (ctx.command === program) {
|
||||||
return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
|
return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n')
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
.addHelpCommand(false)
|
|
||||||
.configureOutput({
|
.configureOutput({
|
||||||
writeOut: (str) => {
|
writeOut: (str) => {
|
||||||
const colored = str
|
const colored = str
|
||||||
.replace(/^([A-Z][\w ]*:)/gm, color.yellow('$1'))
|
.replace(/^(Usage:)/gm, color.yellow('$1'))
|
||||||
|
.replace(/^(Commands:)/gm, color.yellow('$1'))
|
||||||
|
.replace(/^(Options:)/gm, color.yellow('$1'))
|
||||||
|
.replace(/^(Arguments:)/gm, color.yellow('$1'))
|
||||||
process.stdout.write(colored)
|
process.stdout.write(colored)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -55,88 +56,44 @@ program
|
||||||
.command('version', { hidden: true })
|
.command('version', { hidden: true })
|
||||||
.action(() => console.log(program.version()))
|
.action(() => console.log(program.version()))
|
||||||
|
|
||||||
// Apps
|
program
|
||||||
|
.command('config')
|
||||||
|
.description('Show current host configuration')
|
||||||
|
.action(configShow)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('info')
|
||||||
|
.description('Show info for an app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(infoApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('list')
|
.command('list')
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('List all apps')
|
.description('List all apps')
|
||||||
.option('-t, --tools', 'show only tools')
|
.option('-t, --tools', 'show only tools')
|
||||||
.option('-a, --apps', 'show only apps (exclude tools)')
|
.option('-a, --apps', 'show only apps (exclude tools)')
|
||||||
.action(listApps)
|
.action(listApps)
|
||||||
|
|
||||||
program
|
|
||||||
.command('info')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Show info for an app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(infoApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('new')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Create a new toes app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.option('--ssr', 'SSR template with pages directory (default)')
|
|
||||||
.option('--bare', 'minimal template with no pages')
|
|
||||||
.option('--spa', 'single-page app with client-side rendering')
|
|
||||||
.action(newApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('get')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Download an app from server')
|
|
||||||
.argument('<name>', 'app name')
|
|
||||||
.action(getApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('open')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Open an app in browser')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(openApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('rename')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Rename an app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.argument('<new-name>', 'new app name')
|
|
||||||
.action(renameApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('rm')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Remove an app from the server')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(rmApp)
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('start')
|
.command('start')
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Start an app')
|
.description('Start an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(startApp)
|
.action(startApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('stop')
|
.command('stop')
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Stop an app')
|
.description('Stop an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(stopApp)
|
.action(stopApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('restart')
|
.command('restart')
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Restart an app')
|
.description('Restart an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(restartApp)
|
.action(restartApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('logs')
|
.command('logs')
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Show logs for an app')
|
.description('Show logs for an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.option('-f, --follow', 'follow log output')
|
.option('-f, --follow', 'follow log output')
|
||||||
|
|
@ -156,47 +113,59 @@ program
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('stats')
|
.command('stats')
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Show CPU and memory stats for apps')
|
.description('Show CPU and memory stats for apps')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(statsApp)
|
.action(statsApp)
|
||||||
|
|
||||||
// Sync
|
program
|
||||||
|
.command('open')
|
||||||
|
.description('Open an app in browser')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(openApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('get')
|
||||||
|
.description('Download an app from server')
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.action(getApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('new')
|
||||||
|
.description('Create a new toes app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.option('--ssr', 'SSR template with pages directory (default)')
|
||||||
|
.option('--bare', 'minimal template with no pages')
|
||||||
|
.option('--spa', 'single-page app with client-side rendering')
|
||||||
|
.action(newApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('push')
|
.command('push')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Push local changes to server')
|
.description('Push local changes to server')
|
||||||
.action(pushApp)
|
.action(pushApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('pull')
|
.command('pull')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Pull changes from server')
|
.description('Pull changes from server')
|
||||||
.option('-f, --force', 'overwrite local changes')
|
.option('-f, --force', 'overwrite local changes')
|
||||||
.action(pullApp)
|
.action(pullApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('status')
|
.command('status')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Show what would be pushed/pulled')
|
.description('Show what would be pushed/pulled')
|
||||||
.action(statusApp)
|
.action(statusApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('diff')
|
.command('diff')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Show diff of changed files')
|
.description('Show diff of changed files')
|
||||||
.action(diffApp)
|
.action(diffApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('sync')
|
.command('sync')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Watch and sync changes bidirectionally')
|
.description('Watch and sync changes bidirectionally')
|
||||||
.action(syncApp)
|
.action(syncApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('clean')
|
.command('clean')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Remove local files not on server')
|
.description('Remove local files not on server')
|
||||||
.option('-f, --force', 'skip confirmation')
|
.option('-f, --force', 'skip confirmation')
|
||||||
.option('-n, --dry-run', 'show what would be removed')
|
.option('-n, --dry-run', 'show what would be removed')
|
||||||
|
|
@ -204,7 +173,6 @@ program
|
||||||
|
|
||||||
const stash = program
|
const stash = program
|
||||||
.command('stash')
|
.command('stash')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Stash local changes')
|
.description('Stash local changes')
|
||||||
.action(stashApp)
|
.action(stashApp)
|
||||||
|
|
||||||
|
|
@ -218,17 +186,8 @@ stash
|
||||||
.description('List all stashes')
|
.description('List all stashes')
|
||||||
.action(stashListApp)
|
.action(stashListApp)
|
||||||
|
|
||||||
// Config
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('config')
|
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('Show current host configuration')
|
|
||||||
.action(configShow)
|
|
||||||
|
|
||||||
const env = program
|
const env = program
|
||||||
.command('env')
|
.command('env')
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('Manage environment variables')
|
.description('Manage environment variables')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(envList)
|
.action(envList)
|
||||||
|
|
@ -250,17 +209,28 @@ env
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('versions')
|
.command('versions')
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('List deployed versions')
|
.description('List deployed versions')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(versionsApp)
|
.action(versionsApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('rollback')
|
.command('rollback')
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('Rollback to a previous version')
|
.description('Rollback to a previous version')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
||||||
.action((name, options) => rollbackApp(name, options.version))
|
.action((name, options) => rollbackApp(name, options.version))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('rm')
|
||||||
|
.description('Remove an app from the server')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(rmApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('rename')
|
||||||
|
.description('Rename an app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.argument('<new-name>', 'new app name')
|
||||||
|
.action(renameApp)
|
||||||
|
|
||||||
export { program }
|
export { program }
|
||||||
|
|
|
||||||
|
|
@ -126,13 +126,10 @@ router.delete('/apps/:app', c => {
|
||||||
router.delete('/apps/:app/files/:path{.+}', c => {
|
router.delete('/apps/:app/files/:path{.+}', c => {
|
||||||
const appName = c.req.param('app')
|
const appName = c.req.param('app')
|
||||||
const filePath = c.req.param('path')
|
const filePath = c.req.param('path')
|
||||||
const version = c.req.query('version')
|
|
||||||
|
|
||||||
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
|
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
|
||||||
|
|
||||||
const basePath = version
|
const basePath = join(APPS_DIR, appName, 'current')
|
||||||
? join(APPS_DIR, appName, version)
|
|
||||||
: join(APPS_DIR, appName, 'current')
|
|
||||||
|
|
||||||
const fullPath = safePath(basePath, filePath)
|
const fullPath = safePath(basePath, filePath)
|
||||||
if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
|
if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user