Compare commits
No commits in common. "91c6ee36753744fd314816999a1a1fc5d562d6c5" and "f12854fc045029de595299b68d8190b412e3d2c1" have entirely different histories.
91c6ee3675
...
f12854fc04
|
|
@ -35,14 +35,7 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
|
||||||
- `commands/logs.ts` - log viewing with tail support
|
- `commands/logs.ts` - log viewing with tail support
|
||||||
|
|
||||||
### Shared (`src/shared/`)
|
### Shared (`src/shared/`)
|
||||||
- Code shared between frontend (browser) and backend (server)
|
|
||||||
- `types.ts` - App, AppState, Manifest interfaces
|
- `types.ts` - App, AppState, Manifest interfaces
|
||||||
- IMPORTANT: Cannot use filesystem or Node APIs (runs in browser)
|
|
||||||
|
|
||||||
### Lib (`src/lib/`)
|
|
||||||
- Code shared between CLI and server (server-side only)
|
|
||||||
- `templates.ts` - Template generation for new apps
|
|
||||||
- Can use filesystem and Node APIs (never runs in browser)
|
|
||||||
|
|
||||||
### Other
|
### Other
|
||||||
- `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script
|
- `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
registry=https://npm.nose.space
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "code",
|
|
||||||
"dependencies": {
|
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/howl": "*",
|
|
||||||
"@because/hype": "*",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest",
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.9.2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"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/howl": ["@because/howl@0.0.2", "https://npm.nose.space/@because/howl/-/howl-0.0.2.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-Z4okzEa282LKkBk9DQwEUU6FT+PeThfQ6iQAY41LIEjs8B2kfXRZnbWLs7tgpwCfYORxb0RO4Hr7KiyEqnfTvQ=="],
|
|
||||||
|
|
||||||
"@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/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=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"lucide-static": ["lucide-static@0.555.0", "https://npm.nose.space/lucide-static/-/lucide-static-0.555.0.tgz", {}, "sha512-FMMaYYsEYsUA6xlEzIMoKEV3oGnxIIvAN+AtLmYXvlTJptJTveJjVBQwvtA/zZLrD6KLEu89G95dQYlhivw5jQ=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './src/server'
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
{
|
|
||||||
"name": "code",
|
|
||||||
"module": "index.tsx",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"toes": "bun run --watch index.tsx",
|
|
||||||
"start": "bun toes",
|
|
||||||
"dev": "bun run --hot index.tsx"
|
|
||||||
},
|
|
||||||
"toes": {
|
|
||||||
"icon": "🖥️"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@because/hype": "*",
|
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/howl": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export default () => <h1>code</h1>
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
|
||||||
import { readdir, stat } from 'fs/promises'
|
|
||||||
import { readFileSync } from 'fs'
|
|
||||||
import { join, extname, basename } from 'path'
|
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
|
||||||
|
|
||||||
// Styles
|
|
||||||
const Container = define('CodeBrowserContainer', {
|
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
padding: '20px',
|
|
||||||
maxWidth: '1200px',
|
|
||||||
margin: '0 auto',
|
|
||||||
})
|
|
||||||
|
|
||||||
const Header = define('Header', {
|
|
||||||
marginBottom: '20px',
|
|
||||||
paddingBottom: '10px',
|
|
||||||
borderBottom: '2px solid #333',
|
|
||||||
})
|
|
||||||
|
|
||||||
const Title = define('Title', {
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
})
|
|
||||||
|
|
||||||
const AppName = define('AppName', {
|
|
||||||
color: '#666',
|
|
||||||
fontSize: '18px',
|
|
||||||
marginTop: '5px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const FileList = define('FileList', {
|
|
||||||
listStyle: 'none',
|
|
||||||
padding: 0,
|
|
||||||
margin: '20px 0',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
})
|
|
||||||
|
|
||||||
const FileItem = define('FileItem', {
|
|
||||||
padding: '10px 15px',
|
|
||||||
borderBottom: '1px solid #eee',
|
|
||||||
states: {
|
|
||||||
':last-child': {
|
|
||||||
borderBottom: 'none',
|
|
||||||
},
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const FileLink = define('FileLink', {
|
|
||||||
base: 'a',
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: '#0066cc',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
states: {
|
|
||||||
':hover': {
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const CodeBlock = define('CodeBlock', {
|
|
||||||
margin: '20px 0',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
overflow: 'auto',
|
|
||||||
selectors: {
|
|
||||||
'& pre': {
|
|
||||||
margin: 0,
|
|
||||||
padding: '15px',
|
|
||||||
overflow: 'auto',
|
|
||||||
whiteSpace: 'pre',
|
|
||||||
},
|
|
||||||
'& pre code': {
|
|
||||||
whiteSpace: 'pre',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const CodeHeader = define('CodeHeader', {
|
|
||||||
padding: '10px 15px',
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
borderBottom: '1px solid #ddd',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
fontSize: '14px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const Error = define('Error', {
|
|
||||||
color: '#d32f2f',
|
|
||||||
padding: '20px',
|
|
||||||
backgroundColor: '#ffebee',
|
|
||||||
borderRadius: '4px',
|
|
||||||
margin: '20px 0',
|
|
||||||
})
|
|
||||||
|
|
||||||
const BackLink = define('BackLink', {
|
|
||||||
base: 'a',
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: '#0066cc',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '5px',
|
|
||||||
marginBottom: '15px',
|
|
||||||
states: {
|
|
||||||
':hover': {
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(stylesToCSS(), 200, {
|
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
|
||||||
}))
|
|
||||||
|
|
||||||
async function listFiles(appPath: string, subPath: string = '') {
|
|
||||||
const fullPath = join(appPath, subPath)
|
|
||||||
const entries = await readdir(fullPath, { withFileTypes: true })
|
|
||||||
|
|
||||||
const items = await Promise.all(
|
|
||||||
entries.map(async entry => {
|
|
||||||
const itemPath = join(fullPath, entry.name)
|
|
||||||
const stats = await stat(itemPath)
|
|
||||||
return {
|
|
||||||
name: entry.name,
|
|
||||||
isDirectory: entry.isDirectory(),
|
|
||||||
path: subPath ? `${subPath}/${entry.name}` : entry.name,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return items.sort((a, b) => {
|
|
||||||
if (a.isDirectory !== b.isDirectory) {
|
|
||||||
return a.isDirectory ? -1 : 1
|
|
||||||
}
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLanguage(filename: string): string {
|
|
||||||
const ext = extname(filename).toLowerCase()
|
|
||||||
const langMap: Record<string, string> = {
|
|
||||||
'.js': 'javascript',
|
|
||||||
'.jsx': 'javascript',
|
|
||||||
'.ts': 'typescript',
|
|
||||||
'.tsx': 'typescript',
|
|
||||||
'.json': 'json',
|
|
||||||
'.css': 'css',
|
|
||||||
'.html': 'html',
|
|
||||||
'.md': 'markdown',
|
|
||||||
'.sh': 'bash',
|
|
||||||
'.yml': 'yaml',
|
|
||||||
'.yaml': 'yaml',
|
|
||||||
}
|
|
||||||
return langMap[ext] || 'plaintext'
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/', async c => {
|
|
||||||
const appName = c.req.query('app')
|
|
||||||
const filePath = c.req.query('file') || ''
|
|
||||||
|
|
||||||
if (!appName) {
|
|
||||||
return c.html(
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Code Browser</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
</Header>
|
|
||||||
<Error>Please specify an app name with ?app=<name></Error>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const appPath = join(APPS_DIR, appName)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stat(appPath)
|
|
||||||
} catch {
|
|
||||||
return c.html(
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Code Browser</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
</Header>
|
|
||||||
<Error>App "{appName}" not found</Error>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = join(appPath, filePath)
|
|
||||||
let stats
|
|
||||||
|
|
||||||
try {
|
|
||||||
stats = await stat(fullPath)
|
|
||||||
} catch {
|
|
||||||
return c.html(
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Code Browser</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
</Header>
|
|
||||||
<Error>Path "{filePath}" not found in app "{appName}"</Error>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.isFile()) {
|
|
||||||
const content = readFileSync(fullPath, 'utf-8')
|
|
||||||
const language = getLanguage(basename(fullPath))
|
|
||||||
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
|
||||||
|
|
||||||
return c.html(
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>{`${appName}/${filePath}`}</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
<AppName>{appName}/{filePath}</AppName>
|
|
||||||
</Header>
|
|
||||||
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
|
|
||||||
⬅️ Back
|
|
||||||
</BackLink>
|
|
||||||
<CodeBlock>
|
|
||||||
<CodeHeader>{basename(fullPath)}</CodeHeader>
|
|
||||||
<pre><code class={`language-${language}`}>{content}</code></pre>
|
|
||||||
</CodeBlock>
|
|
||||||
</Container>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await listFiles(appPath, filePath)
|
|
||||||
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
|
||||||
|
|
||||||
return c.html(
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>{`${appName}${filePath ? `/${filePath}` : ''}`}</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
<AppName>{appName}{filePath ? `/${filePath}` : ''}</AppName>
|
|
||||||
</Header>
|
|
||||||
{filePath && (
|
|
||||||
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
|
|
||||||
⬅️ Back
|
|
||||||
</BackLink>
|
|
||||||
)}
|
|
||||||
<FileList>
|
|
||||||
{files.map(file => (
|
|
||||||
<FileItem>
|
|
||||||
<FileLink href={`/?app=${appName}&file=${file.path}`}>
|
|
||||||
{file.isDirectory ? '📁' : '📄'}
|
|
||||||
<span>{file.name}</span>
|
|
||||||
</FileLink>
|
|
||||||
</FileItem>
|
|
||||||
))}
|
|
||||||
</FileList>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["ESNext"],
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "Preserve",
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "hono/jsx",
|
|
||||||
"allowJs": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
"noUnusedLocals": false,
|
|
||||||
"noUnusedParameters": false,
|
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"$*": ["src/server/*"],
|
|
||||||
"#*": ["src/client/*"],
|
|
||||||
"@*": ["src/shared/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
package.json
14
package.json
|
|
@ -8,19 +8,13 @@
|
||||||
"toes": "src/cli/index.ts"
|
"toes": "src/cli/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "./scripts/build.sh",
|
"start": "bun run src/server/index.tsx",
|
||||||
|
"dev": "bun run --hot src/server/index.tsx",
|
||||||
|
"test": "bun test",
|
||||||
"cli:build": "bun run scripts/build.ts",
|
"cli:build": "bun run scripts/build.ts",
|
||||||
"cli:build:all": "bun run scripts/build.ts --all",
|
"cli:build:all": "bun run scripts/build.ts --all",
|
||||||
"cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin",
|
"cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin",
|
||||||
"cli:uninstall": "sudo rm /usr/local/bin",
|
"cli:uninstall": "sudo rm /usr/local/bin"
|
||||||
"deploy": "./scripts/deploy.sh",
|
|
||||||
"dev": "bun run --hot src/server/index.tsx",
|
|
||||||
"remote:install": "./scripts/remote-install.sh",
|
|
||||||
"remote:restart": "./scripts/remote-restart.sh",
|
|
||||||
"remote:start": "./scripts/remote-start.sh",
|
|
||||||
"remote:stop": "./scripts/remote-stop.sh",
|
|
||||||
"start": "bun run src/server/index.tsx",
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo ">> Building client bundle"
|
|
||||||
|
|
||||||
# Clean pub directory
|
|
||||||
rm -rf pub/client
|
|
||||||
mkdir -p pub/client
|
|
||||||
|
|
||||||
# Bundle client code
|
|
||||||
bun build src/client/index.tsx \
|
|
||||||
--outfile pub/client/index.js \
|
|
||||||
--target browser \
|
|
||||||
--minify
|
|
||||||
|
|
||||||
echo ">> Client bundle created at pub/client/index.js"
|
|
||||||
ls -lh pub/client/index.js
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# It isn't enough to modify this yet.
|
|
||||||
# You also need to manually update the toes.service file.
|
|
||||||
HOST="${HOST:-toes@toes.local}"
|
|
||||||
URL="${URL:-http://toes.local}"
|
|
||||||
DEST="${DEST:-~/.toes}"
|
|
||||||
APPS_DIR="${APPS_DIR:-~/apps}"
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Get absolute path of this script's directory
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
ROOT_DIR="$SCRIPT_DIR/.."
|
|
||||||
|
|
||||||
# Load config
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
|
||||||
|
|
||||||
# Make sure we're up-to-date
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo "=> You have unsaved (git) changes"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# SSH to target and update
|
|
||||||
ssh "$HOST" "cd $DEST && git pull origin main && bun run build && sudo systemctl restart toes.service"
|
|
||||||
|
|
||||||
echo "=> Deployed to $HOST"
|
|
||||||
echo "=> Visit $URL"
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
##
|
|
||||||
# installs systemd files to keep toes running on your Raspberry Pi
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
quiet() { "$@" > /dev/null 2>&1; }
|
|
||||||
|
|
||||||
SYSTEMD_DIR="/etc/systemd/system"
|
|
||||||
SERVICE_NAME="toes"
|
|
||||||
SERVICE_FILE="$(dirname "$0")/${SERVICE_NAME}.service"
|
|
||||||
SYSTEMD_PATH="${SYSTEMD_DIR}/${SERVICE_NAME}.service"
|
|
||||||
|
|
||||||
BUN_SYMLINK="/usr/local/bin/bun"
|
|
||||||
BUN_REAL="$HOME/.bun/bin/bun"
|
|
||||||
|
|
||||||
echo ">> Updating system libraries"
|
|
||||||
quiet sudo apt-get update
|
|
||||||
quiet sudo apt-get install -y libcap2-bin
|
|
||||||
quiet sudo apt-get install -y avahi-utils
|
|
||||||
|
|
||||||
echo ">> Ensuring bun is available in /usr/local/bin"
|
|
||||||
if [ ! -x "$BUN_SYMLINK" ]; then
|
|
||||||
if [ -x "$BUN_REAL" ]; then
|
|
||||||
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
|
||||||
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
|
||||||
else
|
|
||||||
echo ">> Installing bun at $BUN_REAL"
|
|
||||||
quiet sudo apt install unzip
|
|
||||||
quiet curl -fsSL https://bun.sh/install | bash
|
|
||||||
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
|
||||||
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "bun already available at $BUN_SYMLINK"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ">> Setting CAP_NET_BIND_SERVICE on $BUN_REAL"
|
|
||||||
quiet sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
|
|
||||||
quiet /usr/sbin/getcap "$BUN_REAL" || true
|
|
||||||
|
|
||||||
echo ">> Creating apps directory"
|
|
||||||
mkdir -p ~/apps
|
|
||||||
|
|
||||||
echo ">> Building client bundle"
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
echo ">> Installing toes service"
|
|
||||||
quiet sudo install -m 644 -o root -g root "$SERVICE_FILE" "$SYSTEMD_PATH"
|
|
||||||
|
|
||||||
echo ">> Reloading systemd daemon"
|
|
||||||
quiet sudo systemctl daemon-reload
|
|
||||||
|
|
||||||
echo ">> Enabling $SERVICE_NAME to start at boot"
|
|
||||||
quiet sudo systemctl enable "$SERVICE_NAME"
|
|
||||||
|
|
||||||
echo ">> Starting (or restarting) $SERVICE_NAME"
|
|
||||||
quiet sudo systemctl restart "$SERVICE_NAME"
|
|
||||||
|
|
||||||
echo ">> Enabling kiosk mode"
|
|
||||||
quiet mkdir -p ~/.config/labwc
|
|
||||||
cat > ~/.config/labwc/autostart <<'EOF'
|
|
||||||
chromium-browser --noerrdialogs --disable-infobars --kiosk http://localhost
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo ">> Done! Rebooting in 5 seconds..."
|
|
||||||
quiet systemctl status "$SERVICE_NAME" --no-pager -l
|
|
||||||
sleep 5
|
|
||||||
quiet sudo nohup reboot >/dev/null 2>&1 &
|
|
||||||
exit 0
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Get absolute path of this script's directory
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
ROOT_DIR="$SCRIPT_DIR/.."
|
|
||||||
|
|
||||||
# Load config
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
|
||||||
|
|
||||||
# Run remote install on the target
|
|
||||||
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
ROOT_DIR="$SCRIPT_DIR/.."
|
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl restart toes.service"
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
ROOT_DIR="$SCRIPT_DIR/.."
|
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl start toes.service"
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
ROOT_DIR="$SCRIPT_DIR/.."
|
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl stop toes.service"
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Toes - Personal Web Appliance
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=toes
|
|
||||||
WorkingDirectory=/home/toes/.toes/
|
|
||||||
Environment=PORT=80
|
|
||||||
Environment=NODE_ENV=production
|
|
||||||
Environment=APPS_DIR=/home/toes/apps/
|
|
||||||
ExecStart=/home/toes/.bun/bin/bun start
|
|
||||||
Restart=always
|
|
||||||
RestartSec=1
|
|
||||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
|
||||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { App } from '@types'
|
import type { App } from '@types'
|
||||||
import { generateTemplates, type TemplateType } from '%templates'
|
import { generateTemplates, type TemplateType } from '@templates'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { generateTemplates } from '../../shared/templates'
|
||||||
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
||||||
import { apps, setSelectedApp } from '../state'
|
import { apps, setSelectedApp } from '../state'
|
||||||
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
|
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
|
||||||
|
|
@ -31,16 +32,16 @@ async function createNewApp(input: HTMLInputElement) {
|
||||||
rerenderModal()
|
rerenderModal()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/apps', {
|
const templates = generateTemplates(name)
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
for (const [filename, content] of Object.entries(templates)) {
|
||||||
body: JSON.stringify({ name }),
|
const res = await fetch(`/api/sync/apps/${name}/files/${filename}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: content,
|
||||||
})
|
})
|
||||||
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
throw new Error(`Failed to create ${filename}`)
|
||||||
|
}
|
||||||
if (!res.ok || !data.ok) {
|
|
||||||
throw new Error(data.error || 'Failed to create app')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success - close modal and select the new app
|
// Success - close modal and select the new app
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { APPS_DIR, allApps, onChange, renameApp, startApp, stopApp, updateAppIcon } from '$apps'
|
import { allApps, onChange, renameApp, startApp, stopApp, updateAppIcon } from '$apps'
|
||||||
import type { App as BackendApp } from '$apps'
|
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 { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
|
||||||
import { dirname, join } from 'path'
|
|
||||||
|
|
||||||
const router = Hype.router()
|
const router = Hype.router()
|
||||||
|
|
||||||
|
|
@ -58,42 +55,6 @@ router.get('/:app/logs', c => {
|
||||||
return c.json(app.logs ?? [])
|
return c.json(app.logs ?? [])
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/', async c => {
|
|
||||||
let body: { name?: string, template?: TemplateType }
|
|
||||||
try {
|
|
||||||
body = await c.req.json()
|
|
||||||
} catch {
|
|
||||||
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = body.name?.trim().toLowerCase().replace(/\s+/g, '-')
|
|
||||||
if (!name) return c.json({ ok: false, error: 'App name is required' }, 400)
|
|
||||||
|
|
||||||
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
||||||
return c.json({ ok: false, error: 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const appPath = join(APPS_DIR, name)
|
|
||||||
if (existsSync(appPath)) {
|
|
||||||
return c.json({ ok: false, error: 'An app with this name already exists' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = body.template ?? 'ssr'
|
|
||||||
const templates = generateTemplates(name, template)
|
|
||||||
|
|
||||||
// Create directories and write files
|
|
||||||
for (const [filename, content] of Object.entries(templates)) {
|
|
||||||
const fullPath = join(appPath, filename)
|
|
||||||
const dir = dirname(fullPath)
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir, { recursive: true })
|
|
||||||
}
|
|
||||||
writeFileSync(fullPath, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ ok: true, name })
|
|
||||||
})
|
|
||||||
|
|
||||||
router.sse('/:app/logs/stream', (send, c) => {
|
router.sse('/:app/logs/stream', (send, c) => {
|
||||||
const appName = c.req.param('app')
|
const appName = c.req.param('app')
|
||||||
const targetApp = allApps().find(a => a.name === appName)
|
const targetApp = allApps().find(a => a.name === appName)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import type { App as SharedApp, AppState } from '@types'
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
import { DEFAULT_EMOJI } from '@types'
|
||||||
import { existsSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from 'fs'
|
import { existsSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from 'fs'
|
||||||
import { join, resolve } from 'path'
|
import { join } from 'path'
|
||||||
|
|
||||||
export type { AppState } from '@types'
|
export type { AppState } from '@types'
|
||||||
|
|
||||||
export const APPS_DIR = process.env.APPS_DIR ?? resolve(join(process.env.DATA_DIR ?? '.', 'apps'))
|
export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
|
||||||
|
|
||||||
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
||||||
const HEALTH_CHECK_INTERVAL = 30000
|
const HEALTH_CHECK_INTERVAL = 30000
|
||||||
|
|
@ -402,7 +402,7 @@ async function runApp(dir: string, port: number) {
|
||||||
|
|
||||||
const proc = Bun.spawn(['bun', 'run', 'toes'], {
|
const proc = Bun.spawn(['bun', 'run', 'toes'], {
|
||||||
cwd,
|
cwd,
|
||||||
env: { ...process.env, PORT: String(port), NO_AUTOPORT: 'true', APPS_DIR },
|
env: { ...process.env, PORT: String(port), NO_AUTOPORT: 'true' },
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
|
||||||
import { readdirSync, readFileSync, statSync } from 'fs'
|
import { readdirSync, readFileSync, statSync } from 'fs'
|
||||||
import { join, relative } from 'path'
|
import { join, relative } from 'path'
|
||||||
|
import { DEFAULT_EMOJI } from './types'
|
||||||
|
|
||||||
export type TemplateType = 'ssr' | 'bare' | 'spa'
|
export type TemplateType = 'ssr' | 'bare' | 'spa'
|
||||||
|
|
||||||
export type AppTemplates = Record<string, string>
|
export type AppTemplates = Record<string, string>
|
||||||
|
|
||||||
interface TemplateVars {
|
interface TemplateVars {
|
||||||
APP_EMOJI: string
|
|
||||||
APP_NAME: string
|
APP_NAME: string
|
||||||
|
APP_EMOJI: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEMPLATES_DIR = join(import.meta.dir, '../../templates')
|
const TEMPLATES_DIR = join(import.meta.dirname, '../../templates')
|
||||||
|
|
||||||
function readDir(dir: string): string[] {
|
function readDir(dir: string): string[] {
|
||||||
const files: string[] = []
|
const files: string[] = []
|
||||||
|
|
@ -34,8 +34,8 @@ function replaceVars(content: string, vars: TemplateVars): string {
|
||||||
|
|
||||||
export function generateTemplates(appName: string, template: TemplateType = 'ssr'): AppTemplates {
|
export function generateTemplates(appName: string, template: TemplateType = 'ssr'): AppTemplates {
|
||||||
const vars: TemplateVars = {
|
const vars: TemplateVars = {
|
||||||
APP_EMOJI: DEFAULT_EMOJI,
|
|
||||||
APP_NAME: appName,
|
APP_NAME: appName,
|
||||||
|
APP_EMOJI: DEFAULT_EMOJI,
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: AppTemplates = {}
|
const result: AppTemplates = {}
|
||||||
Loading…
Reference in New Issue
Block a user