Compare commits
42 Commits
wifi-setup
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ee9163f76 | |||
| f9b67c03bb | |||
| dd5d9254c0 | |||
| 01f23ace16 | |||
| 5f1de651eb | |||
| 460d625f60 | |||
| 3ad7145229 | |||
| a87f0a9651 | |||
| d2b0eb410f | |||
| ffe1df22e6 | |||
| 7f82a37c63 | |||
| 6055b9798d | |||
| f7397dc060 | |||
| d69dc6ae9d | |||
| 4853ee4f7a | |||
|
|
74f9062a89 | ||
|
|
55316027c0 | ||
| cfba207077 | |||
| 702019279a | |||
| 141622f86f | |||
| 526678e87a | |||
| dc570cc6e9 | |||
| d29e306e61 | |||
| 671f51ca0c | |||
| 604ac96b30 | |||
| d082af4e33 | |||
| 9bce15b871 | |||
| 7ab27f2767 | |||
| 45b1903e6b | |||
| 68274d8651 | |||
| 98a1c1ad97 | |||
| 6d02f1db3f | |||
| b0c5a11cde | |||
| 029e349c5b | |||
| 1a71656508 | |||
| 363a82a845 | |||
| 271bf018b8 | |||
|
|
488c643342 | ||
|
|
8fc54bd349 | ||
|
|
3cbb25a82a | ||
| 87d0ff50c1 | |||
| 0499060676 |
25
README.md
25
README.md
|
|
@ -4,11 +4,28 @@ Toes is a personal web server you run in your home.
|
||||||
|
|
||||||
Plug it in, turn it on, and forget about the cloud.
|
Plug it in, turn it on, and forget about the cloud.
|
||||||
|
|
||||||
## quickstart
|
## setup
|
||||||
|
|
||||||
1. Plug in and turn on your Toes computer.
|
Toes runs on a Raspberry Pi. You'll need:
|
||||||
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
|
|
||||||
3. Visit https://toes.local to get started!
|
- A Raspberry Pi running Raspberry Pi OS
|
||||||
|
- A `toes` user with passwordless sudo
|
||||||
|
|
||||||
|
SSH into your Pi as the `toes` user and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://toes.dev/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
|
||||||
|
1. Install system dependencies (git, fish shell, networking tools)
|
||||||
|
2. Install Bun and grant it network binding capabilities
|
||||||
|
3. Clone and build the toes server
|
||||||
|
4. Set up bundled apps (clock, code, cron, env, stats, versions)
|
||||||
|
5. Install and enable a systemd service for auto-start
|
||||||
|
|
||||||
|
Once complete, visit `http://<hostname>.local` on your local network.
|
||||||
|
|
||||||
## features
|
## features
|
||||||
- Hosts bun/hono/hype webapps - both SSR and SPA.
|
- Hosts bun/hono/hype webapps - both SSR and SPA.
|
||||||
|
|
|
||||||
45
apps/git/20260228-000000/bun.lock
Normal file
45
apps/git/20260228-000000/bun.lock
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "git",
|
||||||
|
"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.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.3.3", "https://npm.nose.space/@types/node/-/node-25.3.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
|
"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.12.3", "https://npm.nose.space/hono/-/hono-4.12.3.tgz", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
|
||||||
|
|
||||||
|
"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.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
565
apps/git/20260228-000000/index.tsx
Normal file
565
apps/git/20260228-000000/index.tsx
Normal file
|
|
@ -0,0 +1,565 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
|
import { mkdir, readdir, readlink, rm, stat } from 'fs/promises'
|
||||||
|
import { join, resolve } from 'path'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
const DATA_DIR = process.env.DATA_DIR!
|
||||||
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
|
||||||
|
const MAX_VERSIONS = 5
|
||||||
|
const REPOS_DIR = join(DATA_DIR, 'repos')
|
||||||
|
const VALID_NAME = /^[a-zA-Z0-9_-]+$/
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false, layout: false })
|
||||||
|
const deployLocks = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Styles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const Badge = define('Badge', {
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const CodeBlock = define('CodeBlock', {
|
||||||
|
base: 'pre',
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
padding: theme('spacing-lg'),
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '13px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
lineHeight: '1.5',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Heading = define('Heading', {
|
||||||
|
base: 'h3',
|
||||||
|
margin: '24px 0 8px',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const HelpText = define('HelpText', {
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
margin: '12px 0',
|
||||||
|
})
|
||||||
|
|
||||||
|
const RepoItem = define('RepoItem', {
|
||||||
|
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 RepoList = define('RepoList', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: '20px 0',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
const RepoName = define('RepoName', {
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
title: string
|
||||||
|
children: Child
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)
|
||||||
|
|
||||||
|
const timestamp = () => {
|
||||||
|
const [date, time] = new Date().toISOString().slice(0, 19).split('T')
|
||||||
|
return `${date.replaceAll('-', '')}-${time.replaceAll(':', '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal
|
||||||
|
const validRepoName = (name: string) =>
|
||||||
|
VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)
|
||||||
|
|
||||||
|
async function activateApp(name: string, version: string): Promise<string | null> {
|
||||||
|
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/activate?version=${version}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
const msg = (body as Record<string, string>).error ?? `activate returned ${res.status}`
|
||||||
|
console.error(`Activate failed for ${name}@${version}:`, msg)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanOldVersions(appDir: string): Promise<void> {
|
||||||
|
if (!(await dirExists(appDir))) return
|
||||||
|
|
||||||
|
// Read the current symlink target so we never delete the active version
|
||||||
|
let current: string | null = null
|
||||||
|
try {
|
||||||
|
const target = await readlink(join(appDir, 'current'))
|
||||||
|
current = target.split('/').pop() ?? null
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const entries = await readdir(appDir, { withFileTypes: true })
|
||||||
|
const versions = entries
|
||||||
|
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
|
||||||
|
.map(e => e.name)
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
if (versions.length <= MAX_VERSIONS) return
|
||||||
|
|
||||||
|
const toRemove = versions
|
||||||
|
.slice(0, versions.length - MAX_VERSIONS)
|
||||||
|
.filter(v => v !== current)
|
||||||
|
for (const dir of toRemove) {
|
||||||
|
await rm(join(appDir, dir), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deploy(repoName: string): Promise<{ ok: boolean; error?: string; version?: string }> {
|
||||||
|
const bare = repoPath(repoName)
|
||||||
|
|
||||||
|
if (!(await hasCommits(bare))) {
|
||||||
|
return { ok: false, error: 'No commits in repository' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = timestamp()
|
||||||
|
const appDir = join(APPS_DIR, repoName)
|
||||||
|
const versionDir = join(appDir, ts)
|
||||||
|
|
||||||
|
await mkdir(versionDir, { recursive: true })
|
||||||
|
|
||||||
|
// Extract HEAD into the version directory — no shell, pipe git archive into tar
|
||||||
|
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tar = Bun.spawn(['tar', '-x', '-C', versionDir], {
|
||||||
|
stdin: archive.stdout,
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'pipe',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Consume stderr concurrently to prevent pipe buffer from filling and blocking the process
|
||||||
|
const [archiveExit, tarExit, archiveErr, tarErr] = await Promise.all([
|
||||||
|
archive.exited,
|
||||||
|
tar.exited,
|
||||||
|
new Response(archive.stderr).text(),
|
||||||
|
new Response(tar.stderr).text(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (archiveExit !== 0 || tarExit !== 0) {
|
||||||
|
await rm(versionDir, { recursive: true, force: true })
|
||||||
|
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify package.json with scripts.toes exists
|
||||||
|
const pkgPath = join(versionDir, 'package.json')
|
||||||
|
if (!(await Bun.file(pkgPath).exists())) {
|
||||||
|
await rm(versionDir, { recursive: true, force: true })
|
||||||
|
return { ok: false, error: 'No package.json found in repository' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(await Bun.file(pkgPath).text())
|
||||||
|
if (!pkg.scripts?.toes) {
|
||||||
|
await rm(versionDir, { recursive: true, force: true })
|
||||||
|
return { ok: false, error: 'package.json missing scripts.toes entry' }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await rm(versionDir, { recursive: true, force: true })
|
||||||
|
return { ok: false, error: 'Invalid package.json' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old versions beyond MAX_VERSIONS
|
||||||
|
await cleanOldVersions(appDir)
|
||||||
|
|
||||||
|
return { ok: true, version: ts }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bun.file().exists() is for files only — it returns false for directories.
|
||||||
|
// Use stat() to check directory existence instead.
|
||||||
|
async function dirExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await stat(path)).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBareRepo(name: string): Promise<string> {
|
||||||
|
const bare = repoPath(name)
|
||||||
|
if (!(await dirExists(bare))) {
|
||||||
|
await mkdir(bare, { recursive: true })
|
||||||
|
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: bare }).exited
|
||||||
|
await run(['git', 'init', '--bare'])
|
||||||
|
await run(['git', 'symbolic-ref', 'HEAD', 'refs/heads/main'])
|
||||||
|
await run(['git', 'config', 'http.receivepack', 'true'])
|
||||||
|
}
|
||||||
|
return bare
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastFlush(data: Uint8Array): number {
|
||||||
|
for (let i = data.length - 4; i >= 0; i--) {
|
||||||
|
if (data[i] === 0x30 && data[i + 1] === 0x30 &&
|
||||||
|
data[i + 2] === 0x30 && data[i + 3] === 0x30) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDefaultBranch(bare: string): Promise<string> {
|
||||||
|
const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
|
||||||
|
cwd: bare,
|
||||||
|
stdout: 'pipe',
|
||||||
|
// Ignore stderr to avoid filling the pipe buffer and blocking the process
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
if ((await proc.exited) === 0) {
|
||||||
|
const ref = await new Response(proc.stdout).text()
|
||||||
|
return ref.trim().replace('refs/heads/', '')
|
||||||
|
}
|
||||||
|
return 'main'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitRpc(
|
||||||
|
repo: string,
|
||||||
|
service: string,
|
||||||
|
body: ReadableStream<Uint8Array> | null,
|
||||||
|
): Promise<Response> {
|
||||||
|
const bare = repoPath(repo)
|
||||||
|
|
||||||
|
const proc = Bun.spawn([service, '--stateless-rpc', bare], {
|
||||||
|
stdin: body ?? 'ignore',
|
||||||
|
stdout: 'pipe',
|
||||||
|
// Ignore stderr to avoid filling the pipe buffer and blocking the process
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(proc.stdout, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': `application/x-${service}-result`,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitService(repo: string, service: string): Promise<Response | null> {
|
||||||
|
const bare = repoPath(repo)
|
||||||
|
if (!(await dirExists(bare))) return null
|
||||||
|
|
||||||
|
const proc = Bun.spawn([service, '--stateless-rpc', '--advertise-refs', bare], {
|
||||||
|
stdout: 'pipe',
|
||||||
|
// Ignore stderr to avoid filling the pipe buffer and blocking the process
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
const stdout = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
|
||||||
|
await proc.exited
|
||||||
|
|
||||||
|
const header = serviceHeader(service)
|
||||||
|
const body = new Uint8Array(header.length + stdout.byteLength)
|
||||||
|
body.set(header, 0)
|
||||||
|
body.set(stdout, header.length)
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': `application/x-${service}-advertisement`,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitSidebandMessage(text: string): Uint8Array {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const lines = text.split('\n').filter(Boolean)
|
||||||
|
const parts: Uint8Array[] = []
|
||||||
|
for (const line of lines) {
|
||||||
|
const msg = `\x02remote: ${line}\n`
|
||||||
|
const hex = (4 + msg.length).toString(16).padStart(4, '0')
|
||||||
|
parts.push(encoder.encode(hex + msg))
|
||||||
|
}
|
||||||
|
const total = parts.reduce((sum, p) => sum + p.length, 0)
|
||||||
|
const out = new Uint8Array(total)
|
||||||
|
let offset = 0
|
||||||
|
for (const part of parts) {
|
||||||
|
out.set(part, offset)
|
||||||
|
offset += part.length
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasCommits(bare: string): Promise<boolean> {
|
||||||
|
const proc = Bun.spawn(['git', 'rev-parse', 'HEAD'], {
|
||||||
|
cwd: bare,
|
||||||
|
// Only checking exit code; ignore stdout/stderr to avoid filling the pipe buffer
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
return (await proc.exited) === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array {
|
||||||
|
const pos = findLastFlush(gitBody)
|
||||||
|
if (pos === -1) {
|
||||||
|
const out = new Uint8Array(gitBody.length + msg.length)
|
||||||
|
out.set(gitBody, 0)
|
||||||
|
out.set(msg, gitBody.length)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(gitBody.length + msg.length)
|
||||||
|
out.set(gitBody.subarray(0, pos), 0)
|
||||||
|
out.set(msg, pos)
|
||||||
|
out.set(gitBody.subarray(pos), pos + msg.length)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRepos(): Promise<string[]> {
|
||||||
|
if (!(await dirExists(REPOS_DIR))) return []
|
||||||
|
const entries = await readdir(REPOS_DIR, { withFileTypes: true })
|
||||||
|
return entries
|
||||||
|
.filter(e => e.isDirectory() && e.name.endsWith('.git'))
|
||||||
|
.map(e => e.name.replace(/\.git$/, ''))
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceHeader(service: string): Uint8Array {
|
||||||
|
const line = `# service=${service}\n`
|
||||||
|
const hex = (4 + line.length).toString(16).padStart(4, '0')
|
||||||
|
const header = `${hex}${line}0000`
|
||||||
|
return new TextEncoder().encode(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withDeployLock<T>(repo: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = deployLocks.get(repo) ?? Promise.resolve()
|
||||||
|
const { promise: lock, resolve: release } = Promise.withResolvers<void>()
|
||||||
|
deployLocks.set(repo, lock)
|
||||||
|
await prev
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
release()
|
||||||
|
if (deployLocks.get(repo) === lock) deployLocks.delete(repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
mkdirSync(REPOS_DIR, { recursive: true })
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
app.get('/styles.css', c =>
|
||||||
|
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /:repo.git/info/refs?service=git-upload-pack|git-receive-pack
|
||||||
|
app.get('/:repo{.+\\.git}/info/refs', async c => {
|
||||||
|
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
||||||
|
const service = c.req.query('service')
|
||||||
|
|
||||||
|
if (!validRepoName(repoParam)) {
|
||||||
|
return c.text('Invalid repository name', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
|
||||||
|
return c.text('Invalid service', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service === 'git-receive-pack') {
|
||||||
|
await ensureBareRepo(repoParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bare = repoPath(repoParam)
|
||||||
|
if (!(await dirExists(bare))) {
|
||||||
|
return c.text('Repository not found', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await gitService(repoParam, service)
|
||||||
|
return res ?? c.text('Repository not found', 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /:repo.git/git-upload-pack
|
||||||
|
app.post('/:repo{.+\\.git}/git-upload-pack', async c => {
|
||||||
|
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
||||||
|
|
||||||
|
if (!validRepoName(repoParam)) {
|
||||||
|
return c.text('Invalid repository name', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bare = repoPath(repoParam)
|
||||||
|
if (!(await dirExists(bare))) {
|
||||||
|
return c.text('Repository not found', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gitRpc(repoParam, 'git-upload-pack', c.req.raw.body)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /:repo.git/git-receive-pack
|
||||||
|
app.post('/:repo{.+\\.git}/git-receive-pack', async c => {
|
||||||
|
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
||||||
|
|
||||||
|
if (!validRepoName(repoParam)) {
|
||||||
|
return c.text('Invalid repository name', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureBareRepo(repoParam)
|
||||||
|
|
||||||
|
const response = await gitRpc(repoParam, 'git-receive-pack', c.req.raw.body)
|
||||||
|
// Buffer the full response so we can inject sideband error messages before the
|
||||||
|
// final flush-pkt on deploy failure. The receive-pack response is just ref status
|
||||||
|
// lines (not pack data), so the buffer is small regardless of push size.
|
||||||
|
const gitBody = new Uint8Array(await response.arrayBuffer())
|
||||||
|
|
||||||
|
const deployError = await withDeployLock(repoParam, async () => {
|
||||||
|
try {
|
||||||
|
const result = await deploy(repoParam)
|
||||||
|
if (result.ok && result.version) {
|
||||||
|
const err = await activateApp(repoParam, result.version)
|
||||||
|
if (err) {
|
||||||
|
console.error(`Activate failed for ${repoParam}: ${err}`)
|
||||||
|
return `Deploy succeeded but activation failed: ${err}`
|
||||||
|
}
|
||||||
|
console.log(`Deployed ${repoParam}@${result.version}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
console.error(`Deploy failed for ${repoParam}: ${result.error}`)
|
||||||
|
return `Deploy failed: ${result.error}`
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Deploy error for ${repoParam}:`, e)
|
||||||
|
return `Deploy failed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': response.headers.get('Content-Type') ?? 'application/x-git-receive-pack-result',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deployError) {
|
||||||
|
return new Response(insertBeforeFlush(gitBody, gitSidebandMessage(deployError)), { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(gitBody, { headers })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const repos = await listRepos()
|
||||||
|
const host = c.req.header('host') ?? 'git.toes.local'
|
||||||
|
|
||||||
|
const baseUrl = `http://${host}`
|
||||||
|
|
||||||
|
const repoData = await Promise.all(repos.map(async name => {
|
||||||
|
const bare = repoPath(name)
|
||||||
|
const [commits, branch] = await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
|
||||||
|
return { name, commits, branch }
|
||||||
|
}))
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Git">
|
||||||
|
<Heading>Push to Deploy</Heading>
|
||||||
|
<HelpText>
|
||||||
|
Push a git repository to deploy it as a toes app.
|
||||||
|
The repo must contain a <code>package.json</code> with a <code>scripts.toes</code> entry.
|
||||||
|
</HelpText>
|
||||||
|
|
||||||
|
<CodeBlock>{[
|
||||||
|
'# Add this server as a remote and push',
|
||||||
|
`git remote add toes ${baseUrl}/<app-name>.git`,
|
||||||
|
'git push toes main',
|
||||||
|
'',
|
||||||
|
'# Or push an existing repo',
|
||||||
|
`git push ${baseUrl}/<app-name>.git main`,
|
||||||
|
].join('\n')}</CodeBlock>
|
||||||
|
|
||||||
|
{repoData.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Heading>Repositories</Heading>
|
||||||
|
<RepoList>
|
||||||
|
{repoData.map(({ name, commits, branch }) => (
|
||||||
|
<RepoItem>
|
||||||
|
<div>
|
||||||
|
<RepoName>{name}</RepoName>
|
||||||
|
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
||||||
|
git clone {baseUrl}/{name}.git
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center">
|
||||||
|
<Badge>{branch}</Badge>
|
||||||
|
{commits
|
||||||
|
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
||||||
|
: <Badge>empty</Badge>}
|
||||||
|
</div>
|
||||||
|
</RepoItem>
|
||||||
|
))}
|
||||||
|
</RepoList>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{repoData.length === 0 && (
|
||||||
|
<HelpText>No repositories yet. Push one to get started.</HelpText>
|
||||||
|
)}
|
||||||
|
</Layout>,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
26
apps/git/20260228-000000/package.json
Normal file
26
apps/git/20260228-000000/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "git",
|
||||||
|
"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/git/20260228-000000/tsconfig.json
Normal file
30
apps/git/20260228-000000/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
|
||||||
|
}
|
||||||
|
}
|
||||||
26
install/bun.lock
Normal file
26
install/bun.lock
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "toes-install",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.3.2", "https://npm.nose.space/@types/node/-/node-25.3.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
|
"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.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
123
install/install.sh
Normal file
123
install/install.sh
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
##
|
||||||
|
# toes installer
|
||||||
|
# Usage: curl -sSL https://toes.dev/install | bash
|
||||||
|
# Must be run as the 'toes' user.
|
||||||
|
|
||||||
|
DEST=~/toes
|
||||||
|
REPO="https://git.nose.space/defunkt/toes"
|
||||||
|
|
||||||
|
quiet() { "$@" > /dev/null 2>&1; }
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " ╔══════════════════════════════════╗"
|
||||||
|
echo " ║ 🐾 toes - personal web appliance ║"
|
||||||
|
echo " ╚══════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Must be running as toes
|
||||||
|
if [ "$(whoami)" != "toes" ]; then
|
||||||
|
echo "ERROR: This script must be run as the 'toes' user."
|
||||||
|
echo "Create the user during Raspberry Pi OS setup."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Must have passwordless sudo (can't prompt when piped from curl)
|
||||||
|
if ! sudo -n true 2>/dev/null; then
|
||||||
|
echo "ERROR: This script requires passwordless sudo."
|
||||||
|
echo "On Raspberry Pi OS, the default user has this already."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- System packages --
|
||||||
|
|
||||||
|
echo ">> Updating system packages"
|
||||||
|
quiet sudo apt-get update
|
||||||
|
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
|
||||||
|
|
||||||
|
echo ">> Setting fish as default shell"
|
||||||
|
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
|
||||||
|
quiet sudo chsh -s /usr/bin/fish toes
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Bun --
|
||||||
|
|
||||||
|
BUN_REAL="$HOME/.bun/bin/bun"
|
||||||
|
BUN_SYMLINK="/usr/local/bin/bun"
|
||||||
|
|
||||||
|
if [ ! -x "$BUN_REAL" ]; then
|
||||||
|
echo ">> Installing bun"
|
||||||
|
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
|
||||||
|
if [ ! -x "$BUN_REAL" ]; then
|
||||||
|
echo "ERROR: bun installation failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$BUN_SYMLINK" ]; then
|
||||||
|
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> Setting CAP_NET_BIND_SERVICE on bun"
|
||||||
|
sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
|
||||||
|
|
||||||
|
# -- Clone --
|
||||||
|
|
||||||
|
if [ ! -d "$DEST" ]; then
|
||||||
|
echo ">> Cloning toes"
|
||||||
|
git clone "$REPO" "$DEST"
|
||||||
|
else
|
||||||
|
echo ">> Updating toes"
|
||||||
|
cd "$DEST" && git pull origin main
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Directories --
|
||||||
|
|
||||||
|
mkdir -p ~/data ~/apps
|
||||||
|
|
||||||
|
# -- Dependencies & build --
|
||||||
|
|
||||||
|
echo ">> Installing dependencies"
|
||||||
|
cd "$DEST" && bun install
|
||||||
|
|
||||||
|
echo ">> Building client"
|
||||||
|
cd "$DEST" && bun run build
|
||||||
|
|
||||||
|
# -- Bundled apps --
|
||||||
|
|
||||||
|
echo ">> Installing bundled apps"
|
||||||
|
BUNDLED_APPS="clock code cron env stats versions"
|
||||||
|
for app in $BUNDLED_APPS; do
|
||||||
|
if [ -d "$DEST/apps/$app" ]; then
|
||||||
|
if [ -d ~/apps/"$app" ]; then
|
||||||
|
echo " $app (exists, skipping)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo " $app"
|
||||||
|
cp -r "$DEST/apps/$app" ~/apps/
|
||||||
|
version_dir=$(ls -1 ~/apps/"$app" | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
|
||||||
|
if [ -n "$version_dir" ]; then
|
||||||
|
ln -sfn "$version_dir" ~/apps/"$app"/current
|
||||||
|
(cd ~/apps/"$app"/current && bun install --frozen-lockfile) > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- Systemd --
|
||||||
|
|
||||||
|
echo ">> Installing toes service"
|
||||||
|
sudo install -m 644 -o root -g root "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable toes
|
||||||
|
|
||||||
|
echo ">> Starting toes"
|
||||||
|
sudo systemctl restart toes
|
||||||
|
|
||||||
|
# -- Done --
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " toes is installed and running!"
|
||||||
|
echo " Visit: http://$(hostname).local"
|
||||||
|
echo ""
|
||||||
16
install/package.json
Normal file
16
install/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "toes-install",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "install toes",
|
||||||
|
"module": "server.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run server.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
install/server.ts
Normal file
17
install/server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { resolve } from "path"
|
||||||
|
|
||||||
|
const script = await Bun.file(resolve(import.meta.dir, "install.sh")).text()
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
port: parseInt(process.env.PORT || "3000"),
|
||||||
|
fetch(req) {
|
||||||
|
if (new URL(req.url).pathname === "/install") {
|
||||||
|
return new Response(script, {
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return new Response("404 Not Found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)
|
||||||
43
install/tsconfig.json
Normal file
43
install/tsconfig.json
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"exclude": ["apps", "templates"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": [
|
||||||
|
"ESNext",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
|
"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,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": [
|
||||||
|
"./src/server/*"
|
||||||
|
],
|
||||||
|
"@*": [
|
||||||
|
"./src/shared/*"
|
||||||
|
],
|
||||||
|
"%*": [
|
||||||
|
"./src/lib/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.2",
|
||||||
"@because/sneaker": "^0.0.3",
|
"@because/sneaker": "^0.0.3",
|
||||||
"commander": "^14.0.3",
|
"commander": "14.0.3",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@
|
||||||
|
|
||||||
# It isn't enough to modify this yet.
|
# It isn't enough to modify this yet.
|
||||||
# You also need to manually update the toes.service file.
|
# You also need to manually update the toes.service file.
|
||||||
HOST="${HOST:-toes@toes.local}"
|
TOES_USER="${TOES_USER:-toes}"
|
||||||
URL="${URL:-http://toes.local}"
|
HOST="${HOST:-toes.local}"
|
||||||
DEST="${DEST:-~/toes}"
|
SSH_HOST="$TOES_USER@$HOST"
|
||||||
DATA_DIR="${DATA_DIR:-~/data}"
|
URL="${URL:-http://$HOST}"
|
||||||
APPS_DIR="${APPS_DIR:-~/apps}"
|
DEST="${DEST:-$HOME/toes}"
|
||||||
|
DATA_DIR="${DATA_DIR:-$HOME/data}"
|
||||||
|
APPS_DIR="${APPS_DIR:-$HOME/apps}"
|
||||||
|
|
||||||
|
mkdir -p "$DEST" "$DATA_DIR" "$APPS_DIR"
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@ source "$ROOT_DIR/scripts/config.sh"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
||||||
# SSH to target: pull, build, sync apps, restart
|
# SSH to target: pull, build, sync apps, restart
|
||||||
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
|
ssh "$SSH_HOST" bash <<'SCRIPT'
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
DEST="${DEST:-$HOME/toes}"
|
||||||
|
APPS_DIR="${APPS_DIR:-$HOME/apps}"
|
||||||
|
|
||||||
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
|
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
|
||||||
|
|
||||||
echo "=> Syncing default apps..."
|
echo "=> Syncing default apps..."
|
||||||
|
|
@ -35,5 +38,5 @@ done
|
||||||
sudo systemctl restart toes.service
|
sudo systemctl restart toes.service
|
||||||
SCRIPT
|
SCRIPT
|
||||||
|
|
||||||
echo "=> Deployed to $HOST"
|
echo "=> Deployed to $SSH_HOST"
|
||||||
echo "=> Visit $URL"
|
echo "=> Visit $URL"
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
# Run remote install on the target
|
# Run remote install on the target
|
||||||
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
ssh "$SSH_HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$HOST" "journalctl -u toes -n 100"
|
ssh "$SSH_HOST" "journalctl -u toes -n 100"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl restart toes.service"
|
ssh "$SSH_HOST" "sudo systemctl restart toes.service"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl start toes.service"
|
ssh "$SSH_HOST" "sudo systemctl start toes.service"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl stop toes.service"
|
ssh "$SSH_HOST" "sudo systemctl stop toes.service"
|
||||||
|
|
|
||||||
65
scripts/setup-ssh.sh
Executable file
65
scripts/setup-ssh.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# setup-ssh.sh - Configure SSH for the toes CLI user
|
||||||
|
#
|
||||||
|
# This script:
|
||||||
|
# 1. Creates a `cli` system user with /usr/local/bin/toes as shell
|
||||||
|
# 2. Sets an empty password on `cli` for passwordless SSH
|
||||||
|
# 3. Adds a Match block in sshd_config to allow empty passwords for `cli`
|
||||||
|
# 4. Adds /usr/local/bin/toes to /etc/shells
|
||||||
|
# 5. Restarts sshd
|
||||||
|
#
|
||||||
|
# Run as root on the toes machine.
|
||||||
|
# Usage: ssh cli@toes.local
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOES_SHELL="/usr/local/bin/toes"
|
||||||
|
SSHD_CONFIG="/etc/ssh/sshd_config"
|
||||||
|
|
||||||
|
echo "==> Setting up SSH CLI user for toes"
|
||||||
|
|
||||||
|
# 1. Create cli system user
|
||||||
|
if ! id cli &>/dev/null; then
|
||||||
|
useradd --system --home-dir /home/cli --shell "$TOES_SHELL" --create-home cli
|
||||||
|
echo " Created cli user"
|
||||||
|
else
|
||||||
|
echo " cli user already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Set empty password
|
||||||
|
passwd -d cli
|
||||||
|
echo " Set empty password on cli"
|
||||||
|
|
||||||
|
# 3. Add Match block for cli user in sshd_config
|
||||||
|
if ! grep -q 'Match User cli' "$SSHD_CONFIG"; then
|
||||||
|
cat >> "$SSHD_CONFIG" <<EOF
|
||||||
|
|
||||||
|
# toes CLI: allow passwordless SSH for the cli user
|
||||||
|
Match User cli
|
||||||
|
PermitEmptyPasswords yes
|
||||||
|
EOF
|
||||||
|
echo " Added Match User cli block to sshd_config"
|
||||||
|
else
|
||||||
|
echo " sshd_config already has Match User cli block"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Ensure /usr/local/bin/toes is in /etc/shells
|
||||||
|
if ! grep -q "^${TOES_SHELL}$" /etc/shells; then
|
||||||
|
echo "$TOES_SHELL" >> /etc/shells
|
||||||
|
echo " Added $TOES_SHELL to /etc/shells"
|
||||||
|
else
|
||||||
|
echo " $TOES_SHELL already in /etc/shells"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warn if toes binary doesn't exist yet
|
||||||
|
if [ ! -f "$TOES_SHELL" ]; then
|
||||||
|
echo " WARNING: $TOES_SHELL does not exist yet"
|
||||||
|
echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Restart sshd
|
||||||
|
echo " Restarting sshd..."
|
||||||
|
systemctl restart sshd || service ssh restart || true
|
||||||
|
|
||||||
|
echo "==> Done. Connect with: ssh cli@toes.local"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { LogLine } from '@types'
|
import type { LogLine } from '@types'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { get, handleError, makeUrl, post } from '../http'
|
import { get, getSignal, handleError, makeUrl, post } from '../http'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
interface CronJobSummary {
|
interface CronJobSummary {
|
||||||
|
|
@ -195,7 +195,7 @@ const printCronLog = (line: LogLine) =>
|
||||||
async function tailCronLogs(app: string, grep?: string) {
|
async function tailCronLogs(app: string, grep?: string) {
|
||||||
try {
|
try {
|
||||||
const url = makeUrl(`/api/apps/${app}/logs/stream`)
|
const url = makeUrl(`/api/apps/${app}/logs/stream`)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`App not found: ${app}`)
|
console.error(`App not found: ${app}`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { LogLine } from '@types'
|
import type { LogLine } from '@types'
|
||||||
import { get, handleError, makeUrl } from '../http'
|
import { get, getSignal, handleError, makeUrl } from '../http'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
interface LogOptions {
|
interface LogOptions {
|
||||||
|
|
@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) {
|
||||||
export async function tailLogs(name: string, grep?: string) {
|
export async function tailLogs(name: string, grep?: string) {
|
||||||
try {
|
try {
|
||||||
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`App not found: ${name}`)
|
console.error(`App not found: ${name}`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import color from 'kleur'
|
||||||
import { diffLines } from 'diff'
|
import { diffLines } from 'diff'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, 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, getSignal, handleError, makeUrl, post, put } from '../http'
|
||||||
import { confirm, prompt } from '../prompts'
|
import { confirm, prompt } from '../prompts'
|
||||||
import { getAppName, getAppPackage, isApp, resolveAppName } from '../name'
|
import { getAppName, getAppPackage, isApp, resolveAppName } from '../name'
|
||||||
|
|
||||||
|
|
@ -592,7 +592,7 @@ export async function syncApp() {
|
||||||
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
res = await fetch(url)
|
res = await fetch(url, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
|
console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
|
||||||
watcher.close()
|
watcher.close()
|
||||||
|
|
@ -967,7 +967,7 @@ interface VersionsResponse {
|
||||||
|
|
||||||
async function getVersions(appName: string): Promise<VersionsResponse | null> {
|
async function getVersions(appName: string): Promise<VersionsResponse | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`))
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`), { signal: getSignal() })
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
console.error(`App not found: ${appName}`)
|
console.error(`App not found: ${appName}`)
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { Manifest } from '@types'
|
import type { Manifest } from '@types'
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
|
|
||||||
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
||||||
|
const signalStore = new AsyncLocalStorage<AbortSignal>()
|
||||||
|
|
||||||
const normalizeUrl = (url: string) =>
|
const normalizeUrl = (url: string) =>
|
||||||
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
|
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
|
||||||
|
|
@ -18,6 +20,12 @@ export const HOST = process.env.TOES_URL
|
||||||
? normalizeUrl(process.env.TOES_URL)
|
? normalizeUrl(process.env.TOES_URL)
|
||||||
: DEFAULT_HOST
|
: DEFAULT_HOST
|
||||||
|
|
||||||
|
export const getSignal = () => signalStore.getStore()
|
||||||
|
|
||||||
|
export function withSignal<T>(signal: AbortSignal, fn: () => T): T {
|
||||||
|
return signalStore.run(signal, fn)
|
||||||
|
}
|
||||||
|
|
||||||
export function makeUrl(path: string): string {
|
export function makeUrl(path: string): string {
|
||||||
return `${HOST}${path}`
|
return `${HOST}${path}`
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +45,7 @@ export function handleError(error: unknown): void {
|
||||||
|
|
||||||
export async function get<T>(url: string): Promise<T | undefined> {
|
export async function get<T>(url: string): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(url))
|
const res = await fetch(makeUrl(url), { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
|
@ -51,7 +59,7 @@ export async function get<T>(url: string): Promise<T | undefined> {
|
||||||
|
|
||||||
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
|
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() })
|
||||||
if (res.status === 404) return { exists: false }
|
if (res.status === 404) return { exists: false }
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -69,6 +77,7 @@ export async function post<T, B = unknown>(url: string, body?: B): Promise<T | u
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
signal: getSignal(),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
@ -86,6 +95,7 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
|
||||||
const res = await fetch(makeUrl(url), {
|
const res = await fetch(makeUrl(url), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: body as BodyInit,
|
body: body as BodyInit,
|
||||||
|
signal: getSignal(),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
@ -102,7 +112,7 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
|
||||||
export async function download(url: string): Promise<Buffer | undefined> {
|
export async function download(url: string): Promise<Buffer | undefined> {
|
||||||
try {
|
try {
|
||||||
const fullUrl = makeUrl(url)
|
const fullUrl = makeUrl(url)
|
||||||
const res = await fetch(fullUrl)
|
const res = await fetch(fullUrl, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
|
@ -118,6 +128,7 @@ export async function del(url: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(url), {
|
const res = await fetch(makeUrl(url), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
signal: getSignal(),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { program } from './setup'
|
import { program } from './setup'
|
||||||
|
|
||||||
|
const isCliUser = process.env.USER === 'cli'
|
||||||
|
const noArgs = process.argv.length <= 2
|
||||||
|
const isTTY = !!process.stdin.isTTY
|
||||||
|
|
||||||
|
if (isCliUser && noArgs && isTTY) {
|
||||||
|
const { shell } = await import('./shell')
|
||||||
|
await shell()
|
||||||
|
} else {
|
||||||
program.parse()
|
program.parse()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,4 +322,14 @@ program
|
||||||
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
||||||
.action((name, options) => rollbackApp(name, options.version))
|
.action((name, options) => rollbackApp(name, options.version))
|
||||||
|
|
||||||
|
// Shell
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('shell')
|
||||||
|
.description('Interactive shell')
|
||||||
|
.action(async () => {
|
||||||
|
const { shell } = await import('./shell')
|
||||||
|
await shell()
|
||||||
|
})
|
||||||
|
|
||||||
export { program }
|
export { program }
|
||||||
|
|
|
||||||
227
src/cli/shell.ts
Normal file
227
src/cli/shell.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import type { App } from '@types'
|
||||||
|
|
||||||
|
import * as readline from 'readline'
|
||||||
|
|
||||||
|
import color from 'kleur'
|
||||||
|
|
||||||
|
import { get, handleError, HOST, withSignal } from './http'
|
||||||
|
import { program } from './setup'
|
||||||
|
import { STATE_ICONS } from './commands/manage'
|
||||||
|
|
||||||
|
let appNamesCache: string[] = []
|
||||||
|
let appNamesCacheTime = 0
|
||||||
|
|
||||||
|
const APP_CACHE_TTL = 5000
|
||||||
|
|
||||||
|
function tokenize(input: string): string[] {
|
||||||
|
const tokens: string[] = []
|
||||||
|
let current = ''
|
||||||
|
let quote: string | null = null
|
||||||
|
|
||||||
|
for (const ch of input) {
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) {
|
||||||
|
quote = null
|
||||||
|
} else {
|
||||||
|
current += ch
|
||||||
|
}
|
||||||
|
} else if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch
|
||||||
|
} else if (ch === ' ' || ch === '\t') {
|
||||||
|
if (current) {
|
||||||
|
tokens.push(current)
|
||||||
|
current = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) tokens.push(current)
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAppNames(): Promise<string[]> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (appNamesCache.length > 0 && now - appNamesCacheTime < APP_CACHE_TTL) {
|
||||||
|
return appNamesCache
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const apps = await get<App[]>('/api/apps')
|
||||||
|
if (apps) {
|
||||||
|
appNamesCache = apps.map(a => a.name)
|
||||||
|
appNamesCacheTime = now
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// use stale cache
|
||||||
|
}
|
||||||
|
return appNamesCache
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommandNames(): string[] {
|
||||||
|
return program.commands
|
||||||
|
.filter((cmd: { _hidden?: boolean }) => !cmd._hidden)
|
||||||
|
.map((cmd: { name: () => string }) => cmd.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printBanner(): Promise<void> {
|
||||||
|
const apps = await get<App[]>('/api/apps')
|
||||||
|
if (!apps) {
|
||||||
|
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
|
||||||
|
console.log()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache app names from banner fetch
|
||||||
|
appNamesCache = apps.map(a => a.name)
|
||||||
|
appNamesCacheTime = Date.now()
|
||||||
|
|
||||||
|
const visibleApps = apps.filter(a => !a.tool)
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
// App status line
|
||||||
|
const parts = visibleApps.map(a => {
|
||||||
|
const icon = STATE_ICONS[a.state] ?? '\u25CB'
|
||||||
|
return `${icon} ${a.name}`
|
||||||
|
})
|
||||||
|
if (parts.length > 0) {
|
||||||
|
console.log(' ' + parts.join(' '))
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
const running = visibleApps.filter(a => a.state === 'running').length
|
||||||
|
const stopped = visibleApps.filter(a => a.state !== 'running').length
|
||||||
|
const summary = []
|
||||||
|
if (running) summary.push(`${running} running`)
|
||||||
|
if (stopped) summary.push(`${stopped} stopped`)
|
||||||
|
if (summary.length > 0) {
|
||||||
|
console.log(color.gray(` ${summary.join(', ')} \u2014 type "help" for commands`))
|
||||||
|
} else {
|
||||||
|
console.log(color.gray(' no apps \u2014 type "help" for commands'))
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shell(): Promise<void> {
|
||||||
|
await printBanner()
|
||||||
|
|
||||||
|
// Configure Commander to throw instead of exiting
|
||||||
|
program.exitOverride()
|
||||||
|
program.configureOutput({
|
||||||
|
writeOut: (str: string) => process.stdout.write(str),
|
||||||
|
writeErr: (str: string) => process.stderr.write(str),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
prompt: color.cyan('toes> '),
|
||||||
|
completer: (line: string, callback: (err: null, result: [string[], string]) => void) => {
|
||||||
|
const tokens = tokenize(line)
|
||||||
|
const trailing = line.endsWith(' ')
|
||||||
|
|
||||||
|
if (tokens.length === 0 || (tokens.length === 1 && !trailing)) {
|
||||||
|
// Complete command names
|
||||||
|
const partial = tokens[0] ?? ''
|
||||||
|
const commands = getCommandNames()
|
||||||
|
const hits = commands.filter(c => c.startsWith(partial))
|
||||||
|
callback(null, [hits, partial])
|
||||||
|
} else {
|
||||||
|
// Complete app names
|
||||||
|
const partial = trailing ? '' : (tokens[tokens.length - 1] ?? '')
|
||||||
|
const names = appNamesCache
|
||||||
|
const hits = names.filter(n => n.startsWith(partial))
|
||||||
|
callback(null, [hits, partial])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh app names cache in background for tab completion
|
||||||
|
fetchAppNames()
|
||||||
|
|
||||||
|
let activeAbort: AbortController | null = null
|
||||||
|
|
||||||
|
rl.on('SIGINT', () => {
|
||||||
|
if (activeAbort) {
|
||||||
|
activeAbort.abort()
|
||||||
|
activeAbort = null
|
||||||
|
console.log()
|
||||||
|
rl.prompt()
|
||||||
|
} else {
|
||||||
|
// Clear current line
|
||||||
|
rl.write(null, { ctrl: true, name: 'u' })
|
||||||
|
console.log()
|
||||||
|
rl.prompt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
rl.prompt()
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
const input = line.trim()
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
rl.prompt()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'exit' || input === 'quit') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'clear') {
|
||||||
|
console.clear()
|
||||||
|
rl.prompt()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'help') {
|
||||||
|
program.outputHelp()
|
||||||
|
rl.prompt()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = tokenize(input)
|
||||||
|
|
||||||
|
// Set up AbortController for this command
|
||||||
|
activeAbort = new AbortController()
|
||||||
|
const signal = activeAbort.signal
|
||||||
|
|
||||||
|
// Pause readline so commands can use their own prompts
|
||||||
|
rl.pause()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withSignal(signal, () => program.parseAsync(['node', 'toes', ...tokens]))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Commander throws on exitOverride — suppress help/version exits
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
const code = (err as { code: string }).code
|
||||||
|
if (code === 'commander.helpDisplayed' || code === 'commander.version') {
|
||||||
|
// Already printed, just continue
|
||||||
|
} else if (code === 'commander.unknownCommand') {
|
||||||
|
console.error(`Unknown command: ${tokens[0]}`)
|
||||||
|
} else {
|
||||||
|
// Other Commander errors (missing arg, etc.)
|
||||||
|
// Commander already printed the error message
|
||||||
|
}
|
||||||
|
} else if (signal.aborted) {
|
||||||
|
// Command was cancelled by Ctrl+C
|
||||||
|
} else {
|
||||||
|
handleError(err)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeAbort = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh app names cache after commands that might change state
|
||||||
|
fetchAppNames()
|
||||||
|
|
||||||
|
rl.resume()
|
||||||
|
rl.prompt()
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.close()
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,14 @@ import { define } from '@because/forge'
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { buildAppUrl } from '../../shared/urls'
|
import { buildAppUrl } from '../../shared/urls'
|
||||||
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
|
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
|
||||||
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
|
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||||
import { apps, getSelectedTab, isNarrow } from '../state'
|
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
|
||||||
import {
|
import {
|
||||||
ActionBar,
|
ActionBar,
|
||||||
AppSelectorChevron,
|
|
||||||
Button,
|
Button,
|
||||||
ClickableAppName,
|
ClickableAppName,
|
||||||
|
HamburgerButton,
|
||||||
|
HamburgerLine,
|
||||||
HeaderActions,
|
HeaderActions,
|
||||||
InfoLabel,
|
InfoLabel,
|
||||||
InfoRow,
|
InfoRow,
|
||||||
|
|
@ -52,14 +53,15 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
<Main>
|
<Main>
|
||||||
<MainHeader>
|
<MainHeader>
|
||||||
<MainTitle>
|
<MainTitle>
|
||||||
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
|
|
||||||
|
|
||||||
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
|
||||||
{isNarrow && (
|
{isNarrow && (
|
||||||
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
|
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
|
||||||
▼
|
<HamburgerLine />
|
||||||
</AppSelectorChevron>
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
</HamburgerButton>
|
||||||
)}
|
)}
|
||||||
|
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
|
||||||
|
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
||||||
</MainTitle>
|
</MainTitle>
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
{!app.tool && (
|
{!app.tool && (
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import type { CSSProperties } from 'hono/jsx'
|
||||||
import {
|
import {
|
||||||
apps,
|
apps,
|
||||||
selectedApp,
|
selectedApp,
|
||||||
setSelectedApp,
|
|
||||||
setSidebarSection,
|
setSidebarSection,
|
||||||
sidebarSection,
|
sidebarSection,
|
||||||
} from '../state'
|
} from '../state'
|
||||||
|
|
@ -17,19 +16,13 @@ import {
|
||||||
interface AppSelectorProps {
|
interface AppSelectorProps {
|
||||||
render: () => void
|
render: () => void
|
||||||
onSelect?: () => void
|
onSelect?: () => void
|
||||||
onDashboard?: () => void
|
|
||||||
collapsed?: boolean
|
collapsed?: boolean
|
||||||
|
large?: boolean
|
||||||
switcherStyle?: CSSProperties
|
switcherStyle?: CSSProperties
|
||||||
listStyle?: CSSProperties
|
listStyle?: CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSelector({ render, onSelect, onDashboard, collapsed, switcherStyle, listStyle }: AppSelectorProps) {
|
export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
|
||||||
const selectApp = (name: string) => {
|
|
||||||
setSelectedApp(name)
|
|
||||||
onSelect?.()
|
|
||||||
render()
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchSection = (section: 'apps' | 'tools') => {
|
const switchSection = (section: 'apps' | 'tools') => {
|
||||||
setSidebarSection(section)
|
setSidebarSection(section)
|
||||||
render()
|
render()
|
||||||
|
|
@ -43,18 +36,18 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
|
||||||
<>
|
<>
|
||||||
{!collapsed && toolApps.length > 0 && (
|
{!collapsed && toolApps.length > 0 && (
|
||||||
<SectionSwitcher style={switcherStyle}>
|
<SectionSwitcher style={switcherStyle}>
|
||||||
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
|
<SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
|
||||||
Apps
|
Apps
|
||||||
</SectionTab>
|
</SectionTab>
|
||||||
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
|
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
|
||||||
Tools
|
Tools
|
||||||
</SectionTab>
|
</SectionTab>
|
||||||
</SectionSwitcher>
|
</SectionSwitcher>
|
||||||
)}
|
)}
|
||||||
<AppList style={listStyle}>
|
<AppList style={listStyle}>
|
||||||
{collapsed && onDashboard && (
|
{collapsed && (
|
||||||
<AppItem
|
<AppItem
|
||||||
onClick={onDashboard}
|
href="/"
|
||||||
selected={!selectedApp ? true : undefined}
|
selected={!selectedApp ? true : undefined}
|
||||||
style={{ justifyContent: 'center', padding: '10px 12px' }}
|
style={{ justifyContent: 'center', padding: '10px 12px' }}
|
||||||
title="Toes"
|
title="Toes"
|
||||||
|
|
@ -65,7 +58,9 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
|
||||||
{activeApps.map(app => (
|
{activeApps.map(app => (
|
||||||
<AppItem
|
<AppItem
|
||||||
key={app.name}
|
key={app.name}
|
||||||
onClick={() => selectApp(app.name)}
|
href={`/app/${app.name}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
large={large || undefined}
|
||||||
selected={app.name === selectedApp ? true : undefined}
|
selected={app.name === selectedApp ? true : undefined}
|
||||||
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
|
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
|
||||||
title={collapsed ? app.name : undefined}
|
title={collapsed ? app.name : undefined}
|
||||||
|
|
@ -74,7 +69,7 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
|
||||||
<span style={{ fontSize: 18 }}>{app.icon}</span>
|
<span style={{ fontSize: 18 }}>{app.icon}</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span style={{ fontSize: 14 }}>{app.icon}</span>
|
<span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
|
||||||
{app.name}
|
{app.name}
|
||||||
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
|
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,48 @@
|
||||||
import { Styles } from '@because/forge'
|
import { Styles } from '@because/forge'
|
||||||
import { apps, currentView, isNarrow, selectedApp } from '../state'
|
import { openNewAppModal } from '../modals'
|
||||||
import { Layout } from '../styles'
|
import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
|
||||||
|
import {
|
||||||
|
HamburgerButton,
|
||||||
|
HamburgerLine,
|
||||||
|
Layout,
|
||||||
|
Main,
|
||||||
|
MainContent as MainContentContainer,
|
||||||
|
MainHeader,
|
||||||
|
MainTitle,
|
||||||
|
NewAppButton,
|
||||||
|
} from '../styles'
|
||||||
import { AppDetail } from './AppDetail'
|
import { AppDetail } from './AppDetail'
|
||||||
|
import { AppSelector } from './AppSelector'
|
||||||
import { DashboardLanding } from './DashboardLanding'
|
import { DashboardLanding } from './DashboardLanding'
|
||||||
import { Modal } from './modal'
|
import { Modal } from './modal'
|
||||||
import { SettingsPage } from './SettingsPage'
|
import { SettingsPage } from './SettingsPage'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
|
|
||||||
|
function MobileSidebar({ render }: { render: () => void }) {
|
||||||
|
return (
|
||||||
|
<Main>
|
||||||
|
<MainHeader>
|
||||||
|
<MainTitle>
|
||||||
|
<HamburgerButton onClick={() => { setMobileSidebar(false); render() }} title="Hide apps">
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
</HamburgerButton>
|
||||||
|
<a href="/" style={{ textDecoration: 'none', color: 'inherit' }}>🐾 Toes</a>
|
||||||
|
</MainTitle>
|
||||||
|
</MainHeader>
|
||||||
|
<MainContentContainer>
|
||||||
|
<AppSelector render={render} large />
|
||||||
|
<div style={{ padding: '12px 16px' }}>
|
||||||
|
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
||||||
|
</div>
|
||||||
|
</MainContentContainer>
|
||||||
|
</Main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MainContent({ render }: { render: () => void }) {
|
function MainContent({ render }: { render: () => void }) {
|
||||||
|
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
|
||||||
const selected = apps.find(a => a.name === selectedApp)
|
const selected = apps.find(a => a.name === selectedApp)
|
||||||
if (selected) return <AppDetail app={selected} render={render} />
|
if (selected) return <AppDetail app={selected} render={render} />
|
||||||
if (currentView === 'settings') return <SettingsPage render={render} />
|
if (currentView === 'settings') return <SettingsPage render={render} />
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,37 @@
|
||||||
import { useEffect } from 'hono/jsx'
|
import { useEffect } from 'hono/jsx'
|
||||||
import { openAppSelectorModal } from '../modals'
|
import { navigate } from '../router'
|
||||||
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
|
import { dashboardTab, isNarrow, setMobileSidebar } from '../state'
|
||||||
import {
|
import {
|
||||||
AppSelectorChevron,
|
HamburgerButton,
|
||||||
|
HamburgerLine,
|
||||||
DashboardContainer,
|
DashboardContainer,
|
||||||
DashboardHeader,
|
DashboardHeader,
|
||||||
DashboardTitle,
|
DashboardTitle,
|
||||||
SettingsGear,
|
SettingsGear,
|
||||||
StatusDot,
|
Tab,
|
||||||
StatusDotLink,
|
TabBar,
|
||||||
StatusDotsRow,
|
TabContent,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
import { update } from '../update'
|
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
|
||||||
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
|
import { Urls } from './Urls'
|
||||||
import { Vitals, initVitals } from './Vitals'
|
import { Vitals, initVitals } from './Vitals'
|
||||||
|
|
||||||
let activeTooltip: string | null = null
|
|
||||||
|
|
||||||
export function DashboardLanding({ render }: { render: () => void }) {
|
export function DashboardLanding({ render }: { render: () => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initUnifiedLogs()
|
initUnifiedLogs()
|
||||||
initVitals()
|
initVitals()
|
||||||
|
if (dashboardTab === 'logs') scrollLogsToBottom()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const narrow = isNarrow || undefined
|
const narrow = isNarrow || undefined
|
||||||
|
|
||||||
const openSettings = () => {
|
const openSettings = () => {
|
||||||
setSelectedApp(null)
|
navigate('/settings')
|
||||||
setCurrentView('settings')
|
}
|
||||||
render()
|
|
||||||
|
const switchTab = (tab: typeof dashboardTab) => {
|
||||||
|
navigate(tab === 'urls' ? '/' : `/${tab}`)
|
||||||
|
if (tab === 'logs') scrollLogsToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -40,43 +43,40 @@ export function DashboardLanding({ render }: { render: () => void }) {
|
||||||
>
|
>
|
||||||
⚙️
|
⚙️
|
||||||
</SettingsGear>
|
</SettingsGear>
|
||||||
|
{isNarrow && (
|
||||||
|
<HamburgerButton
|
||||||
|
onClick={() => { setMobileSidebar(true); render() }}
|
||||||
|
title="Show apps"
|
||||||
|
style={{ position: 'absolute', top: 16, left: 16 }}
|
||||||
|
>
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
</HamburgerButton>
|
||||||
|
)}
|
||||||
<DashboardHeader>
|
<DashboardHeader>
|
||||||
<DashboardTitle narrow={narrow}>
|
<DashboardTitle narrow={narrow}>
|
||||||
🐾 Toes
|
🐾 Toes
|
||||||
{isNarrow && (
|
|
||||||
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
|
|
||||||
▼
|
|
||||||
</AppSelectorChevron>
|
|
||||||
)}
|
|
||||||
</DashboardTitle>
|
</DashboardTitle>
|
||||||
</DashboardHeader>
|
</DashboardHeader>
|
||||||
|
|
||||||
<StatusDotsRow>
|
<TabBar centered>
|
||||||
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
|
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab>
|
||||||
<StatusDotLink
|
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>Logs</Tab>
|
||||||
key={app.name}
|
<Tab active={dashboardTab === 'metrics' || undefined} onClick={() => switchTab('metrics')}>Metrics</Tab>
|
||||||
data-tooltip={app.name}
|
</TabBar>
|
||||||
tooltipVisible={activeTooltip === app.name || undefined}
|
|
||||||
onClick={(e: Event) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (isNarrow && activeTooltip !== app.name) {
|
|
||||||
activeTooltip = app.name
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeTooltip = null
|
|
||||||
setSelectedApp(app.name)
|
|
||||||
update()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StatusDot state={app.state} data-app={app.name} />
|
|
||||||
</StatusDotLink>
|
|
||||||
))}
|
|
||||||
</StatusDotsRow>
|
|
||||||
|
|
||||||
<Vitals />
|
<TabContent active={dashboardTab === 'urls' || undefined}>
|
||||||
|
<Urls render={render} />
|
||||||
|
</TabContent>
|
||||||
|
|
||||||
|
<TabContent active={dashboardTab === 'logs' || undefined}>
|
||||||
<UnifiedLogs />
|
<UnifiedLogs />
|
||||||
|
</TabContent>
|
||||||
|
|
||||||
|
<TabContent active={dashboardTab === 'metrics' || undefined}>
|
||||||
|
<Vitals />
|
||||||
|
</TabContent>
|
||||||
</DashboardContainer>
|
</DashboardContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { apps, getSelectedTab, setSelectedTab } from '../state'
|
import { navigate } from '../router'
|
||||||
|
import { apps, getSelectedTab } from '../state'
|
||||||
import { Tab, TabBar } from '../styles'
|
import { Tab, TabBar } from '../styles'
|
||||||
import { resetToolIframe } from '../tool-iframes'
|
import { resetToolIframe } from '../tool-iframes'
|
||||||
|
|
||||||
|
|
@ -12,8 +13,7 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
|
||||||
resetToolIframe(tab, app.name)
|
resetToolIframe(tab, app.name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSelectedTab(app.name, tab)
|
navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
|
||||||
render()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all tools
|
// Find all tools
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from 'hono/jsx'
|
import { useEffect, useState } from 'hono/jsx'
|
||||||
import { getWifiConfig, saveWifiConfig } from '../api'
|
import { getWifiConfig, saveWifiConfig } from '../api'
|
||||||
import { setCurrentView } from '../state'
|
import { navigate } from '../router'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DashboardInstallCmd,
|
DashboardInstallCmd,
|
||||||
|
|
@ -31,8 +31,7 @@ export function SettingsPage({ render }: { render: () => void }) {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
setCurrentView('dashboard')
|
navigate('/')
|
||||||
render()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (e: Event) => {
|
const handleSave = async (e: Event) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { openNewAppModal } from '../modals'
|
import { openNewAppModal } from '../modals'
|
||||||
import {
|
import {
|
||||||
setCurrentView,
|
|
||||||
setSelectedApp,
|
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
} from '../state'
|
} from '../state'
|
||||||
|
|
@ -17,12 +15,6 @@ import {
|
||||||
import { AppSelector } from './AppSelector'
|
import { AppSelector } from './AppSelector'
|
||||||
|
|
||||||
export function Sidebar({ render }: { render: () => void }) {
|
export function Sidebar({ render }: { render: () => void }) {
|
||||||
const goToDashboard = () => {
|
|
||||||
setSelectedApp(null)
|
|
||||||
setCurrentView('dashboard')
|
|
||||||
render()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setSidebarCollapsed(!sidebarCollapsed)
|
setSidebarCollapsed(!sidebarCollapsed)
|
||||||
render()
|
render()
|
||||||
|
|
@ -40,7 +32,7 @@ export function Sidebar({ render }: { render: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Logo>
|
<Logo>
|
||||||
<LogoLink onClick={goToDashboard} title="Go to dashboard">
|
<LogoLink href="/" title="Go to dashboard">
|
||||||
🐾 Toes
|
🐾 Toes
|
||||||
</LogoLink>
|
</LogoLink>
|
||||||
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
|
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
|
||||||
|
|
@ -50,7 +42,7 @@ export function Sidebar({ render }: { render: () => void }) {
|
||||||
</HamburgerButton>
|
</HamburgerButton>
|
||||||
</Logo>
|
</Logo>
|
||||||
)}
|
)}
|
||||||
<AppSelector render={render} collapsed={sidebarCollapsed} onDashboard={goToDashboard} />
|
<AppSelector render={render} collapsed={sidebarCollapsed} />
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,13 @@ function renderLogs() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scrollLogsToBottom() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = document.getElementById('unified-logs-body')
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function initUnifiedLogs() {
|
export function initUnifiedLogs() {
|
||||||
if (_source) return
|
if (_source) return
|
||||||
_source = new EventSource('/api/system/logs/stream')
|
_source = new EventSource('/api/system/logs/stream')
|
||||||
|
|
|
||||||
48
src/client/components/Urls.tsx
Normal file
48
src/client/components/Urls.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { buildAppUrl } from '../../shared/urls'
|
||||||
|
import { navigate } from '../router'
|
||||||
|
import { apps, isNarrow } from '../state'
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
Tile,
|
||||||
|
TileGrid,
|
||||||
|
TileIcon,
|
||||||
|
TileName,
|
||||||
|
TileStatus,
|
||||||
|
} from '../styles'
|
||||||
|
|
||||||
|
export function Urls({ render }: { render: () => void }) {
|
||||||
|
const nonTools = apps.filter(a => !a.tool)
|
||||||
|
|
||||||
|
if (nonTools.length === 0) {
|
||||||
|
return <EmptyState>No apps installed</EmptyState>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TileGrid narrow={isNarrow || undefined}>
|
||||||
|
{nonTools.map(app => {
|
||||||
|
const url = app.tunnelUrl || buildAppUrl(app.name, location.origin)
|
||||||
|
const running = app.state === 'running'
|
||||||
|
const appPage = `/app/${app.name}`
|
||||||
|
|
||||||
|
const openAppPage = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(appPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tile
|
||||||
|
key={app.name}
|
||||||
|
href={running ? url : appPage}
|
||||||
|
target={running ? '_blank' : undefined}
|
||||||
|
narrow={isNarrow || undefined}
|
||||||
|
>
|
||||||
|
<TileStatus state={app.state} onClick={openAppPage} />
|
||||||
|
<TileIcon>{app.icon}</TileIcon>
|
||||||
|
<TileName>{app.name}</TileName>
|
||||||
|
</Tile>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TileGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { render as renderApp } from 'hono/jsx/dom'
|
import { render as renderApp } from 'hono/jsx/dom'
|
||||||
import { Dashboard } from './components'
|
import { Dashboard } from './components'
|
||||||
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow, setSelectedApp } from './state'
|
|
||||||
import { initModal } from './components/modal'
|
import { initModal } from './components/modal'
|
||||||
|
import { initRouter, navigate } from './router'
|
||||||
|
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state'
|
||||||
import { initToolIframes, updateToolIframes } from './tool-iframes'
|
import { initToolIframes, updateToolIframes } from './tool-iframes'
|
||||||
import { initUpdate } from './update'
|
import { initUpdate } from './update'
|
||||||
|
|
||||||
|
|
@ -41,14 +42,16 @@ narrowQuery.addEventListener('change', e => {
|
||||||
render()
|
render()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initialize router (sets initial state from URL and renders)
|
||||||
|
initRouter(render)
|
||||||
|
|
||||||
// SSE connection
|
// SSE connection
|
||||||
const events = new EventSource('/api/apps/stream')
|
const events = new EventSource('/api/apps/stream')
|
||||||
events.onmessage = e => {
|
events.onmessage = e => {
|
||||||
const prev = apps
|
|
||||||
setApps(JSON.parse(e.data))
|
setApps(JSON.parse(e.data))
|
||||||
|
|
||||||
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
||||||
setSelectedApp(null)
|
navigate('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
render()
|
render()
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { closeModal, openModal } from '../components/modal'
|
|
||||||
import { AppSelector } from '../components/AppSelector'
|
|
||||||
|
|
||||||
let renderFn: () => void
|
|
||||||
|
|
||||||
export function openAppSelectorModal(render: () => void) {
|
|
||||||
renderFn = render
|
|
||||||
|
|
||||||
openModal('Select App', () => (
|
|
||||||
<AppSelector
|
|
||||||
render={renderFn}
|
|
||||||
onSelect={closeModal}
|
|
||||||
switcherStyle={{ padding: '0 0 12px', marginLeft: -20, marginRight: -20, paddingLeft: 20, paddingRight: 20, marginBottom: 8 }}
|
|
||||||
listStyle={{ maxHeight: 300, overflow: 'auto' }}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
||||||
import { selectedApp, setSelectedApp } from '../state'
|
import { navigate } from '../router'
|
||||||
|
import { selectedApp } from '../state'
|
||||||
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
|
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
|
||||||
import { theme } from '../themes'
|
import { theme } from '../themes'
|
||||||
|
|
||||||
|
|
@ -32,11 +33,11 @@ async function deleteApp(input: HTMLInputElement) {
|
||||||
throw new Error(`Failed to delete app: ${res.statusText}`)
|
throw new Error(`Failed to delete app: ${res.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success - close modal and clear selection
|
// Success - close modal and navigate to dashboard
|
||||||
if (selectedApp === deleteAppTarget.name) {
|
|
||||||
setSelectedApp(null)
|
|
||||||
}
|
|
||||||
closeModal()
|
closeModal()
|
||||||
|
if (selectedApp === deleteAppTarget.name) {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
|
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
|
||||||
deleteAppDeleting = false
|
deleteAppDeleting = false
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
||||||
import { apps, setSelectedApp } from '../state'
|
import { navigate } from '../router'
|
||||||
|
import { apps } from '../state'
|
||||||
import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles'
|
import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles'
|
||||||
|
|
||||||
type TemplateType = 'ssr' | 'spa' | 'bare'
|
type TemplateType = 'ssr' | 'spa' | 'bare'
|
||||||
|
|
@ -48,9 +49,9 @@ async function createNewApp() {
|
||||||
throw new Error(data.error || 'Failed to create app')
|
throw new Error(data.error || 'Failed to create app')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success - close modal and select the new app
|
// Success - close modal and navigate to the new app
|
||||||
setSelectedApp(name)
|
|
||||||
closeModal()
|
closeModal()
|
||||||
|
navigate(`/app/${name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
newAppError = err instanceof Error ? err.message : 'Failed to create app'
|
newAppError = err instanceof Error ? err.message : 'Failed to create app'
|
||||||
newAppCreating = false
|
newAppCreating = false
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
import { closeModal, openModal, rerenderModal } from '../components/modal'
|
||||||
import { apps, setSelectedApp } from '../state'
|
import { navigate } from '../router'
|
||||||
|
import { apps } from '../state'
|
||||||
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
|
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
|
||||||
|
|
||||||
let renameAppError = ''
|
let renameAppError = ''
|
||||||
|
|
@ -58,9 +59,9 @@ async function doRenameApp(input: HTMLInputElement) {
|
||||||
throw new Error(data.error || 'Failed to rename app')
|
throw new Error(data.error || 'Failed to rename app')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success - update selection and close modal
|
// Success - close modal and navigate to renamed app
|
||||||
setSelectedApp(data.name || newName)
|
|
||||||
closeModal()
|
closeModal()
|
||||||
|
navigate(`/app/${data.name || newName}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
|
renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
|
||||||
renameAppRenaming = false
|
renameAppRenaming = false
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
export { openAppSelectorModal } from './AppSelector'
|
|
||||||
export { openDeleteAppModal } from './DeleteApp'
|
export { openDeleteAppModal } from './DeleteApp'
|
||||||
export { openNewAppModal } from './NewApp'
|
export { openNewAppModal } from './NewApp'
|
||||||
export { openRenameAppModal } from './RenameApp'
|
export { openRenameAppModal } from './RenameApp'
|
||||||
|
|
|
||||||
60
src/client/router.ts
Normal file
60
src/client/router.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
|
||||||
|
|
||||||
|
let _render: () => void
|
||||||
|
|
||||||
|
export function navigate(href: string) {
|
||||||
|
history.pushState(null, '', href)
|
||||||
|
route()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initRouter(render: () => void) {
|
||||||
|
_render = render
|
||||||
|
|
||||||
|
// Intercept link clicks
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const a = (e.target as Element).closest('a')
|
||||||
|
if (!a || !a.href || a.origin !== location.origin || a.target === '_blank') return
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||||
|
e.preventDefault()
|
||||||
|
history.pushState(null, '', a.href)
|
||||||
|
route()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle back/forward
|
||||||
|
window.addEventListener('popstate', route)
|
||||||
|
|
||||||
|
// Initial route from URL
|
||||||
|
route()
|
||||||
|
}
|
||||||
|
|
||||||
|
function route() {
|
||||||
|
setMobileSidebar(false)
|
||||||
|
const path = location.pathname
|
||||||
|
|
||||||
|
if (path.startsWith('/app/')) {
|
||||||
|
const rest = decodeURIComponent(path.slice(5))
|
||||||
|
const slashIdx = rest.indexOf('/')
|
||||||
|
const name = slashIdx === -1 ? rest : rest.slice(0, slashIdx)
|
||||||
|
const tab = slashIdx === -1 ? 'overview' : rest.slice(slashIdx + 1)
|
||||||
|
setSelectedApp(name)
|
||||||
|
setSelectedTab(name, tab)
|
||||||
|
setCurrentView('dashboard')
|
||||||
|
} else if (path === '/settings') {
|
||||||
|
setSelectedApp(null)
|
||||||
|
setCurrentView('settings')
|
||||||
|
} else if (path === '/logs') {
|
||||||
|
setSelectedApp(null)
|
||||||
|
setDashboardTab('logs')
|
||||||
|
setCurrentView('dashboard')
|
||||||
|
} else if (path === '/metrics') {
|
||||||
|
setSelectedApp(null)
|
||||||
|
setDashboardTab('metrics')
|
||||||
|
setCurrentView('dashboard')
|
||||||
|
} else {
|
||||||
|
setSelectedApp(null)
|
||||||
|
setDashboardTab('urls')
|
||||||
|
setCurrentView('dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
_render()
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,43 @@
|
||||||
import type { App } from '../shared/types'
|
import type { App } from '../shared/types'
|
||||||
|
|
||||||
|
export type DashboardTab = 'urls' | 'logs' | 'metrics'
|
||||||
|
|
||||||
// UI state (survives re-renders)
|
// UI state (survives re-renders)
|
||||||
export let currentView: 'dashboard' | 'settings' = 'dashboard'
|
export let currentView: 'dashboard' | 'settings' = 'dashboard'
|
||||||
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
|
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
|
||||||
export let selectedApp: string | null = localStorage.getItem('selectedApp')
|
export let selectedApp: string | null = null
|
||||||
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
||||||
|
export let dashboardTab: DashboardTab = 'urls'
|
||||||
|
export let mobileSidebar: boolean = false
|
||||||
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
|
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
|
||||||
|
|
||||||
// Server state (from SSE)
|
// Server state (from SSE)
|
||||||
export let apps: App[] = []
|
export let apps: App[] = []
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}')
|
export let appTabs: Record<string, string> = {}
|
||||||
|
|
||||||
// State setters
|
// State setters
|
||||||
|
export function setDashboardTab(tab: DashboardTab) {
|
||||||
|
dashboardTab = tab
|
||||||
|
}
|
||||||
|
|
||||||
export function setCurrentView(view: 'dashboard' | 'settings') {
|
export function setCurrentView(view: 'dashboard' | 'settings') {
|
||||||
currentView = view
|
currentView = view
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSelectedApp(name: string | null) {
|
export function setSelectedApp(name: string | null) {
|
||||||
selectedApp = name
|
selectedApp = name
|
||||||
if (name) {
|
|
||||||
localStorage.setItem('selectedApp', name)
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('selectedApp')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setIsNarrow(narrow: boolean) {
|
export function setIsNarrow(narrow: boolean) {
|
||||||
isNarrow = narrow
|
isNarrow = narrow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setMobileSidebar(open: boolean) {
|
||||||
|
mobileSidebar = open
|
||||||
|
}
|
||||||
|
|
||||||
export function setSidebarCollapsed(collapsed: boolean) {
|
export function setSidebarCollapsed(collapsed: boolean) {
|
||||||
sidebarCollapsed = collapsed
|
sidebarCollapsed = collapsed
|
||||||
localStorage.setItem('sidebarCollapsed', String(collapsed))
|
localStorage.setItem('sidebarCollapsed', String(collapsed))
|
||||||
|
|
@ -51,5 +58,4 @@ export const getSelectedTab = (appName: string | null) =>
|
||||||
export function setSelectedTab(appName: string | null, tab: string) {
|
export function setSelectedTab(appName: string | null, tab: string) {
|
||||||
if (!appName) return
|
if (!appName) return
|
||||||
appTabs[appName] = tab
|
appTabs[appName] = tab
|
||||||
localStorage.setItem('appTabs', JSON.stringify(appTabs))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,6 @@ export const GaugeValue = define('GaugeValue', {
|
||||||
// Unified Logs Section
|
// Unified Logs Section
|
||||||
export const LogsSection = define('LogsSection', {
|
export const LogsSection = define('LogsSection', {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 800,
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -202,3 +201,86 @@ export const LogStatus = define('LogStatus', {
|
||||||
warning: { color: '#f59e0b' },
|
warning: { color: '#f59e0b' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// App Tiles Grid
|
||||||
|
export const TileGrid = define('TileGrid', {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 900,
|
||||||
|
margin: '0 auto',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||||
|
gap: 20,
|
||||||
|
variants: {
|
||||||
|
narrow: { gridTemplateColumns: '1fr' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Tile = define('Tile', {
|
||||||
|
base: 'a',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '28px 20px 24px',
|
||||||
|
background: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
background: theme('colors-bgHover'),
|
||||||
|
borderColor: theme('colors-textFaint'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
narrow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px 20px',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TileIcon = define('TileIcon', {
|
||||||
|
fontSize: 48,
|
||||||
|
lineHeight: 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TileName = define('TileName', {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme('colors-text'),
|
||||||
|
textAlign: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TilePort = define('TilePort', {
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme('colors-textFaint'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TileStatus = define('TileStatus', {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4,
|
||||||
|
backgroundClip: 'content-box',
|
||||||
|
variants: {
|
||||||
|
state: {
|
||||||
|
error: { background: theme('colors-statusInvalid') },
|
||||||
|
invalid: { background: theme('colors-statusInvalid') },
|
||||||
|
stopped: { background: theme('colors-statusStopped') },
|
||||||
|
starting: { background: theme('colors-statusStarting') },
|
||||||
|
running: { background: theme('colors-statusRunning') },
|
||||||
|
stopping: { background: theme('colors-statusStarting') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ export {
|
||||||
LogStatus,
|
LogStatus,
|
||||||
LogText,
|
LogText,
|
||||||
LogTimestamp,
|
LogTimestamp,
|
||||||
|
Tile,
|
||||||
|
TileGrid,
|
||||||
|
TileIcon,
|
||||||
|
TileName,
|
||||||
|
TilePort,
|
||||||
|
TileStatus,
|
||||||
VitalCard,
|
VitalCard,
|
||||||
VitalLabel,
|
VitalLabel,
|
||||||
VitalsSection,
|
VitalsSection,
|
||||||
|
|
@ -23,7 +29,6 @@ export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel,
|
||||||
export {
|
export {
|
||||||
AppItem,
|
AppItem,
|
||||||
AppList,
|
AppList,
|
||||||
AppSelectorChevron,
|
|
||||||
ClickableAppName,
|
ClickableAppName,
|
||||||
DashboardContainer,
|
DashboardContainer,
|
||||||
DashboardHeader,
|
DashboardHeader,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ export const Logo = define('Logo', {
|
||||||
})
|
})
|
||||||
|
|
||||||
export const LogoLink = define('LogoLink', {
|
export const LogoLink = define('LogoLink', {
|
||||||
|
base: 'a',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
color: 'inherit',
|
||||||
|
textDecoration: 'none',
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: theme('radius-md'),
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
margin: '-4px -8px',
|
margin: '-4px -8px',
|
||||||
|
|
@ -103,6 +106,7 @@ export const SectionTab = define('SectionTab', {
|
||||||
background: theme('colors-bgSelected'),
|
background: theme('colors-bgSelected'),
|
||||||
color: theme('colors-text'),
|
color: theme('colors-text'),
|
||||||
},
|
},
|
||||||
|
large: { fontSize: 14, padding: '8px 12px' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -112,6 +116,7 @@ export const AppList = define('AppList', {
|
||||||
})
|
})
|
||||||
|
|
||||||
export const AppItem = define('AppItem', {
|
export const AppItem = define('AppItem', {
|
||||||
|
base: 'a',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -125,6 +130,7 @@ export const AppItem = define('AppItem', {
|
||||||
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
|
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
|
large: { fontSize: 18, padding: '12px 16px', gap: 12 },
|
||||||
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
|
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -166,20 +172,6 @@ export const MainTitle = define('MainTitle', {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const AppSelectorChevron = define('AppSelectorChevron', {
|
|
||||||
base: 'button',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px 8px',
|
|
||||||
marginLeft: 4,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme('colors-textMuted'),
|
|
||||||
selectors: {
|
|
||||||
'&:hover': { color: theme('colors-text') },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ClickableAppName = define('ClickableAppName', {
|
export const ClickableAppName = define('ClickableAppName', {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: theme('radius-md'),
|
||||||
|
|
@ -233,6 +225,7 @@ export const DashboardContainer = define('DashboardContainer', {
|
||||||
|
|
||||||
export const DashboardHeader = define('DashboardHeader', {
|
export const DashboardHeader = define('DashboardHeader', {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
width: '100%',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const DashboardTitle = define('DashboardTitle', {
|
export const DashboardTitle = define('DashboardTitle', {
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,12 @@ export const TabBar = define('TabBar', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 24,
|
gap: 24,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
|
variants: {
|
||||||
|
centered: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Tab = define('Tab', {
|
export const Tab = define('Tab', {
|
||||||
|
|
@ -159,6 +165,7 @@ export const TabContent = define('TabContent', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
4
src/lib/config.ts
Normal file
4
src/lib/config.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { hostname } from 'os'
|
||||||
|
|
||||||
|
export const HOSTNAME = hostname()
|
||||||
|
export const LOCAL_HOST = `${HOSTNAME}.local`
|
||||||
|
|
@ -8,7 +8,11 @@ const router = Hype.router()
|
||||||
// individual events so apps can react to specific lifecycle changes.
|
// individual events so apps can react to specific lifecycle changes.
|
||||||
router.sse('/stream', (send) => {
|
router.sse('/stream', (send) => {
|
||||||
const unsub = onEvent(event => send(event))
|
const unsub = onEvent(event => send(event))
|
||||||
return unsub
|
const heartbeat = setInterval(() => send('', 'ping'), 60_000)
|
||||||
|
return () => {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
unsub()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
import { DEFAULT_EMOJI } from '@types'
|
||||||
import { buildAppUrl, toSubdomain } from '@urls'
|
import { buildAppUrl, toSubdomain } from '@urls'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
|
||||||
import { hostname } from 'os'
|
import { LOCAL_HOST } from '%config'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { loadAppEnv } from '../tools/env'
|
import { loadAppEnv } from '../tools/env'
|
||||||
import { publishApp, unpublishAll, unpublishApp } from './mdns'
|
import { publishApp, unpublishAll, unpublishApp } from './mdns'
|
||||||
|
|
@ -16,7 +16,7 @@ export type { AppState } from '@types'
|
||||||
export const APPS_DIR = process.env.APPS_DIR ?? resolve(join(process.env.DATA_DIR ?? '.', 'apps'))
|
export const APPS_DIR = process.env.APPS_DIR ?? resolve(join(process.env.DATA_DIR ?? '.', 'apps'))
|
||||||
export const TOES_DIR = process.env.TOES_DIR ?? join(process.env.DATA_DIR ?? '.', 'toes')
|
export const TOES_DIR = process.env.TOES_DIR ?? join(process.env.DATA_DIR ?? '.', 'toes')
|
||||||
|
|
||||||
const defaultHost = process.env.NODE_ENV === 'production' ? `${hostname()}.local` : 'localhost'
|
const defaultHost = process.env.NODE_ENV === 'production' ? LOCAL_HOST : 'localhost'
|
||||||
export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
|
export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
|
||||||
|
|
||||||
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import systemRouter from './api/system'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { cleanupStalePublishers } from './mdns'
|
import { cleanupStalePublishers } from './mdns'
|
||||||
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
|
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
|
||||||
|
import { Shell } from './shell'
|
||||||
import type { Server } from 'bun'
|
import type { Server } from 'bun'
|
||||||
import type { WsData } from './proxy'
|
import type { WsData } from './proxy'
|
||||||
|
|
||||||
|
|
@ -89,7 +90,7 @@ async function buildBinary(name: string): Promise<boolean> {
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install script: curl -fsSL http://toes.local/install | bash
|
// Install script: curl -fsSL http://<hostname>.local/install | bash
|
||||||
app.get('/install', c => {
|
app.get('/install', c => {
|
||||||
if (!TOES_URL) return c.text('TOES_URL is not configured', 500)
|
if (!TOES_URL) return c.text('TOES_URL is not configured', 500)
|
||||||
const script = INSTALL_SCRIPT.replace('__TOES_URL__', TOES_URL)
|
const script = INSTALL_SCRIPT.replace('__TOES_URL__', TOES_URL)
|
||||||
|
|
@ -113,6 +114,13 @@ app.get('/dist/:file', async c => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// SPA routes — serve the shell for all client-side paths
|
||||||
|
app.get('/app/:name/:tab', c => c.html(<Shell />))
|
||||||
|
app.get('/app/:name', c => c.html(<Shell />))
|
||||||
|
app.get('/logs', c => c.html(<Shell />))
|
||||||
|
app.get('/metrics', c => c.html(<Shell />))
|
||||||
|
app.get('/settings', c => c.html(<Shell />))
|
||||||
|
|
||||||
cleanupStalePublishers()
|
cleanupStalePublishers()
|
||||||
await initApps()
|
await initApps()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { toSubdomain } from '@urls'
|
import { toSubdomain } from '@urls'
|
||||||
|
import { LOCAL_HOST } from '%config'
|
||||||
import { networkInterfaces } from 'os'
|
import { networkInterfaces } from 'os'
|
||||||
import { hostLog } from './tui'
|
import { hostLog } from './tui'
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ export function cleanupStalePublishers() {
|
||||||
if (!isEnabled) return
|
if (!isEnabled) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = Bun.spawnSync(['pkill', '-f', 'avahi-publish.*toes\\.local'])
|
const result = Bun.spawnSync(['pkill', '-f', `avahi-publish.*${LOCAL_HOST}`])
|
||||||
if (result.exitCode === 0) {
|
if (result.exitCode === 0) {
|
||||||
hostLog('mDNS: cleaned up stale avahi-publish processes')
|
hostLog('mDNS: cleaned up stale avahi-publish processes')
|
||||||
}
|
}
|
||||||
|
|
@ -41,22 +42,22 @@ export function publishApp(name: string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = `${toSubdomain(name)}.toes.local`
|
const host = `${toSubdomain(name)}.${LOCAL_HOST}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
|
const proc = Bun.spawn(['avahi-publish', '-a', host, '-R', ip], {
|
||||||
stdout: 'ignore',
|
stdout: 'ignore',
|
||||||
stderr: 'ignore',
|
stderr: 'ignore',
|
||||||
})
|
})
|
||||||
|
|
||||||
_publishers.set(name, proc)
|
_publishers.set(name, proc)
|
||||||
hostLog(`mDNS: published ${hostname} -> ${ip}`)
|
hostLog(`mDNS: published ${host} -> ${ip}`)
|
||||||
|
|
||||||
proc.exited.then(() => {
|
proc.exited.then(() => {
|
||||||
_publishers.delete(name)
|
_publishers.delete(name)
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
hostLog(`mDNS: failed to publish ${hostname}`)
|
hostLog(`mDNS: failed to publish ${host}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,7 +69,7 @@ export function unpublishApp(name: string) {
|
||||||
|
|
||||||
proc.kill()
|
proc.kill()
|
||||||
_publishers.delete(name)
|
_publishers.delete(name)
|
||||||
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
|
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unpublishAll() {
|
export function unpublishAll() {
|
||||||
|
|
@ -76,7 +77,7 @@ export function unpublishAll() {
|
||||||
|
|
||||||
for (const [name, proc] of _publishers) {
|
for (const [name, proc] of _publishers) {
|
||||||
proc.kill()
|
proc.kill()
|
||||||
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
|
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
|
||||||
}
|
}
|
||||||
_publishers.clear()
|
_publishers.clear()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,18 +146,19 @@ export function renameTunnelConfig(oldName: string, newName: string) {
|
||||||
saveConfig(config)
|
saveConfig(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelReconnect(appName: string) {
|
function cancelReconnect(appName: string, resetAttempts = true) {
|
||||||
const timer = _reconnectTimers.get(appName)
|
const timer = _reconnectTimers.get(appName)
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
_reconnectTimers.delete(appName)
|
_reconnectTimers.delete(appName)
|
||||||
}
|
}
|
||||||
_reconnectAttempts.delete(appName)
|
if (resetAttempts) _reconnectAttempts.delete(appName)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTunnel(appName: string, port: number, subdomain?: string) {
|
function openTunnel(appName: string, port: number, subdomain?: string, isReconnect = false) {
|
||||||
// Cancel any pending reconnect timer to prevent duplicate loops
|
// Cancel any pending reconnect timer to prevent duplicate loops
|
||||||
cancelReconnect(appName)
|
// but preserve attempts counter during reconnection so backoff works
|
||||||
|
cancelReconnect(appName, !isReconnect)
|
||||||
|
|
||||||
// Close existing tunnel if any
|
// Close existing tunnel if any
|
||||||
const existing = _tunnels.get(appName)
|
const existing = _tunnels.get(appName)
|
||||||
|
|
@ -232,7 +233,7 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
|
||||||
const config = loadConfig()
|
const config = loadConfig()
|
||||||
if (!config[appName]) return
|
if (!config[appName]) return
|
||||||
hostLog(`Tunnel reconnecting: ${appName}`)
|
hostLog(`Tunnel reconnecting: ${appName}`)
|
||||||
openTunnel(appName, port, config[appName]?.subdomain)
|
openTunnel(appName, port, config[appName]?.subdomain, true)
|
||||||
}, delay)
|
}, delay)
|
||||||
|
|
||||||
_reconnectTimers.set(appName, timer)
|
_reconnectTimers.set(appName, timer)
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,10 @@ function ensureConnection() {
|
||||||
buf = lines.pop()!
|
buf = lines.pop()!
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.startsWith('data: ')) continue
|
if (!line.startsWith('data: ')) continue
|
||||||
|
const payload = line.slice(6)
|
||||||
|
if (!payload) continue
|
||||||
try {
|
try {
|
||||||
const event: ToesEvent = JSON.parse(line.slice(6))
|
const event: ToesEvent = JSON.parse(payload)
|
||||||
_listeners.forEach(l => {
|
_listeners.forEach(l => {
|
||||||
if (l.types.includes(event.type)) l.callback(event)
|
if (l.types.includes(event.type)) l.callback(event)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user