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] list projects
|
||||||
[x] start/stop/restart project
|
[x] start/stop/restart project
|
||||||
[x] create 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",
|
"name": "toes",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.1",
|
"@because/hype": "^0.0.2",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
"packages": {
|
"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/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=="],
|
"@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",
|
"description": "personal web appliance - turn it on and forget about the cloud",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": ["src"],
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./tools": "./src/tools/index.ts"
|
"./tools": "./src/tools/index.ts"
|
||||||
|
|
@ -36,7 +38,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.1",
|
"@because/hype": "^0.0.2",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,4 @@ export {
|
||||||
startApp,
|
startApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
} from './manage'
|
} 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] ?? '◯'
|
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}`)
|
console.log(` State: ${app.state}`)
|
||||||
if (app.port) {
|
if (app.port) {
|
||||||
console.log(` Port: ${app.port}`)
|
console.log(` Port: ${app.port}`)
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import { loadGitignore } from '@gitignore'
|
||||||
import { computeHash, 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, 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 { 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 } from '../prompts'
|
import { confirm, prompt } from '../prompts'
|
||||||
import { getAppName, isApp } from '../name'
|
import { getAppName, isApp, resolveAppName } from '../name'
|
||||||
|
|
||||||
interface ManifestDiff {
|
interface ManifestDiff {
|
||||||
changed: string[]
|
changed: string[]
|
||||||
|
|
@ -236,7 +236,7 @@ export async function diffApp() {
|
||||||
const { changed, localOnly, remoteOnly } = diff
|
const { changed, localOnly, remoteOnly } = diff
|
||||||
|
|
||||||
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) {
|
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) {
|
||||||
console.log(color.green('✓ No differences'))
|
// console.log(color.green('✓ No differences'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,7 +329,7 @@ export async function statusApp() {
|
||||||
const hasLocalChanges = toPush.length > 0
|
const hasLocalChanges = toPush.length > 0
|
||||||
|
|
||||||
// Display status
|
// Display status
|
||||||
console.log(`Status for ${color.bold(appName)}:\n`)
|
// console.log(`Status for ${color.bold(appName)}:\n`)
|
||||||
|
|
||||||
if (!remoteManifest) {
|
if (!remoteManifest) {
|
||||||
console.log(color.yellow('App does not exist on server'))
|
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> {
|
async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
||||||
const localManifest = generateManifest(process.cwd(), appName)
|
const localManifest = generateManifest(process.cwd(), appName)
|
||||||
const result = await getManifest(appName)
|
const result = await getManifest(appName)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { program } from 'commander'
|
||||||
|
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import {
|
import {
|
||||||
|
cleanApp,
|
||||||
configShow,
|
configShow,
|
||||||
diffApp,
|
diffApp,
|
||||||
getApp,
|
getApp,
|
||||||
|
|
@ -15,10 +16,15 @@ import {
|
||||||
renameApp,
|
renameApp,
|
||||||
restartApp,
|
restartApp,
|
||||||
rmApp,
|
rmApp,
|
||||||
|
rollbackApp,
|
||||||
|
stashApp,
|
||||||
|
stashListApp,
|
||||||
|
stashPopApp,
|
||||||
startApp,
|
startApp,
|
||||||
statusApp,
|
statusApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
syncApp,
|
syncApp,
|
||||||
|
versionsApp,
|
||||||
} from './commands'
|
} from './commands'
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
@ -141,6 +147,41 @@ program
|
||||||
.description('Watch and sync changes bidirectionally')
|
.description('Watch and sync changes bidirectionally')
|
||||||
.action(syncApp)
|
.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
|
program
|
||||||
.command('rm')
|
.command('rm')
|
||||||
.description('Remove an app from the server')
|
.description('Remove an app from the server')
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
||||||
import { apps, setSelectedApp } from '../state'
|
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 newAppCreating = false
|
||||||
|
let newAppError = ''
|
||||||
|
let newAppName = ''
|
||||||
|
let newAppTemplate: TemplateType = 'ssr'
|
||||||
|
let newAppTool = false
|
||||||
|
|
||||||
async function createNewApp(input: HTMLInputElement) {
|
async function createNewApp() {
|
||||||
const name = input.value.trim().toLowerCase().replace(/\s+/g, '-')
|
const name = newAppName.trim().toLowerCase().replace(/\s+/g, '-')
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
newAppError = 'App name is required'
|
newAppError = 'App name is required'
|
||||||
|
|
@ -34,7 +39,7 @@ async function createNewApp(input: HTMLInputElement) {
|
||||||
const res = await fetch('/api/apps', {
|
const res = await fetch('/api/apps', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name, template: newAppTemplate, tool: newAppTool }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -54,14 +59,16 @@ async function createNewApp(input: HTMLInputElement) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openNewAppModal() {
|
export function openNewAppModal() {
|
||||||
newAppError = ''
|
|
||||||
newAppCreating = false
|
newAppCreating = false
|
||||||
|
newAppError = ''
|
||||||
|
newAppName = ''
|
||||||
|
newAppTemplate = 'ssr'
|
||||||
|
newAppTool = false
|
||||||
|
|
||||||
openModal('New App', () => (
|
openModal('New App', () => (
|
||||||
<Form onSubmit={(e: Event) => {
|
<Form onSubmit={(e: Event) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
|
createNewApp()
|
||||||
createNewApp(input)
|
|
||||||
}}>
|
}}>
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel for="app-name">App Name</FormLabel>
|
<FormLabel for="app-name">App Name</FormLabel>
|
||||||
|
|
@ -69,10 +76,39 @@ export function openNewAppModal() {
|
||||||
id="app-name"
|
id="app-name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="my-app"
|
placeholder="my-app"
|
||||||
|
value={newAppName}
|
||||||
|
onInput={(e: Event) => {
|
||||||
|
newAppName = (e.target as HTMLInputElement).value
|
||||||
|
}}
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
{newAppError && <FormError>{newAppError}</FormError>}
|
{newAppError && <FormError>{newAppError}</FormError>}
|
||||||
</FormField>
|
</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>
|
<FormActions>
|
||||||
<Button type="button" onClick={closeModal} disabled={newAppCreating}>
|
<Button type="button" onClick={closeModal} disabled={newAppCreating}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,43 @@ export const FormActions = define('FormActions', {
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginTop: 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 { 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 {
|
export {
|
||||||
AppItem,
|
AppItem,
|
||||||
AppList,
|
AppList,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ export type TemplateType = 'ssr' | 'bare' | 'spa'
|
||||||
|
|
||||||
export type AppTemplates = Record<string, string>
|
export type AppTemplates = Record<string, string>
|
||||||
|
|
||||||
|
interface TemplateOptions {
|
||||||
|
tool?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface TemplateVars {
|
interface TemplateVars {
|
||||||
APP_EMOJI: string
|
APP_EMOJI: string
|
||||||
APP_NAME: string
|
APP_NAME: string
|
||||||
|
|
@ -32,7 +36,7 @@ function replaceVars(content: string, vars: TemplateVars): string {
|
||||||
.replace(/\$\$APP_EMOJI\$\$/g, vars.APP_EMOJI)
|
.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 = {
|
const vars: TemplateVars = {
|
||||||
APP_EMOJI: DEFAULT_EMOJI,
|
APP_EMOJI: DEFAULT_EMOJI,
|
||||||
APP_NAME: appName,
|
APP_NAME: appName,
|
||||||
|
|
@ -43,8 +47,17 @@ export function generateTemplates(appName: string, template: TemplateType = 'ssr
|
||||||
// Read shared files from templates/
|
// Read shared files from templates/
|
||||||
for (const filename of ['.npmrc', 'package.json', 'tsconfig.json']) {
|
for (const filename of ['.npmrc', 'package.json', 'tsconfig.json']) {
|
||||||
const path = join(TEMPLATES_DIR, filename)
|
const path = join(TEMPLATES_DIR, filename)
|
||||||
const content = readFileSync(path, 'utf-8')
|
let content = readFileSync(path, 'utf-8')
|
||||||
result[filename] = replaceVars(content, vars)
|
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
|
// Read template-specific files
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,15 @@ import type { App as BackendApp } from '$apps'
|
||||||
import type { App as SharedApp } from '@types'
|
import type { App as SharedApp } from '@types'
|
||||||
import { generateTemplates, type TemplateType } from '%templates'
|
import { generateTemplates, type TemplateType } from '%templates'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'fs'
|
||||||
import { dirname, join } from 'path'
|
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()
|
const router = Hype.router()
|
||||||
|
|
||||||
// BackendApp -> SharedApp
|
// BackendApp -> SharedApp
|
||||||
|
|
@ -55,7 +61,7 @@ router.get('/:app/logs', c => {
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/', async c => {
|
router.post('/', async c => {
|
||||||
let body: { name?: string, template?: TemplateType }
|
let body: { name?: string, template?: TemplateType, tool?: boolean }
|
||||||
try {
|
try {
|
||||||
body = await c.req.json()
|
body = await c.req.json()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -75,11 +81,16 @@ router.post('/', async c => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = body.template ?? 'ssr'
|
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)) {
|
for (const [filename, content] of Object.entries(templates)) {
|
||||||
const fullPath = join(appPath, filename)
|
const fullPath = join(versionPath, filename)
|
||||||
const dir = dirname(fullPath)
|
const dir = dirname(fullPath)
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true })
|
mkdirSync(dir, { recursive: true })
|
||||||
|
|
@ -87,6 +98,9 @@ router.post('/', async c => {
|
||||||
writeFileSync(fullPath, content)
|
writeFileSync(fullPath, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create current symlink
|
||||||
|
symlinkSync(ts, currentPath)
|
||||||
|
|
||||||
return c.json({ ok: true, name })
|
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', 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 => {
|
router.get('/apps/:app/manifest', c => {
|
||||||
const appName = c.req.param('app')
|
const appName = c.req.param('app')
|
||||||
if (!appName) return c.json({ error: 'App not found' }, 404)
|
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 { DEFAULT_EMOJI } from '@types'
|
||||||
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, watch, writeFileSync } from 'fs'
|
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, watch, writeFileSync } from 'fs'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
|
import { appLog, hostLog, setApps } from './tui'
|
||||||
|
|
||||||
export type { AppState } from '@types'
|
export type { AppState } from '@types'
|
||||||
|
|
||||||
|
|
@ -222,14 +223,17 @@ const clearTimers = (app: App) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = (app: App, ...msg: string[]) => {
|
const info = (app: App, ...msg: string[]) => {
|
||||||
console.log('🐾', `[${app.name}]`, ...msg)
|
appLog(app, ...msg)
|
||||||
app.logs?.push({ time: Date.now(), text: msg.join(' ') })
|
app.logs?.push({ time: Date.now(), text: msg.join(' ') })
|
||||||
}
|
}
|
||||||
|
|
||||||
const isApp = (dir: string): boolean =>
|
const isApp = (dir: string): boolean =>
|
||||||
!loadApp(dir).error
|
!loadApp(dir).error
|
||||||
|
|
||||||
const update = () => _listeners.forEach(cb => cb())
|
const update = () => {
|
||||||
|
setApps(allApps())
|
||||||
|
_listeners.forEach(cb => cb())
|
||||||
|
}
|
||||||
|
|
||||||
function allAppDirs() {
|
function allAppDirs() {
|
||||||
return readdirSync(APPS_DIR, { withFileTypes: true })
|
return readdirSync(APPS_DIR, { withFileTypes: true })
|
||||||
|
|
@ -246,6 +250,7 @@ function discoverApps() {
|
||||||
const tool = pkg.toes?.tool
|
const tool = pkg.toes?.tool
|
||||||
_apps.set(dir, { name: dir, state, icon, error, tool })
|
_apps.set(dir, { name: dir, state, icon, error, tool })
|
||||||
}
|
}
|
||||||
|
update()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPort(appName?: string): number {
|
function getPort(appName?: string): number {
|
||||||
|
|
@ -282,15 +287,15 @@ async function gracefulShutdown(signal: string) {
|
||||||
if (_shuttingDown) return
|
if (_shuttingDown) return
|
||||||
_shuttingDown = true
|
_shuttingDown = true
|
||||||
|
|
||||||
console.log(`\n🐾 Received ${signal}, shutting down gracefully...`)
|
hostLog(`Received ${signal}, shutting down gracefully...`)
|
||||||
|
|
||||||
const running = runningApps()
|
const running = runningApps()
|
||||||
if (running.length === 0) {
|
if (running.length === 0) {
|
||||||
console.log('🐾 No apps running, exiting.')
|
hostLog('No apps running, exiting.')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🐾 Stopping ${running.length} app(s)...`)
|
hostLog(`Stopping ${running.length} app(s)...`)
|
||||||
|
|
||||||
// Stop all running apps
|
// Stop all running apps
|
||||||
for (const app of running) {
|
for (const app of running) {
|
||||||
|
|
@ -308,14 +313,14 @@ async function gracefulShutdown(signal: string) {
|
||||||
const stillRunning = runningApps()
|
const stillRunning = runningApps()
|
||||||
if (stillRunning.length === 0) {
|
if (stillRunning.length === 0) {
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
console.log('🐾 All apps stopped, exiting.')
|
hostLog('All apps stopped, exiting.')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for timeout
|
// Check for timeout
|
||||||
if (Date.now() - shutdownStart > SHUTDOWN_TIMEOUT) {
|
if (Date.now() - shutdownStart > SHUTDOWN_TIMEOUT) {
|
||||||
clearInterval(checkInterval)
|
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) {
|
for (const app of stillRunning) {
|
||||||
if (app.proc) {
|
if (app.proc) {
|
||||||
app.proc.kill(9) // SIGKILL
|
app.proc.kill(9) // SIGKILL
|
||||||
|
|
@ -323,7 +328,7 @@ async function gracefulShutdown(signal: string) {
|
||||||
}
|
}
|
||||||
// Give a moment for SIGKILL to take effect
|
// Give a moment for SIGKILL to take effect
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('🐾 Forced shutdown complete, exiting.')
|
hostLog('Forced shutdown complete, exiting.')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import appsRouter from './api/apps'
|
||||||
import syncRouter from './api/sync'
|
import syncRouter from './api/sync'
|
||||||
import { Hype } from '@because/hype'
|
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/apps', appsRouter)
|
||||||
app.route('/api/sync', syncRouter)
|
app.route('/api/sync', syncRouter)
|
||||||
|
|
@ -20,7 +20,6 @@ app.get('/tool/:tool', c => {
|
||||||
return c.redirect(url)
|
return c.redirect(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('🐾 Toes!')
|
|
||||||
initApps()
|
initApps()
|
||||||
|
|
||||||
export default {
|
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