versions, rollback, clean
This commit is contained in:
parent
68ebf1d7a7
commit
a56af4ed47
6
TODO.txt
6
TODO.txt
|
|
@ -47,5 +47,9 @@
|
|||
[x] list projects
|
||||
[x] start/stop/restart project
|
||||
[x] create project
|
||||
[ ] todo.txt
|
||||
[x] todo.txt
|
||||
[x] tools
|
||||
[x] code browser
|
||||
[x] versioned pushes
|
||||
[x] version browser
|
||||
[ ] ...
|
||||
|
|
|
|||
4
bun.lock
4
bun.lock
|
|
@ -6,7 +6,7 @@
|
|||
"name": "toes",
|
||||
"dependencies": {
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"commander": "^14.0.2",
|
||||
"diff": "^8.0.3",
|
||||
"kleur": "^4.1.5",
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"packages": {
|
||||
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||
|
||||
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
|
||||
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
"description": "personal web appliance - turn it on and forget about the cloud",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"files": ["src"],
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tools": "./src/tools/index.ts"
|
||||
|
|
@ -36,9 +38,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"commander": "^14.0.2",
|
||||
"diff": "^8.0.3",
|
||||
"kleur": "^4.1.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,4 +11,4 @@ export {
|
|||
startApp,
|
||||
stopApp,
|
||||
} from './manage'
|
||||
export { diffApp, getApp, pullApp, pushApp, statusApp, syncApp } from './sync'
|
||||
export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export async function infoApp(arg?: string) {
|
|||
}
|
||||
|
||||
const icon = STATE_ICONS[app.state] ?? '◯'
|
||||
console.log(`${icon} ${color.bold(app.name)} ${app.tool && '[tool]'}`)
|
||||
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
|
||||
console.log(` State: ${app.state}`)
|
||||
if (app.port) {
|
||||
console.log(` Port: ${app.port}`)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { loadGitignore } from '@gitignore'
|
|||
import { computeHash, generateManifest } from '%sync'
|
||||
import color from 'kleur'
|
||||
import { diffLines } from 'diff'
|
||||
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
|
||||
import { confirm } from '../prompts'
|
||||
import { getAppName, isApp } from '../name'
|
||||
import { confirm, prompt } from '../prompts'
|
||||
import { getAppName, isApp, resolveAppName } from '../name'
|
||||
|
||||
interface ManifestDiff {
|
||||
changed: string[]
|
||||
|
|
@ -236,7 +236,7 @@ export async function diffApp() {
|
|||
const { changed, localOnly, remoteOnly } = diff
|
||||
|
||||
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) {
|
||||
console.log(color.green('✓ No differences'))
|
||||
// console.log(color.green('✓ No differences'))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -329,7 +329,7 @@ export async function statusApp() {
|
|||
const hasLocalChanges = toPush.length > 0
|
||||
|
||||
// Display status
|
||||
console.log(`Status for ${color.bold(appName)}:\n`)
|
||||
// console.log(`Status for ${color.bold(appName)}:\n`)
|
||||
|
||||
if (!remoteManifest) {
|
||||
console.log(color.yellow('App does not exist on server'))
|
||||
|
|
@ -471,6 +471,364 @@ export async function syncApp() {
|
|||
}
|
||||
}
|
||||
|
||||
export async function versionsApp(name?: string) {
|
||||
const appName = resolveAppName(name)
|
||||
if (!appName) return
|
||||
|
||||
const result = await getVersions(appName)
|
||||
|
||||
if (!result) return
|
||||
|
||||
if (result.versions.length === 0) {
|
||||
console.log(`No versions found for ${color.bold(appName)}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Versions for ${color.bold(appName)}:\n`)
|
||||
|
||||
for (const version of result.versions) {
|
||||
const isCurrent = version === result.current
|
||||
const date = formatVersion(version)
|
||||
|
||||
if (isCurrent) {
|
||||
console.log(` ${color.green('→')} ${color.bold(version)} ${color.gray(date)} ${color.green('(current)')}`)
|
||||
} else {
|
||||
console.log(` ${version} ${color.gray(date)}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
}
|
||||
|
||||
export async function rollbackApp(name?: string, version?: string) {
|
||||
const appName = resolveAppName(name)
|
||||
if (!appName) return
|
||||
|
||||
// Get available versions
|
||||
const result = await getVersions(appName)
|
||||
|
||||
if (!result) return
|
||||
|
||||
if (result.versions.length === 0) {
|
||||
console.error(`No versions found for ${color.bold(appName)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out current version for rollback candidates
|
||||
const candidates = result.versions.filter(v => v !== result.current)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
console.error('No previous versions to rollback to')
|
||||
return
|
||||
}
|
||||
|
||||
let targetVersion: string | undefined = version
|
||||
|
||||
if (!targetVersion) {
|
||||
// Show available versions and prompt
|
||||
console.log(`Available versions for ${color.bold(appName)}:\n`)
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const v = candidates[i]!
|
||||
const date = formatVersion(v)
|
||||
console.log(` ${color.cyan(String(i + 1))}. ${v} ${color.gray(date)}`)
|
||||
}
|
||||
|
||||
console.log()
|
||||
const answer = await prompt('Enter version number or name: ')
|
||||
|
||||
// Check if it's a number (index) or version name
|
||||
const index = parseInt(answer, 10)
|
||||
if (!isNaN(index) && index >= 1 && index <= candidates.length) {
|
||||
targetVersion = candidates[index - 1]!
|
||||
} else if (candidates.includes(answer)) {
|
||||
targetVersion = answer
|
||||
} else {
|
||||
console.error('Invalid selection')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate version exists (handles both user-provided and selected versions)
|
||||
if (!targetVersion || !result.versions.includes(targetVersion)) {
|
||||
console.error(`Version ${color.bold(targetVersion ?? 'unknown')} not found`)
|
||||
console.error(`Available versions: ${result.versions.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (targetVersion === result.current) {
|
||||
console.log(`Version ${color.bold(targetVersion)} is already active`)
|
||||
return
|
||||
}
|
||||
|
||||
const ok = await confirm(`Rollback ${color.bold(appName)} to version ${color.bold(targetVersion)}?`)
|
||||
if (!ok) return
|
||||
|
||||
console.log(`Rolling back to ${color.bold(targetVersion)}...`)
|
||||
|
||||
type ActivateResponse = { ok: boolean }
|
||||
const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${targetVersion}`)
|
||||
|
||||
if (!activateRes?.ok) {
|
||||
console.error('Failed to activate version')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(color.green(`✓ Rolled back to version ${targetVersion}`))
|
||||
}
|
||||
|
||||
const STASH_BASE = '/tmp/toes-stash'
|
||||
|
||||
export async function stashApp() {
|
||||
if (!isApp()) {
|
||||
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||
return
|
||||
}
|
||||
|
||||
const appName = getAppName()
|
||||
const diff = await getManifestDiff(appName)
|
||||
|
||||
if (diff === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const { changed, localOnly } = diff
|
||||
const toStash = [...changed, ...localOnly]
|
||||
|
||||
if (toStash.length === 0) {
|
||||
console.log('No local changes to stash')
|
||||
return
|
||||
}
|
||||
|
||||
const stashDir = join(STASH_BASE, appName)
|
||||
|
||||
// Check if stash already exists
|
||||
if (existsSync(stashDir)) {
|
||||
console.error('Stash already exists. Use `toes stash-pop` first.')
|
||||
return
|
||||
}
|
||||
|
||||
mkdirSync(stashDir, { recursive: true })
|
||||
|
||||
// Save stash metadata
|
||||
const metadata = {
|
||||
app: appName,
|
||||
timestamp: new Date().toISOString(),
|
||||
files: toStash,
|
||||
changed,
|
||||
localOnly,
|
||||
}
|
||||
writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2))
|
||||
|
||||
// Copy files to stash
|
||||
for (const file of toStash) {
|
||||
const srcPath = join(process.cwd(), file)
|
||||
const destPath = join(stashDir, 'files', file)
|
||||
const destDir = dirname(destPath)
|
||||
|
||||
if (!existsSync(destDir)) {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
const content = readFileSync(srcPath)
|
||||
writeFileSync(destPath, content)
|
||||
console.log(` ${color.yellow('→')} ${file}`)
|
||||
}
|
||||
|
||||
// Restore changed files from server
|
||||
if (changed.length > 0) {
|
||||
console.log(`\nRestoring ${changed.length} changed files from server...`)
|
||||
for (const file of changed) {
|
||||
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
||||
if (content) {
|
||||
writeFileSync(join(process.cwd(), file), content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete local-only files
|
||||
if (localOnly.length > 0) {
|
||||
console.log(`Removing ${localOnly.length} local-only files...`)
|
||||
for (const file of localOnly) {
|
||||
unlinkSync(join(process.cwd(), file))
|
||||
}
|
||||
}
|
||||
|
||||
console.log(color.green(`\n✓ Stashed ${toStash.length} file(s)`))
|
||||
}
|
||||
|
||||
export async function stashListApp() {
|
||||
if (!existsSync(STASH_BASE)) {
|
||||
console.log('No stashes')
|
||||
return
|
||||
}
|
||||
|
||||
const entries = readdirSync(STASH_BASE, { withFileTypes: true })
|
||||
const stashes = entries.filter(e => e.isDirectory())
|
||||
|
||||
if (stashes.length === 0) {
|
||||
console.log('No stashes')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Stashes:\n')
|
||||
for (const stash of stashes) {
|
||||
const metadataPath = join(STASH_BASE, stash.name, 'metadata.json')
|
||||
if (existsSync(metadataPath)) {
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as {
|
||||
timestamp: string
|
||||
files: string[]
|
||||
}
|
||||
const date = new Date(metadata.timestamp).toLocaleString()
|
||||
console.log(` ${color.bold(stash.name)} ${color.gray(date)} ${color.gray(`(${metadata.files.length} files)`)}`)
|
||||
} else {
|
||||
console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`)
|
||||
}
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
export async function stashPopApp() {
|
||||
if (!isApp()) {
|
||||
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||
return
|
||||
}
|
||||
|
||||
const appName = getAppName()
|
||||
const stashDir = join(STASH_BASE, appName)
|
||||
|
||||
if (!existsSync(stashDir)) {
|
||||
console.error(`No stash found for ${color.bold(appName)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Read metadata
|
||||
const metadataPath = join(stashDir, 'metadata.json')
|
||||
if (!existsSync(metadataPath)) {
|
||||
console.error('Invalid stash: missing metadata')
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as {
|
||||
app: string
|
||||
timestamp: string
|
||||
files: string[]
|
||||
changed: string[]
|
||||
localOnly: string[]
|
||||
}
|
||||
|
||||
console.log(`Restoring stash from ${new Date(metadata.timestamp).toLocaleString()}...\n`)
|
||||
|
||||
// Restore files from stash
|
||||
for (const file of metadata.files) {
|
||||
const srcPath = join(stashDir, 'files', file)
|
||||
const destPath = join(process.cwd(), file)
|
||||
const destDir = dirname(destPath)
|
||||
|
||||
if (!existsSync(srcPath)) {
|
||||
console.log(` ${color.red('✗')} ${file} (missing from stash)`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existsSync(destDir)) {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
const content = readFileSync(srcPath)
|
||||
writeFileSync(destPath, content)
|
||||
console.log(` ${color.green('←')} ${file}`)
|
||||
}
|
||||
|
||||
// Remove stash directory
|
||||
rmSync(stashDir, { recursive: true })
|
||||
|
||||
console.log(color.green(`\n✓ Restored ${metadata.files.length} file(s)`))
|
||||
}
|
||||
|
||||
export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) {
|
||||
if (!isApp()) {
|
||||
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||
return
|
||||
}
|
||||
|
||||
const appName = getAppName()
|
||||
const diff = await getManifestDiff(appName)
|
||||
|
||||
if (diff === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const { localOnly } = diff
|
||||
|
||||
if (localOnly.length === 0) {
|
||||
console.log('Nothing to clean')
|
||||
return
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('Would remove:')
|
||||
for (const file of localOnly) {
|
||||
console.log(` ${color.red('✗')} ${file}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!options.force) {
|
||||
console.log('Files not on server:')
|
||||
for (const file of localOnly) {
|
||||
console.log(` ${color.red('✗')} ${file}`)
|
||||
}
|
||||
console.log()
|
||||
const ok = await confirm(`Remove ${localOnly.length} file(s)?`)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
for (const file of localOnly) {
|
||||
const fullPath = join(process.cwd(), file)
|
||||
unlinkSync(fullPath)
|
||||
console.log(` ${color.red('✗')} ${file}`)
|
||||
}
|
||||
|
||||
console.log(color.green(`✓ Removed ${localOnly.length} file(s)`))
|
||||
}
|
||||
|
||||
interface VersionsResponse {
|
||||
current: string | null
|
||||
versions: string[]
|
||||
}
|
||||
|
||||
async function getVersions(appName: string): Promise<VersionsResponse | null> {
|
||||
try {
|
||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`))
|
||||
if (res.status === 404) {
|
||||
console.error(`App not found: ${appName}`)
|
||||
return null
|
||||
}
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
return await res.json()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatVersion(version: string): string {
|
||||
// Parse YYYYMMDD-HHMMSS format
|
||||
const match = version.match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/)
|
||||
if (!match) return ''
|
||||
|
||||
const date = new Date(
|
||||
parseInt(match[1]!, 10),
|
||||
parseInt(match[2]!, 10) - 1,
|
||||
parseInt(match[3]!, 10),
|
||||
parseInt(match[4]!, 10),
|
||||
parseInt(match[5]!, 10),
|
||||
parseInt(match[6]!, 10)
|
||||
)
|
||||
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
||||
const localManifest = generateManifest(process.cwd(), appName)
|
||||
const result = await getManifest(appName)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { program } from 'commander'
|
|||
|
||||
import color from 'kleur'
|
||||
import {
|
||||
cleanApp,
|
||||
configShow,
|
||||
diffApp,
|
||||
getApp,
|
||||
|
|
@ -15,10 +16,15 @@ import {
|
|||
renameApp,
|
||||
restartApp,
|
||||
rmApp,
|
||||
rollbackApp,
|
||||
stashApp,
|
||||
stashListApp,
|
||||
stashPopApp,
|
||||
startApp,
|
||||
statusApp,
|
||||
stopApp,
|
||||
syncApp,
|
||||
versionsApp,
|
||||
} from './commands'
|
||||
|
||||
program
|
||||
|
|
@ -141,6 +147,41 @@ program
|
|||
.description('Watch and sync changes bidirectionally')
|
||||
.action(syncApp)
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description('Remove local files not on server')
|
||||
.option('-f, --force', 'skip confirmation')
|
||||
.option('-n, --dry-run', 'show what would be removed')
|
||||
.action(cleanApp)
|
||||
|
||||
const stash = program
|
||||
.command('stash')
|
||||
.description('Stash local changes')
|
||||
.action(stashApp)
|
||||
|
||||
stash
|
||||
.command('pop')
|
||||
.description('Restore stashed changes')
|
||||
.action(stashPopApp)
|
||||
|
||||
stash
|
||||
.command('list')
|
||||
.description('List all stashes')
|
||||
.action(stashListApp)
|
||||
|
||||
program
|
||||
.command('versions')
|
||||
.description('List deployed versions')
|
||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||
.action(versionsApp)
|
||||
|
||||
program
|
||||
.command('rollback')
|
||||
.description('Rollback to a previous version')
|
||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
||||
.action((name, options) => rollbackApp(name, options.version))
|
||||
|
||||
program
|
||||
.command('rm')
|
||||
.description('Remove an app from the server')
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
||||
import { apps, setSelectedApp } from '../state'
|
||||
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
|
||||
import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles'
|
||||
|
||||
type TemplateType = 'ssr' | 'spa' | 'bare'
|
||||
|
||||
let newAppError = ''
|
||||
let newAppCreating = false
|
||||
let newAppError = ''
|
||||
let newAppName = ''
|
||||
let newAppTemplate: TemplateType = 'ssr'
|
||||
let newAppTool = false
|
||||
|
||||
async function createNewApp(input: HTMLInputElement) {
|
||||
const name = input.value.trim().toLowerCase().replace(/\s+/g, '-')
|
||||
async function createNewApp() {
|
||||
const name = newAppName.trim().toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
if (!name) {
|
||||
newAppError = 'App name is required'
|
||||
|
|
@ -34,7 +39,7 @@ async function createNewApp(input: HTMLInputElement) {
|
|||
const res = await fetch('/api/apps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
body: JSON.stringify({ name, template: newAppTemplate, tool: newAppTool }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
|
@ -54,14 +59,16 @@ async function createNewApp(input: HTMLInputElement) {
|
|||
}
|
||||
|
||||
export function openNewAppModal() {
|
||||
newAppError = ''
|
||||
newAppCreating = false
|
||||
newAppError = ''
|
||||
newAppName = ''
|
||||
newAppTemplate = 'ssr'
|
||||
newAppTool = false
|
||||
|
||||
openModal('New App', () => (
|
||||
<Form onSubmit={(e: Event) => {
|
||||
e.preventDefault()
|
||||
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
|
||||
createNewApp(input)
|
||||
createNewApp()
|
||||
}}>
|
||||
<FormField>
|
||||
<FormLabel for="app-name">App Name</FormLabel>
|
||||
|
|
@ -69,10 +76,39 @@ export function openNewAppModal() {
|
|||
id="app-name"
|
||||
type="text"
|
||||
placeholder="my-app"
|
||||
value={newAppName}
|
||||
onInput={(e: Event) => {
|
||||
newAppName = (e.target as HTMLInputElement).value
|
||||
}}
|
||||
autofocus
|
||||
/>
|
||||
{newAppError && <FormError>{newAppError}</FormError>}
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormLabel for="app-template">Template</FormLabel>
|
||||
<FormSelect
|
||||
id="app-template"
|
||||
onChange={(e: Event) => {
|
||||
newAppTemplate = (e.target as HTMLSelectElement).value as TemplateType
|
||||
}}
|
||||
>
|
||||
<option value="ssr" selected={newAppTemplate === 'ssr'}>SSR</option>
|
||||
<option value="spa" selected={newAppTemplate === 'spa'}>SPA</option>
|
||||
<option value="bare" selected={newAppTemplate === 'bare'}>Bare</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<FormCheckboxField>
|
||||
<FormCheckbox
|
||||
id="app-tool"
|
||||
type="checkbox"
|
||||
checked={newAppTool}
|
||||
onChange={(e: Event) => {
|
||||
newAppTool = (e.target as HTMLInputElement).checked
|
||||
rerenderModal()
|
||||
}}
|
||||
/>
|
||||
<FormCheckboxLabel for="app-tool">Tool</FormCheckboxLabel>
|
||||
</FormCheckboxField>
|
||||
<FormActions>
|
||||
<Button type="button" onClick={closeModal} disabled={newAppCreating}>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -51,3 +51,43 @@ export const FormActions = define('FormActions', {
|
|||
gap: 8,
|
||||
marginTop: 8,
|
||||
})
|
||||
|
||||
export const FormSelect = define('FormSelect', {
|
||||
base: 'select',
|
||||
padding: '8px 12px',
|
||||
background: theme('colors-bgSubtle'),
|
||||
border: `1px solid ${theme('colors-border')}`,
|
||||
borderRadius: theme('radius-md'),
|
||||
color: theme('colors-text'),
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
borderColor: theme('colors-primary'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const FormCheckbox = define('FormCheckbox', {
|
||||
base: 'input',
|
||||
width: 16,
|
||||
height: 16,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
accentColor: theme('colors-primary'),
|
||||
})
|
||||
|
||||
export const FormCheckboxField = define('FormCheckboxField', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
})
|
||||
|
||||
export const FormCheckboxLabel = define('FormCheckboxLabel', {
|
||||
base: 'label',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: theme('colors-text'),
|
||||
cursor: 'pointer',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { ActionBar, Button, NewAppButton } from './buttons'
|
||||
export { Form, FormActions, FormError, FormField, FormInput, FormLabel } from './forms'
|
||||
export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from './forms'
|
||||
export {
|
||||
AppItem,
|
||||
AppList,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ export type TemplateType = 'ssr' | 'bare' | 'spa'
|
|||
|
||||
export type AppTemplates = Record<string, string>
|
||||
|
||||
interface TemplateOptions {
|
||||
tool?: boolean
|
||||
}
|
||||
|
||||
interface TemplateVars {
|
||||
APP_EMOJI: string
|
||||
APP_NAME: string
|
||||
|
|
@ -32,7 +36,7 @@ function replaceVars(content: string, vars: TemplateVars): string {
|
|||
.replace(/\$\$APP_EMOJI\$\$/g, vars.APP_EMOJI)
|
||||
}
|
||||
|
||||
export function generateTemplates(appName: string, template: TemplateType = 'ssr'): AppTemplates {
|
||||
export function generateTemplates(appName: string, template: TemplateType = 'ssr', options: TemplateOptions = {}): AppTemplates {
|
||||
const vars: TemplateVars = {
|
||||
APP_EMOJI: DEFAULT_EMOJI,
|
||||
APP_NAME: appName,
|
||||
|
|
@ -43,8 +47,17 @@ export function generateTemplates(appName: string, template: TemplateType = 'ssr
|
|||
// Read shared files from templates/
|
||||
for (const filename of ['.npmrc', 'package.json', 'tsconfig.json']) {
|
||||
const path = join(TEMPLATES_DIR, filename)
|
||||
const content = readFileSync(path, 'utf-8')
|
||||
result[filename] = replaceVars(content, vars)
|
||||
let content = readFileSync(path, 'utf-8')
|
||||
content = replaceVars(content, vars)
|
||||
|
||||
// Add tool option to package.json if specified
|
||||
if (filename === 'package.json' && options.tool) {
|
||||
const pkg = JSON.parse(content)
|
||||
pkg.toes.tool = true
|
||||
content = JSON.stringify(pkg, null, 2) + '\n'
|
||||
}
|
||||
|
||||
result[filename] = content
|
||||
}
|
||||
|
||||
// Read template-specific files
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@ import type { App as BackendApp } from '$apps'
|
|||
import type { App as SharedApp } from '@types'
|
||||
import { generateTemplates, type TemplateType } from '%templates'
|
||||
import { Hype } from '@because/hype'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const timestamp = () => {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
const router = Hype.router()
|
||||
|
||||
// BackendApp -> SharedApp
|
||||
|
|
@ -55,7 +61,7 @@ router.get('/:app/logs', c => {
|
|||
})
|
||||
|
||||
router.post('/', async c => {
|
||||
let body: { name?: string, template?: TemplateType }
|
||||
let body: { name?: string, template?: TemplateType, tool?: boolean }
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
|
|
@ -75,11 +81,16 @@ router.post('/', async c => {
|
|||
}
|
||||
|
||||
const template = body.template ?? 'ssr'
|
||||
const templates = generateTemplates(name, template)
|
||||
const templates = generateTemplates(name, template, { tool: body.tool })
|
||||
|
||||
// Create directories and write files
|
||||
// Create versioned directory structure
|
||||
const ts = timestamp()
|
||||
const versionPath = join(appPath, ts)
|
||||
const currentPath = join(appPath, 'current')
|
||||
|
||||
// Create directories and write files into version directory
|
||||
for (const [filename, content] of Object.entries(templates)) {
|
||||
const fullPath = join(appPath, filename)
|
||||
const fullPath = join(versionPath, filename)
|
||||
const dir = dirname(fullPath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
|
@ -87,6 +98,9 @@ router.post('/', async c => {
|
|||
writeFileSync(fullPath, content)
|
||||
}
|
||||
|
||||
// Create current symlink
|
||||
symlinkSync(ts, currentPath)
|
||||
|
||||
return c.json({ ok: true, name })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,29 @@ const router = Hype.router()
|
|||
|
||||
router.get('/apps', c => c.json(allApps().map(a => a.name)))
|
||||
|
||||
router.get('/apps/:app/versions', c => {
|
||||
const appName = c.req.param('app')
|
||||
if (!appName) return c.json({ error: 'App not found' }, 404)
|
||||
|
||||
const appDir = safePath(APPS_DIR, appName)
|
||||
if (!appDir) return c.json({ error: 'Invalid path' }, 400)
|
||||
if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404)
|
||||
|
||||
const currentLink = join(appDir, 'current')
|
||||
const currentVersion = existsSync(currentLink)
|
||||
? realpathSync(currentLink).split('/').pop()
|
||||
: null
|
||||
|
||||
const entries = readdirSync(appDir, { withFileTypes: true })
|
||||
const versions = entries
|
||||
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
|
||||
.map(e => e.name)
|
||||
.sort()
|
||||
.reverse() // Newest first
|
||||
|
||||
return c.json({ versions, current: currentVersion })
|
||||
})
|
||||
|
||||
router.get('/apps/:app/manifest', c => {
|
||||
const appName = c.req.param('app')
|
||||
if (!appName) return c.json({ error: 'App not found' }, 404)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Subprocess } from 'bun'
|
|||
import { DEFAULT_EMOJI } from '@types'
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, watch, writeFileSync } from 'fs'
|
||||
import { join, resolve } from 'path'
|
||||
import { appLog, hostLog, setApps } from './tui'
|
||||
|
||||
export type { AppState } from '@types'
|
||||
|
||||
|
|
@ -222,14 +223,17 @@ const clearTimers = (app: App) => {
|
|||
}
|
||||
|
||||
const info = (app: App, ...msg: string[]) => {
|
||||
console.log('🐾', `[${app.name}]`, ...msg)
|
||||
appLog(app, ...msg)
|
||||
app.logs?.push({ time: Date.now(), text: msg.join(' ') })
|
||||
}
|
||||
|
||||
const isApp = (dir: string): boolean =>
|
||||
!loadApp(dir).error
|
||||
|
||||
const update = () => _listeners.forEach(cb => cb())
|
||||
const update = () => {
|
||||
setApps(allApps())
|
||||
_listeners.forEach(cb => cb())
|
||||
}
|
||||
|
||||
function allAppDirs() {
|
||||
return readdirSync(APPS_DIR, { withFileTypes: true })
|
||||
|
|
@ -246,6 +250,7 @@ function discoverApps() {
|
|||
const tool = pkg.toes?.tool
|
||||
_apps.set(dir, { name: dir, state, icon, error, tool })
|
||||
}
|
||||
update()
|
||||
}
|
||||
|
||||
function getPort(appName?: string): number {
|
||||
|
|
@ -282,15 +287,15 @@ async function gracefulShutdown(signal: string) {
|
|||
if (_shuttingDown) return
|
||||
_shuttingDown = true
|
||||
|
||||
console.log(`\n🐾 Received ${signal}, shutting down gracefully...`)
|
||||
hostLog(`Received ${signal}, shutting down gracefully...`)
|
||||
|
||||
const running = runningApps()
|
||||
if (running.length === 0) {
|
||||
console.log('🐾 No apps running, exiting.')
|
||||
hostLog('No apps running, exiting.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`🐾 Stopping ${running.length} app(s)...`)
|
||||
hostLog(`Stopping ${running.length} app(s)...`)
|
||||
|
||||
// Stop all running apps
|
||||
for (const app of running) {
|
||||
|
|
@ -308,14 +313,14 @@ async function gracefulShutdown(signal: string) {
|
|||
const stillRunning = runningApps()
|
||||
if (stillRunning.length === 0) {
|
||||
clearInterval(checkInterval)
|
||||
console.log('🐾 All apps stopped, exiting.')
|
||||
hostLog('All apps stopped, exiting.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (Date.now() - shutdownStart > SHUTDOWN_TIMEOUT) {
|
||||
clearInterval(checkInterval)
|
||||
console.log(`🐾 Shutdown timeout, forcing ${stillRunning.length} app(s) to stop...`)
|
||||
hostLog(`Shutdown timeout, forcing ${stillRunning.length} app(s) to stop...`)
|
||||
for (const app of stillRunning) {
|
||||
if (app.proc) {
|
||||
app.proc.kill(9) // SIGKILL
|
||||
|
|
@ -323,7 +328,7 @@ async function gracefulShutdown(signal: string) {
|
|||
}
|
||||
// Give a moment for SIGKILL to take effect
|
||||
setTimeout(() => {
|
||||
console.log('🐾 Forced shutdown complete, exiting.')
|
||||
hostLog('Forced shutdown complete, exiting.')
|
||||
process.exit(1)
|
||||
}, 500)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import appsRouter from './api/apps'
|
|||
import syncRouter from './api/sync'
|
||||
import { Hype } from '@because/hype'
|
||||
|
||||
const app = new Hype({ layout: false })
|
||||
const app = new Hype({ layout: false, logging: false })
|
||||
|
||||
app.route('/api/apps', appsRouter)
|
||||
app.route('/api/sync', syncRouter)
|
||||
|
|
@ -20,7 +20,6 @@ app.get('/tool/:tool', c => {
|
|||
return c.redirect(url)
|
||||
})
|
||||
|
||||
console.log('🐾 Toes!')
|
||||
initApps()
|
||||
|
||||
export default {
|
||||
|
|
|
|||
106
src/server/tui.ts
Normal file
106
src/server/tui.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { App } from '$apps'
|
||||
|
||||
const RENDER_DEBOUNCE = 50
|
||||
|
||||
let _apps: App[] = []
|
||||
let _enabled = process.stdout.isTTY ?? false
|
||||
let _lastRender = 0
|
||||
let _renderTimer: Timer | undefined
|
||||
let _showEmoji = false
|
||||
|
||||
export const setShowEmoji = (show: boolean) => {
|
||||
_showEmoji = show
|
||||
scheduleRender()
|
||||
}
|
||||
|
||||
export function appLog(app: App, ...msg: string[]) {
|
||||
if (!_enabled) {
|
||||
console.log('🐾', `[${app.name}]`, msg.join(' '))
|
||||
}
|
||||
}
|
||||
|
||||
export function hostLog(...msg: string[]) {
|
||||
if (!_enabled) {
|
||||
console.log('🐾', msg.join(' '))
|
||||
}
|
||||
}
|
||||
|
||||
export function setApps(apps: App[]) {
|
||||
_apps = apps
|
||||
scheduleRender()
|
||||
}
|
||||
|
||||
const formatStatus = (app: App): string => {
|
||||
switch (app.state) {
|
||||
case 'running':
|
||||
return '\x1b[32m●\x1b[0m'
|
||||
case 'starting':
|
||||
return '\x1b[33m◐\x1b[0m'
|
||||
case 'stopping':
|
||||
return '\x1b[33m◑\x1b[0m'
|
||||
case 'stopped':
|
||||
return '\x1b[90m○\x1b[0m'
|
||||
case 'invalid':
|
||||
return '\x1b[31m✕\x1b[0m'
|
||||
default:
|
||||
return '\x1b[90m?\x1b[0m'
|
||||
}
|
||||
}
|
||||
|
||||
const formatAppLine = (app: App): string => {
|
||||
const status = formatStatus(app)
|
||||
const port = app.port ? `\x1b[90m:${app.port}\x1b[0m` : ''
|
||||
|
||||
if (_showEmoji) {
|
||||
const icon = app.icon ?? '📦'
|
||||
return ` ${icon} ${app.name} ${status}${port}`
|
||||
} else {
|
||||
return ` ${status} ${app.name}${port}`
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleRender = () => {
|
||||
if (_renderTimer) return
|
||||
const elapsed = Date.now() - _lastRender
|
||||
const delay = Math.max(0, RENDER_DEBOUNCE - elapsed)
|
||||
_renderTimer = setTimeout(() => {
|
||||
_renderTimer = undefined
|
||||
render()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!_enabled) return
|
||||
_lastRender = Date.now()
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
// Clear screen and move cursor to top
|
||||
lines.push('\x1b[2J\x1b[H')
|
||||
|
||||
// Header
|
||||
lines.push('\x1b[1m🐾 Toes\x1b[0m')
|
||||
lines.push('')
|
||||
|
||||
// Apps section
|
||||
const regularApps = _apps.filter(a => !a.tool)
|
||||
const tools = _apps.filter(a => a.tool)
|
||||
|
||||
if (regularApps.length > 0) {
|
||||
lines.push('\x1b[1mApps\x1b[0m')
|
||||
for (const app of regularApps) {
|
||||
lines.push(formatAppLine(app))
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (tools.length > 0) {
|
||||
lines.push('\x1b[1mTools\x1b[0m')
|
||||
for (const app of tools) {
|
||||
lines.push(formatAppLine(app))
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join('\n'))
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user