Compare commits
No commits in common. "b8434ef2dfc0fc324aa5727c574977e98ae5ccad" and "68ebf1d7a7185e7a6f03105963106e2e0805902d" have entirely different histories.
b8434ef2df
...
68ebf1d7a7
6
TODO.txt
6
TODO.txt
|
|
@ -47,9 +47,5 @@
|
||||||
[x] list projects
|
[x] list projects
|
||||||
[x] start/stop/restart project
|
[x] start/stop/restart project
|
||||||
[x] create project
|
[x] create project
|
||||||
[x] todo.txt
|
[ ] 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.2",
|
"@because/hype": "^0.0.1",
|
||||||
"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.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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@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,9 +4,7 @@
|
||||||
"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": [
|
"files": ["src"],
|
||||||
"src"
|
|
||||||
],
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./tools": "./src/tools/index.ts"
|
"./tools": "./src/tools/index.ts"
|
||||||
|
|
@ -38,7 +36,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.1",
|
||||||
"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 { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
|
export { diffApp, getApp, pullApp, pushApp, statusApp, syncApp } 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}`)
|
||||||
|
|
@ -79,40 +79,17 @@ interface ListAppsOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listApps(options: ListAppsOptions) {
|
export async function listApps(options: ListAppsOptions) {
|
||||||
const allApps: App[] | undefined = await get('/api/apps')
|
const apps: App[] | undefined = await get('/api/apps')
|
||||||
if (!allApps) return
|
if (!apps) return
|
||||||
|
|
||||||
if (options.all) {
|
const filtered = apps.filter((app) => {
|
||||||
const apps = allApps.filter((app) => !app.tool)
|
if (options.all) return true
|
||||||
const tools = allApps.filter((app) => app.tool)
|
if (options.tools) return app.tool
|
||||||
|
return !app.tool
|
||||||
|
})
|
||||||
|
|
||||||
if (tools.length === 0) {
|
for (const app of filtered) {
|
||||||
// No tools, just list apps without header/indent
|
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
||||||
for (const app of apps) {
|
|
||||||
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (apps.length > 0) {
|
|
||||||
console.log('apps:')
|
|
||||||
for (const app of apps) {
|
|
||||||
console.log(` ${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
|
||||||
}
|
|
||||||
console.log()
|
|
||||||
}
|
|
||||||
console.log('tools:')
|
|
||||||
for (const tool of tools) {
|
|
||||||
console.log(` ${STATE_ICONS[tool.state] ?? '◯'} ${tool.name}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const filtered = allApps.filter((app) => {
|
|
||||||
if (options.tools) return app.tool
|
|
||||||
return !app.tool
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const app of filtered) {
|
|
||||||
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, readdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, 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 } from '../prompts'
|
||||||
import { getAppName, isApp, resolveAppName } from '../name'
|
import { getAppName, isApp } 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,364 +471,6 @@ 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,7 +2,6 @@ import { program } from 'commander'
|
||||||
|
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import {
|
import {
|
||||||
cleanApp,
|
|
||||||
configShow,
|
configShow,
|
||||||
diffApp,
|
diffApp,
|
||||||
getApp,
|
getApp,
|
||||||
|
|
@ -16,15 +15,10 @@ 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
|
||||||
|
|
@ -147,41 +141,6 @@ 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,17 +1,12 @@
|
||||||
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, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles'
|
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
|
||||||
|
|
||||||
type TemplateType = 'ssr' | 'spa' | 'bare'
|
|
||||||
|
|
||||||
let newAppCreating = false
|
|
||||||
let newAppError = ''
|
let newAppError = ''
|
||||||
let newAppName = ''
|
let newAppCreating = false
|
||||||
let newAppTemplate: TemplateType = 'ssr'
|
|
||||||
let newAppTool = false
|
|
||||||
|
|
||||||
async function createNewApp() {
|
async function createNewApp(input: HTMLInputElement) {
|
||||||
const name = newAppName.trim().toLowerCase().replace(/\s+/g, '-')
|
const name = input.value.trim().toLowerCase().replace(/\s+/g, '-')
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
newAppError = 'App name is required'
|
newAppError = 'App name is required'
|
||||||
|
|
@ -39,7 +34,7 @@ async function createNewApp() {
|
||||||
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, template: newAppTemplate, tool: newAppTool }),
|
body: JSON.stringify({ name }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -59,16 +54,14 @@ async function createNewApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openNewAppModal() {
|
export function openNewAppModal() {
|
||||||
newAppCreating = false
|
|
||||||
newAppError = ''
|
newAppError = ''
|
||||||
newAppName = ''
|
newAppCreating = false
|
||||||
newAppTemplate = 'ssr'
|
|
||||||
newAppTool = false
|
|
||||||
|
|
||||||
openModal('New App', () => (
|
openModal('New App', () => (
|
||||||
<Form onSubmit={(e: Event) => {
|
<Form onSubmit={(e: Event) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
createNewApp()
|
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
|
||||||
|
createNewApp(input)
|
||||||
}}>
|
}}>
|
||||||
<FormField>
|
<FormField>
|
||||||
<FormLabel for="app-name">App Name</FormLabel>
|
<FormLabel for="app-name">App Name</FormLabel>
|
||||||
|
|
@ -76,39 +69,10 @@ 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,43 +51,3 @@ 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, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from './forms'
|
export { Form, FormActions, FormError, FormField, FormInput, FormLabel } from './forms'
|
||||||
export {
|
export {
|
||||||
AppItem,
|
AppItem,
|
||||||
AppList,
|
AppList,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,6 @@ 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
|
||||||
|
|
@ -36,7 +32,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', options: TemplateOptions = {}): AppTemplates {
|
export function generateTemplates(appName: string, template: TemplateType = 'ssr'): AppTemplates {
|
||||||
const vars: TemplateVars = {
|
const vars: TemplateVars = {
|
||||||
APP_EMOJI: DEFAULT_EMOJI,
|
APP_EMOJI: DEFAULT_EMOJI,
|
||||||
APP_NAME: appName,
|
APP_NAME: appName,
|
||||||
|
|
@ -47,17 +43,8 @@ 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)
|
||||||
let content = readFileSync(path, 'utf-8')
|
const content = readFileSync(path, 'utf-8')
|
||||||
content = replaceVars(content, vars)
|
result[filename] = 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,15 +3,9 @@ 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, symlinkSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, 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
|
||||||
|
|
@ -61,7 +55,7 @@ router.get('/:app/logs', c => {
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/', async c => {
|
router.post('/', async c => {
|
||||||
let body: { name?: string, template?: TemplateType, tool?: boolean }
|
let body: { name?: string, template?: TemplateType }
|
||||||
try {
|
try {
|
||||||
body = await c.req.json()
|
body = await c.req.json()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -81,16 +75,11 @@ router.post('/', async c => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = body.template ?? 'ssr'
|
const template = body.template ?? 'ssr'
|
||||||
const templates = generateTemplates(name, template, { tool: body.tool })
|
const templates = generateTemplates(name, template)
|
||||||
|
|
||||||
// Create versioned directory structure
|
// Create directories and write files
|
||||||
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(versionPath, filename)
|
const fullPath = join(appPath, filename)
|
||||||
const dir = dirname(fullPath)
|
const dir = dirname(fullPath)
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true })
|
mkdirSync(dir, { recursive: true })
|
||||||
|
|
@ -98,9 +87,6 @@ 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,29 +27,6 @@ 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,7 +3,6 @@ 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'
|
||||||
|
|
||||||
|
|
@ -223,17 +222,14 @@ const clearTimers = (app: App) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = (app: App, ...msg: string[]) => {
|
const info = (app: App, ...msg: string[]) => {
|
||||||
appLog(app, ...msg)
|
console.log('🐾', `[${app.name}]`, ...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 = () => {
|
const update = () => _listeners.forEach(cb => cb())
|
||||||
setApps(allApps())
|
|
||||||
_listeners.forEach(cb => cb())
|
|
||||||
}
|
|
||||||
|
|
||||||
function allAppDirs() {
|
function allAppDirs() {
|
||||||
return readdirSync(APPS_DIR, { withFileTypes: true })
|
return readdirSync(APPS_DIR, { withFileTypes: true })
|
||||||
|
|
@ -250,7 +246,6 @@ 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 {
|
||||||
|
|
@ -287,15 +282,15 @@ async function gracefulShutdown(signal: string) {
|
||||||
if (_shuttingDown) return
|
if (_shuttingDown) return
|
||||||
_shuttingDown = true
|
_shuttingDown = true
|
||||||
|
|
||||||
hostLog(`Received ${signal}, shutting down gracefully...`)
|
console.log(`\n🐾 Received ${signal}, shutting down gracefully...`)
|
||||||
|
|
||||||
const running = runningApps()
|
const running = runningApps()
|
||||||
if (running.length === 0) {
|
if (running.length === 0) {
|
||||||
hostLog('No apps running, exiting.')
|
console.log('🐾 No apps running, exiting.')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
hostLog(`Stopping ${running.length} app(s)...`)
|
console.log(`🐾 Stopping ${running.length} app(s)...`)
|
||||||
|
|
||||||
// Stop all running apps
|
// Stop all running apps
|
||||||
for (const app of running) {
|
for (const app of running) {
|
||||||
|
|
@ -313,14 +308,14 @@ async function gracefulShutdown(signal: string) {
|
||||||
const stillRunning = runningApps()
|
const stillRunning = runningApps()
|
||||||
if (stillRunning.length === 0) {
|
if (stillRunning.length === 0) {
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
hostLog('All apps stopped, exiting.')
|
console.log('🐾 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)
|
||||||
hostLog(`Shutdown timeout, forcing ${stillRunning.length} app(s) to stop...`)
|
console.log(`🐾 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
|
||||||
|
|
@ -328,7 +323,7 @@ async function gracefulShutdown(signal: string) {
|
||||||
}
|
}
|
||||||
// Give a moment for SIGKILL to take effect
|
// Give a moment for SIGKILL to take effect
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
hostLog('Forced shutdown complete, exiting.')
|
console.log('🐾 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, logging: false })
|
const app = new Hype({ layout: false })
|
||||||
|
|
||||||
app.route('/api/apps', appsRouter)
|
app.route('/api/apps', appsRouter)
|
||||||
app.route('/api/sync', syncRouter)
|
app.route('/api/sync', syncRouter)
|
||||||
|
|
@ -20,6 +20,7 @@ app.get('/tool/:tool', c => {
|
||||||
return c.redirect(url)
|
return c.redirect(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('🐾 Toes!')
|
||||||
initApps()
|
initApps()
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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, indent: boolean): string => {
|
|
||||||
const status = formatStatus(app)
|
|
||||||
const port = app.port ? `\x1b[90m:${app.port}\x1b[0m` : ''
|
|
||||||
const prefix = indent ? ' ' : ''
|
|
||||||
|
|
||||||
if (_showEmoji) {
|
|
||||||
const icon = app.icon ?? '📦'
|
|
||||||
return `${prefix}${icon} ${app.name} ${status}${port}`
|
|
||||||
} else {
|
|
||||||
return `${prefix}${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 (tools.length === 0) {
|
|
||||||
// No tools, just list apps without header/indent
|
|
||||||
for (const app of regularApps) {
|
|
||||||
lines.push(formatAppLine(app, false))
|
|
||||||
}
|
|
||||||
lines.push('')
|
|
||||||
} else {
|
|
||||||
if (regularApps.length > 0) {
|
|
||||||
lines.push('\x1b[1mApps\x1b[0m')
|
|
||||||
for (const app of regularApps) {
|
|
||||||
lines.push(formatAppLine(app, true))
|
|
||||||
}
|
|
||||||
lines.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('\x1b[1mTools\x1b[0m')
|
|
||||||
for (const app of tools) {
|
|
||||||
lines.push(formatAppLine(app, true))
|
|
||||||
}
|
|
||||||
lines.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write(lines.join('\n'))
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user