Compare commits

..

No commits in common. "b8434ef2dfc0fc324aa5727c574977e98ae5ccad" and "68ebf1d7a7185e7a6f03105963106e2e0805902d" have entirely different histories.

16 changed files with 49 additions and 720 deletions

View File

@ -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
[ ] ... [ ] ...

View File

@ -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=="],

View File

@ -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"

View File

@ -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'

View File

@ -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,33 +79,11 @@ 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 (tools.length === 0) {
// No tools, just list apps without header/indent
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 if (options.tools) return app.tool
return !app.tool return !app.tool
}) })
@ -113,7 +91,6 @@ export async function listApps(options: ListAppsOptions) {
for (const app of filtered) { for (const app of filtered) {
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`) console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
} }
}
} }
interface NewAppOptions { interface NewAppOptions {

View File

@ -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)

View File

@ -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')

View File

@ -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

View File

@ -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',
})

View File

@ -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,

View File

@ -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

View File

@ -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 })
}) })

View File

@ -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)

View File

@ -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)
} }

View File

@ -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 {

View File

@ -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'))
}