Compare commits

..

No commits in common. "c081785d373008d7ec169834927a29a8f4ee16d2" and "f5c5102fc89ce7f5c8b0e5822d2b953fd413edbe" have entirely different histories.

12 changed files with 322 additions and 53 deletions

View File

@ -22,7 +22,7 @@ This will:
1. Install system dependencies (git, fish shell, networking tools) 1. Install system dependencies (git, fish shell, networking tools)
2. Install Bun and grant it network binding capabilities 2. Install Bun and grant it network binding capabilities
3. Clone and build the toes server 3. Clone and build the toes server
4. Set up bundled apps (clock, code, cron, env, stats) 4. Set up bundled apps (clock, code, cron, env, stats, versions)
5. Install and enable a systemd service for auto-start 5. Install and enable a systemd service for auto-start
Once complete, visit `http://<hostname>.local` on your local network. Once complete, visit `http://<hostname>.local` on your local network.

View File

@ -3,17 +3,11 @@ import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools' import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, stat } from 'fs/promises' import { readdir, stat } from 'fs/promises'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { join, resolve, extname, basename } from 'path' import { join, extname, basename } from 'path'
import type { Child } from 'hono/jsx' import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR! const APPS_DIR = process.env.APPS_DIR!
const safePath = (base: string, ...segments: string[]) => {
const full = resolve(base, ...segments)
if (!full.startsWith(base + '/') && full !== base) return null
return full
}
const app = new Hype({ prettyHTML: false }) const app = new Hype({ prettyHTML: false })
const Container = define('Container', { const Container = define('Container', {
@ -263,15 +257,21 @@ const fileMemoryScript = `
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
var app = params.get('app'); var app = params.get('app');
var file = params.get('file'); var file = params.get('file');
var version = params.get('version') || 'current';
if (!app) return; if (!app) return;
var key = 'code-app:' + app + ':file'; var key = 'code-app:' + app + ':' + version + ':file';
if (params.has('file')) { if (params.has('file')) {
// Explicit file param (even empty) - save it
if (file) localStorage.setItem(key, file); if (file) localStorage.setItem(key, file);
else localStorage.removeItem(key); else localStorage.removeItem(key);
} else { } else {
// No file param - restore saved location
var saved = localStorage.getItem(key); var saved = localStorage.getItem(key);
if (saved) { if (saved) {
window.location.replace('/?app=' + encodeURIComponent(app) + '&file=' + encodeURIComponent(saved)); var url = '/?app=' + encodeURIComponent(app);
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
url += '&file=' + encodeURIComponent(saved);
window.location.replace(url);
} }
} }
})(); })();
@ -327,14 +327,14 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
app.get('/raw', async c => { app.get('/raw', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') const filePath = c.req.query('file')
if (!appName || !filePath) { if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400) return c.text('Missing app or file parameter', 400)
} }
const fullPath = safePath(APPS_DIR, appName, filePath) const fullPath = join(APPS_DIR, appName, version, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const file = Bun.file(fullPath) const file = Bun.file(fullPath)
if (!await file.exists()) { if (!await file.exists()) {
@ -346,14 +346,14 @@ app.get('/raw', async c => {
app.post('/save', async c => { app.post('/save', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') const filePath = c.req.query('file')
if (!appName || !filePath) { if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400) return c.text('Missing app or file parameter', 400)
} }
const fullPath = safePath(APPS_DIR, appName, filePath) const fullPath = join(APPS_DIR, appName, version, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const content = await c.req.text() const content = await c.req.text()
try { try {
@ -385,9 +385,10 @@ async function listFiles(appPath: string, subPath: string = '') {
interface BreadcrumbProps { interface BreadcrumbProps {
appName: string appName: string
filePath: string filePath: string
versionParam: string
} }
function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) { function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
const parts = filePath ? filePath.split('/').filter(Boolean) : [] const parts = filePath ? filePath.split('/').filter(Boolean) : []
const crumbs: { name: string; path: string }[] = [] const crumbs: { name: string; path: string }[] = []
@ -400,7 +401,7 @@ function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
return ( return (
<Breadcrumb> <Breadcrumb>
{crumbs.length > 0 ? ( {crumbs.length > 0 ? (
<BreadcrumbLink href={`/?app=${appName}&file=`}>{appName}</BreadcrumbLink> <BreadcrumbLink href={`/?app=${appName}${versionParam}&file=`}>{appName}</BreadcrumbLink>
) : ( ) : (
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent> <BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
)} )}
@ -410,7 +411,7 @@ function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
{i === crumbs.length - 1 ? ( {i === crumbs.length - 1 ? (
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent> <BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
) : ( ) : (
<BreadcrumbLink href={`/?app=${appName}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink> <BreadcrumbLink href={`/?app=${appName}${versionParam}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
)} )}
</> </>
))} ))}
@ -478,6 +479,7 @@ function getPrismLanguage(filename: string): string {
app.get('/', async c => { app.get('/', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') || '' const filePath = c.req.query('file') || ''
if (!appName) { if (!appName) {
@ -488,34 +490,19 @@ app.get('/', async c => {
) )
} }
const appPath = safePath(APPS_DIR, appName) const appPath = join(APPS_DIR, appName, version)
if (!appPath) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Invalid app name</ErrorBox>
</Layout>
)
}
try { try {
await stat(appPath) await stat(appPath)
} catch { } catch {
return c.html( return c.html(
<Layout title="Code Browser"> <Layout title="Code Browser">
<ErrorBox>App "{appName}" not found</ErrorBox> <ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
</Layout>
)
}
const fullPath = safePath(appPath, filePath)
if (!fullPath) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Invalid file path</ErrorBox>
</Layout> </Layout>
) )
} }
const fullPath = join(appPath, filePath)
let fileStats let fileStats
try { try {
@ -528,16 +515,18 @@ app.get('/', async c => {
) )
} }
const versionParam = version !== 'current' ? `&version=${version}` : ''
if (fileStats.isFile()) { if (fileStats.isFile()) {
const filename = basename(fullPath) const filename = basename(fullPath)
const fileType = getFileType(filename) const fileType = getFileType(filename)
const rawUrl = `/raw?app=${appName}&file=${filePath}` const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
const downloadUrl = `${rawUrl}&download=1` const downloadUrl = `${rawUrl}&download=1`
if (fileType === 'image') { if (fileType === 'image') {
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`}> <Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<MediaContainer> <MediaContainer>
<MediaHeader> <MediaHeader>
<span>{filename}</span> <span>{filename}</span>
@ -554,7 +543,7 @@ app.get('/', async c => {
if (fileType === 'audio') { if (fileType === 'audio') {
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`}> <Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<MediaContainer> <MediaContainer>
<MediaHeader> <MediaHeader>
<span>{filename}</span> <span>{filename}</span>
@ -571,7 +560,7 @@ app.get('/', async c => {
if (fileType === 'video') { if (fileType === 'video') {
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`}> <Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<MediaContainer> <MediaContainer>
<MediaHeader> <MediaHeader>
<span>{filename}</span> <span>{filename}</span>
@ -626,7 +615,7 @@ saveBtn.onclick = async () => {
status.textContent = 'Saving...'; status.textContent = 'Saving...';
try { try {
const res = await fetch('/save?app=${appName}&file=${filePath}', { const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'text/plain' },
body: jar.toString() body: jar.toString()
@ -652,14 +641,14 @@ document.addEventListener('keydown', (e) => {
` `
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`} editable> <Layout title={`${appName}/${filePath}`} editable>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<CodeBlock> <CodeBlock>
<CodeHeader> <CodeHeader>
<span>{filename}</span> <span>{filename}</span>
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
<span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span> <span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span>
<EditButton id="save-btn">Save</EditButton> <EditButton id="save-btn">Save</EditButton>
<EditLink href={`/?app=${appName}&file=${filePath}`}>Done</EditLink> <EditLink href={`/?app=${appName}${versionParam}&file=${filePath}`}>Done</EditLink>
</div> </div>
</CodeHeader> </CodeHeader>
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre> <pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
@ -671,11 +660,11 @@ document.addEventListener('keydown', (e) => {
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`} highlight> <Layout title={`${appName}/${filePath}`} highlight>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<CodeBlock> <CodeBlock>
<CodeHeader> <CodeHeader>
<span>{filename}</span> <span>{filename}</span>
<EditLink href={`/?app=${appName}&file=${filePath}&edit=1`}>Edit</EditLink> <EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
</CodeHeader> </CodeHeader>
<pre><code class={`language-${language}`}>{content}</code></pre> <pre><code class={`language-${language}`}>{content}</code></pre>
</CodeBlock> </CodeBlock>
@ -687,11 +676,11 @@ document.addEventListener('keydown', (e) => {
return c.html( return c.html(
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}> <Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<FileList> <FileList>
{files.map(file => ( {files.map(file => (
<FileItem> <FileItem>
<FileLink href={`/?app=${appName}&file=${file.path}`}> <FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />} {file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
<span>{file.name}</span> <span>{file.name}</span>
</FileLink> </FileLink>

View File

@ -15,7 +15,7 @@ const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false }) const app = new Hype({ prettyHTML: false })
// Styles // Styles (follow versions tool pattern)
const Container = define('Container', { const Container = define('Container', {
fontFamily: theme('fonts-sans'), fontFamily: theme('fonts-sans'),
padding: '20px', padding: '20px',
@ -572,7 +572,7 @@ app.post('/new', async c => {
return c.redirect('/new?error=invalid-name') return c.redirect('/new?error=invalid-name')
} }
const cronDir = join(APPS_DIR, appName, 'cron') const cronDir = join(APPS_DIR, appName, 'current', 'cron')
const filePath = join(cronDir, `${name}.ts`) const filePath = join(cronDir, `${name}.ts`)
// Check if file already exists // Check if file already exists

View File

@ -13,7 +13,8 @@ export async function getApps(): Promise<string[]> {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue if (!entry.isDirectory()) continue
if (existsSync(join(APPS_DIR, entry.name, 'package.json'))) { // Check if it has a current symlink (valid app)
if (existsSync(join(APPS_DIR, entry.name, 'current'))) {
apps.push(entry.name) apps.push(entry.name)
} }
} }
@ -34,7 +35,7 @@ export async function discoverCronJobs(): Promise<DiscoveryResult> {
for (const app of apps) { for (const app of apps) {
if (!app.isDirectory()) continue if (!app.isDirectory()) continue
const cronDir = join(APPS_DIR, app.name, 'cron') const cronDir = join(APPS_DIR, app.name, 'current', 'cron')
if (!existsSync(cronDir)) continue if (!existsSync(cronDir)) continue
const files = await readdir(cronDir) const files = await readdir(cronDir)

View File

@ -37,7 +37,7 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
job.lastDuration = undefined job.lastDuration = undefined
onUpdate() onUpdate()
const cwd = join(APPS_DIR, job.app) const cwd = join(APPS_DIR, job.app, 'current')
forwardLog(job.app, `[cron] Running ${job.name}`) forwardLog(job.app, `[cron] Running ${job.name}`)

1
apps/versions/.npmrc Normal file
View File

@ -0,0 +1 @@
registry=https://npm.nose.space

45
apps/versions/bun.lock Normal file
View File

@ -0,0 +1,45 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "versions",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
"@because/hype": ["@because/hype@0.0.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/toes": ["@because/toes@0.0.5", "https://npm.nose.space/@because/toes/-/toes-0.0.5.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-YM1VuR1sym7m7pFcaiqnjg6eJUyhJYUH2ROBb+xi+HEXajq46ZL8KDyyCtz7WiHTfrbxcEWGjqyj20a7UppcJg=="],
"@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/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

177
apps/versions/index.tsx Normal file
View File

@ -0,0 +1,177 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, readlink, stat } from 'fs/promises'
import { join } from 'path'
import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR!
const TOES_URL = process.env.TOES_URL!
const app = new Hype({ prettyHTML: false })
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
const VersionList = define('VersionList', {
listStyle: 'none',
padding: 0,
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const VersionItem = define('VersionItem', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
states: {
':last-child': {
borderBottom: 'none',
},
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const VersionLink = define('VersionLink', {
base: 'a',
textDecoration: 'none',
color: theme('colors-link'),
fontFamily: theme('fonts-mono'),
fontSize: '15px',
cursor: 'pointer',
states: {
':hover': {
textDecoration: 'underline',
},
},
})
const Badge = define('Badge', {
fontSize: '12px',
padding: '2px 8px',
borderRadius: theme('radius-md'),
backgroundColor: theme('colors-bgElement'),
color: theme('colors-statusRunning'),
fontWeight: 'bold',
})
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
})
interface LayoutProps {
title: string
children: Child
}
function Layout({ title, children }: LayoutProps) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>
{children}
</Container>
</body>
</html>
)
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
async function getVersions(appPath: string): Promise<{ name: string; isCurrent: boolean }[]> {
const entries = await readdir(appPath, { withFileTypes: true })
let currentTarget = ''
try {
currentTarget = await readlink(join(appPath, 'current'))
} catch { }
return entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => ({ name: e.name, isCurrent: e.name === currentTarget }))
.sort((a, b) => b.name.localeCompare(a.name))
}
function formatTimestamp(ts: string): string {
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}:${ts.slice(13, 15)}`
}
app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) {
return c.html(
<Layout title="Versions">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox>
</Layout>
)
}
const appPath = join(APPS_DIR, appName)
try {
await stat(appPath)
} catch {
return c.html(
<Layout title="Versions">
<ErrorBox>App "{appName}" not found</ErrorBox>
</Layout>
)
}
const versions = await getVersions(appPath)
if (versions.length === 0) {
return c.html(
<Layout title="Versions">
<ErrorBox>No versions found</ErrorBox>
</Layout>
)
}
return c.html(
<Layout title="Versions">
<VersionList>
{versions.map(v => (
<VersionItem>
<VersionLink
href={`${TOES_URL}/tool/code?app=${appName}&version=${v.name}`}
>
{formatTimestamp(v.name)}
</VersionLink>
{v.isCurrent && <Badge>current</Badge>}
</VersionItem>
))}
</VersionList>
</Layout>
)
})
export default app.defaults

View File

@ -0,0 +1,26 @@
{
"name": "versions",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx",
"start": "bun toes",
"dev": "bun run --hot index.tsx"
},
"toes": {
"tool": true,
"icon": "📦"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.3"
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5"
}
}

View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@ -88,7 +88,7 @@ cd "$DEST" && bun run build
# -- Bundled apps -- # -- Bundled apps --
echo ">> Installing bundled apps" echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env stats" BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do for app in $BUNDLED_APPS; do
if [ -d "$DEST/apps/$app" ]; then if [ -d "$DEST/apps/$app" ]; then
if [ -d ~/apps/"$app" ]; then if [ -d ~/apps/"$app" ]; then

View File

@ -58,7 +58,7 @@ mkdir -p ~/data
mkdir -p ~/apps mkdir -p ~/apps
echo ">> Installing bundled apps" echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env git metrics" BUNDLED_APPS="clock code cron env git metrics versions"
for app in $BUNDLED_APPS; do for app in $BUNDLED_APPS; do
if [ -d "apps/$app" ]; then if [ -d "apps/$app" ]; then
echo " Installing $app..." echo " Installing $app..."