Compare commits
No commits in common. "c081785d373008d7ec169834927a29a8f4ee16d2" and "f5c5102fc89ce7f5c8b0e5822d2b953fd413edbe" have entirely different histories.
c081785d37
...
f5c5102fc8
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
1
apps/versions/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
45
apps/versions/bun.lock
Normal file
45
apps/versions/bun.lock
Normal 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
177
apps/versions/index.tsx
Normal 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=<name></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
|
||||||
26
apps/versions/package.json
Normal file
26
apps/versions/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/versions/tsconfig.json
Normal file
30
apps/versions/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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..."
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user