Compare commits

..

18 Commits

Author SHA1 Message Date
894ae7e497 Replace .npmrc with bunfig.toml for Bun-native registry config
Also bump git app's @because/toes dependency to 0.0.10 for VALID_NAME export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:54:12 -07:00
21e300df90 Show tabs only when both app/tool repos exist 2026-03-09 00:15:57 -07:00
267e4e59f7 Add tabs to separate apps and tool repos 2026-03-09 00:14:58 -07:00
946cdb1794 Show tunnel URL for public repos in git clone hints 2026-03-09 00:08:33 -07:00
0e943bda2a Use sneaker host header as base URL 2026-03-08 23:49:23 -07:00
eef2fabd71 share 2026-03-08 23:43:30 -07:00
b410a74d15 Refactor app building and simplify gitUrl 2026-03-08 23:38:15 -07:00
d9533032bc Use git URL from tunnel if available 2026-03-08 23:26:12 -07:00
e0347444aa Add share field to app type and show share button 2026-03-08 23:17:35 -07:00
423c9588da gotcha 2026-03-08 23:06:48 -07:00
ecae0b4a5c 0.0.12 2026-03-08 23:03:16 -07:00
f16201114e Replace EventSource with fetch-based SSE with reconnect 2026-03-08 23:03:00 -07:00
758ad67fd4 ok 2026-03-08 23:01:17 -07:00
711a9db55e o that 2026-03-08 23:00:13 -07:00
5954959208 0.0.11 2026-03-08 22:59:10 -07:00
0aa375f037 Add ANSI color and styling to shell scripts 2026-03-08 22:18:53 -07:00
98c09dd843 npmrc 2026-03-08 22:05:29 -07:00
26189e9e4d Bump @because/toes to 0.0.10 2026-03-08 22:04:26 -07:00
16 changed files with 324 additions and 124 deletions

View File

@ -7,7 +7,7 @@
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "0.0.9",
"@because/toes": "^0.0.12",
},
"devDependencies": {
"@types/bun": "latest",
@ -22,15 +22,15 @@
"@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/sneaker": ["@because/sneaker@0.0.3", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.3.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-4cG8w/tYPGbDtLw89k1PiASJKfWUdd1NXv+GKad2d7Ckw3FpZ+dnN2+gR2ihs81dqAkNaZomo+9RznBju2WaOw=="],
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
"@because/toes": ["@because/toes@0.0.9", "https://npm.nose.space/@because/toes/-/toes-0.0.9.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.3", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-QkRTPLkPW8awH1DC0vqFR9oS3q+xOZ00eL6VuFElM1fatqHnLO8zKKJcyvy1pU8ZKS4Ev7F+OoyBzRuo6OTa/g=="],
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
"@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/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@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=="],
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],

2
apps/git/bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[install]
registry = "https://npm.nose.space"

View File

@ -15,26 +15,6 @@ const TOES_URL = process.env.TOES_URL!
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
const TOGGLE_SCRIPT = `
function toggleVisibility(btn) {
var repo = btn.dataset.repo;
var current = btn.dataset.visibility;
var next = current === 'public' ? 'private' : 'public';
btn.dataset.visibility = next;
btn.textContent = next;
btn.classList.toggle('public', next === 'public');
fetch('/api/visibility/' + encodeURIComponent(repo), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility: next })
}).catch(function() {
btn.dataset.visibility = current;
btn.textContent = current;
btn.classList.toggle('public', current === 'public');
});
}
`
const app = new Hype({ prettyHTML: false, layout: false })
const deployLocks = new Map<string, Promise<void>>()
@ -113,6 +93,31 @@ const RepoName = define('RepoName', {
color: theme('colors-text'),
})
const Tab = define('Tab', {
base: 'button',
padding: '6px 0',
background: 'none',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
fontSize: '14px',
color: theme('colors-textMuted'),
states: {
':hover': { color: theme('colors-text') },
'.active': {
color: theme('colors-text'),
borderBottomColor: theme('colors-primary'),
fontWeight: '500',
},
},
})
const TabBar = define('TabBar', {
display: 'flex',
gap: '24px',
marginBottom: '20px',
})
const Toggle = define('Toggle', {
base: 'button',
display: 'inline-flex',
@ -156,7 +161,8 @@ interface LayoutProps {
interface RepoListPageProps {
baseUrl: string
external: boolean
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility }>
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility; tool: boolean }>
tunnelUrl?: string
}
type Visibility = 'public' | 'private'
@ -543,7 +549,56 @@ function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
)
}
function RepoListPage({ baseUrl, external, repos }: RepoListPageProps) {
function RepoListItems({ baseUrl, external, repos, tunnelUrl }: {
baseUrl: string
external: boolean
repos: RepoListPageProps['repos']
tunnelUrl?: string
}) {
if (repos.length === 0) {
return <HelpText>No repositories yet.</HelpText>
}
return (
<RepoList>
{repos.map(({ name, commits, branch, visibility }) => (
<RepoItem>
<div>
<RepoName>{name}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{name}
</HelpText>
{!external && tunnelUrl && visibility === 'public' && (
<HelpText style="margin: 2px 0 0; font-size: 12px">
git clone {tunnelUrl}/{name}
</HelpText>
)}
</div>
<div style="display: flex; gap: 8px; align-items: center">
{!external && (
<Toggle
class={visibility === 'public' ? 'public' : ''}
data-repo={name}
data-visibility={visibility}
onclick="toggleVisibility(this)"
>
{visibility === 'public' ? 'public' : 'private'}
</Toggle>
)}
<Badge>{branch}</Badge>
{commits
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
: <Badge>empty</Badge>}
</div>
</RepoItem>
))}
</RepoList>
)
}
function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) {
const appRepos = repos.filter(r => !r.tool)
const toolRepos = repos.filter(r => r.tool)
return (
<Layout title="Git">
{!external && (
@ -565,38 +620,31 @@ function RepoListPage({ baseUrl, external, repos }: RepoListPageProps) {
</>
)}
{repos.length > 0 && (
{repos.length > 0 && appRepos.length > 0 && toolRepos.length > 0 && (
<>
<Heading>Repositories</Heading>
<RepoList>
{repos.map(({ name, commits, branch, visibility }) => (
<RepoItem>
<div>
<RepoName>{name}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{name}
</HelpText>
</div>
<div style="display: flex; gap: 8px; align-items: center">
{!external && (
<Toggle
class={visibility === 'public' ? 'public' : ''}
data-repo={name}
data-visibility={visibility}
onclick="toggleVisibility(this)"
>
{visibility === 'public' ? 'public' : 'private'}
</Toggle>
)}
<Badge>{branch}</Badge>
{commits
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
: <Badge>empty</Badge>}
</div>
</RepoItem>
))}
</RepoList>
{!external && <script src="/toggle.js" />}
<TabBar>
<Tab class="active" data-tab="tab-apps" onclick="switchTab(this)">Apps</Tab>
<Tab data-tab="tab-tools" onclick="switchTab(this)">Tools</Tab>
</TabBar>
<div>
<div id="tab-apps">
<RepoListItems baseUrl={baseUrl} external={external} repos={appRepos} tunnelUrl={tunnelUrl} />
</div>
<div id="tab-tools" style="display: none">
<RepoListItems baseUrl={baseUrl} external={external} repos={toolRepos} tunnelUrl={tunnelUrl} />
</div>
</div>
{!external && <script src="/client/toggle.js" />}
<script src="/client/tabs.js" />
</>
)}
{repos.length > 0 && (appRepos.length === 0 || toolRepos.length === 0) && (
<>
<Heading>Repositories</Heading>
<RepoListItems baseUrl={baseUrl} external={external} repos={repos} tunnelUrl={tunnelUrl} />
{!external && <script src="/client/toggle.js" />}
</>
)}
@ -624,9 +672,6 @@ app.get('/styles.css', c =>
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
)
app.get('/toggle.js', c =>
c.text(TOGGLE_SCRIPT, 200, { 'Content-Type': 'application/javascript; charset=utf-8' }),
)
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
@ -641,8 +686,13 @@ app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
return c.text('Invalid service', 400)
}
if (service === 'git-receive-pack' && c.req.header('x-sneaker')) {
return c.text('Push access denied over sneaker', 403)
if (c.req.header('x-sneaker')) {
if (service === 'git-receive-pack') {
return c.text('Push access denied over sneaker', 403)
}
if (await getVisibility(repoParam) !== 'public') {
return c.text('Repository not found', 404)
}
}
if (service === 'git-receive-pack') {
@ -666,6 +716,10 @@ app.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], a
return c.text('Invalid repository name', 400)
}
if (c.req.header('x-sneaker') && await getVisibility(repoParam) !== 'public') {
return c.text('Repository not found', 404)
}
const bare = repoPath(repoParam)
if (!(await dirExists(bare))) {
return c.text('Repository not found', 404)
@ -748,8 +802,9 @@ app.post('/api/visibility/:repo', async c => {
app.get('/', async c => {
const appName = c.req.query('app')
const baseUrl = APP_URL
const external = !!c.req.header('x-sneaker')
const sneakerHost = c.req.header('x-sneaker')
const external = !!sneakerHost
const baseUrl = sneakerHost ? `https://${sneakerHost}` : APP_URL
// When viewing a specific app, only show that app's repo
if (appName) {
@ -764,6 +819,19 @@ app.get('/', async c => {
// No app selected — show all repos
const repos = await listRepos()
// Fetch all apps to determine which repos are tools
let toolSet = new Set<string>()
try {
const res = await fetch(`${TOES_URL}/api/apps`)
if (res.ok) {
const apps = await res.json() as Array<{ name: string; tool?: boolean | string }>
for (const a of apps) {
if (a.tool) toolSet.add(a.name)
}
}
} catch {}
const repoData = await Promise.all(repos.map(async name => {
const bare = repoPath(name)
const [commits, branch, visibility] = await Promise.all([
@ -771,7 +839,7 @@ app.get('/', async c => {
getDefaultBranch(bare),
getVisibility(name),
])
return { name, commits, branch, visibility }
return { name, commits, branch, visibility, tool: toolSet.has(name) }
}))
// Hide private repos from external (sneaker) requests
@ -779,7 +847,19 @@ app.get('/', async c => {
? repoData.filter(r => r.visibility === 'public')
: repoData
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} />)
// Fetch tunnel URL for the git tool so we can show it for public repos
let tunnelUrl: string | undefined
if (!external) {
try {
const res = await fetch(`${TOES_URL}/api/apps/git`)
if (res.ok) {
const info = await res.json() as { tunnelUrl?: string }
tunnelUrl = info.tunnelUrl
}
} catch {}
}
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} tunnelUrl={tunnelUrl} />)
})
export default app.defaults

View File

@ -0,0 +1,13 @@
function switchTab(btn: HTMLButtonElement) {
const tabs = btn.parentElement!.querySelectorAll('button')
for (const tab of tabs) tab.classList.remove('active')
btn.classList.add('active')
const panels = btn.parentElement!.nextElementSibling!.children
for (const panel of panels) (panel as HTMLElement).style.display = 'none'
const target = document.getElementById(btn.dataset.tab!)
if (target) target.style.display = 'block'
}
Object.assign(window, { switchTab })

View File

@ -0,0 +1,19 @@
function toggleVisibility(btn: HTMLButtonElement) {
const repo = btn.dataset.repo!
const current = btn.dataset.visibility!
const next = current === 'public' ? 'private' : 'public'
btn.dataset.visibility = next
btn.textContent = next
btn.classList.toggle('public', next === 'public')
fetch('/api/visibility/' + encodeURIComponent(repo), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility: next }),
}).catch(() => {
btn.dataset.visibility = current
btn.textContent = current
btn.classList.toggle('public', current === 'public')
})
}
Object.assign(window, { toggleVisibility })

View File

@ -8,6 +8,7 @@
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.4",
"@because/toes": "^0.0.12",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5",
@ -28,6 +29,8 @@
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],

45
color-preview.sh Executable file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
echo ""
echo " ┌── CLI installer (curl | bash) ──────────────┐"
echo ""
echo " ${b}🐾 toes cli${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " ${d}Fetching macos/arm64...${r}"
echo " ${g}Installed to${r} ${b}/Users/chris/.local/bin/toes${r}"
echo ""
echo " ${y}Add /Users/chris/.local/bin to your \$PATH, then:${r}"
echo " Run ${c}toes${r} to get started."
echo ""
echo ""
echo " ┌── After deploy ─────────────────────────────┐"
echo ""
echo " ${b}${g}🐾 Deployed${r} to ${b}pi@toes.local${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://toes.local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://toes.local/install | bash${r}"
echo ""
echo ""
echo " ┌── After server install ─────────────────────┐"
echo ""
echo " ${d}╔══════════════════════════════════╗${r}"
echo " ${d}${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
echo " ${d}╚══════════════════════════════════╝${r}"
echo ""
echo " ${d}>>${r} Updating system packages"
echo " ${d}>>${r} Installing bun"
echo " ${d}>>${r} Building"
echo ""
echo " ${b}${g}🐾 toes abc1234 is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://toes.local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://toes.local/install | bash${r}"
echo ""

View File

@ -15,18 +15,20 @@ DATA_DIR=~/data
# ── Helpers ──────────────────────────────────────────────
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
quiet() { "$@" > /dev/null 2>&1; }
info() { echo ">> $1"; }
info() { echo " ${d}>>${r} $1"; }
fail() { echo "ERROR: $1" >&2; exit 1; }
fail() { echo " ${y}ERROR:${r} $1" >&2; exit 1; }
# ── Preflight ────────────────────────────────────────────
echo ""
echo " ╔══════════════════════════════════╗"
echo " ║ 🐾 toes - personal web appliance ║"
echo " ╚══════════════════════════════════╝"
echo " ${d}╔══════════════════════════════════╗${r}"
echo " ${d}${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
echo " ${d}╚══════════════════════════════════╝${r}"
echo ""
[ "$(whoami)" = "toes" ] || fail "Must be run as the 'toes' user."
@ -125,9 +127,11 @@ sudo systemctl restart toes
VERSION=$(git describe --tags --always 2>/dev/null || echo "unknown")
echo ""
echo " toes $VERSION is running!"
echo " http://$(hostname).local"
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Install the CLI on your local machine:"
echo " curl -fsSL http://$(hostname).local/install | bash"
echo " Dashboard: ${c}http://$(hostname).local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"
echo ""

View File

@ -1,6 +1,6 @@
{
"name": "@because/toes",
"version": "0.0.10",
"version": "0.0.12",
"description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts",
"type": "module",
@ -47,6 +47,7 @@
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.4",
"@because/toes": "^0.0.12",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5"

View File

@ -57,9 +57,14 @@ done
sudo systemctl restart toes.service
SCRIPT
echo "=> Deployed to $SSH_HOST"
echo "=> Visit $URL"
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' r=$'\033[0m'
echo ""
echo " Install the CLI on your local machine:"
echo " curl -fsSL $URL/install | bash"
echo " ${b}${g}🐾 Deployed${r} to ${b}$SSH_HOST${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}$URL${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL $URL/install | bash${r}"
echo ""

View File

@ -64,7 +64,7 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle>
<HeaderActions>
{!app.tool && (
{(!app.tool || app.share) && (
app.tunnelUrl
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
: app.tunnelEnabled

View File

@ -28,10 +28,9 @@ function convert(app: BackendApp): SharedApp {
router.sse('/stream', (send) => {
let queue = Promise.resolve()
const broadcast = () => {
const apps: SharedApp[] = allApps().map(({
name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl
}) => ({
name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl,
const apps: SharedApp[] = allApps().map(app => ({
...convert(app),
logs: app.logs,
}))
queue = queue.then(() => send(apps))
}

View File

@ -158,11 +158,7 @@ export function registerApp(dir: string) {
const { pkg, error } = loadApp(dir)
const state: AppState = error ? 'invalid' : 'stopped'
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
const tool = pkg.toes?.tool
const apps = pkg.toes?.apps
const dashboard = pkg.toes?.dashboard
_apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard })
_apps.set(dir, buildApp(dir, pkg, state, error))
update()
emit({ type: 'app:create', app: dir })
if (!error) {
@ -301,6 +297,15 @@ export function updateAppIcon(dir: string, icon: string) {
}
}
const buildApp = (dir: string, pkg: any, state: AppState, error?: string): App => ({
name: dir, state, error,
icon: pkg.toes?.icon ?? DEFAULT_EMOJI,
tool: pkg.toes?.tool,
apps: pkg.toes?.apps,
dashboard: pkg.toes?.dashboard,
share: pkg.toes?.share,
})
const clearTimers = (app: App) => {
if (app.startupTimer) {
clearTimeout(app.startupTimer)
@ -349,11 +354,7 @@ function discoverApps() {
for (const dir of allAppDirs()) {
const { pkg, error } = loadApp(dir)
const state: AppState = error ? 'invalid' : 'stopped'
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
const tool = pkg.toes?.tool
const apps = pkg.toes?.apps
const dashboard = pkg.toes?.dashboard
_apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard })
_apps.set(dir, buildApp(dir, pkg, state, error))
}
update()
}

View File

@ -1,19 +1,21 @@
#!/usr/bin/env bash
set -e
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
darwin) OS=macos ;;
linux) OS=linux ;;
*) echo "🐾 Unsupported OS: $OS" >&2; exit 1 ;;
*) echo "${y}Unsupported OS: $OS${r}" >&2; exit 1 ;;
esac
case "$ARCH" in
x86_64) ARCH=x64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) echo "🐾 Unsupported arch: $ARCH" >&2; exit 1 ;;
*) echo "${y}Unsupported arch: $ARCH${r}" >&2; exit 1 ;;
esac
BINARY="toes-${OS}-${ARCH}"
@ -28,11 +30,18 @@ else
fi
mkdir -p "$INSTALL_DIR"
echo "🐾 Downloading toes CLI (${OS}/${ARCH})..."
echo ""
echo " ${b}🐾 toes cli${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " ${d}Fetching ${OS}/${ARCH}...${r}"
curl -fsSL "$URL" -o "$INSTALL_DIR/toes"
chmod +x "$INSTALL_DIR/toes"
echo "🐾 Installed toes to $INSTALL_DIR/toes"
echo " ${g}Installed to${r} ${b}$INSTALL_DIR/toes${r}"
echo ""
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
echo "🐾 Add $INSTALL_DIR to your PATH to use toes globally"
echo " ${y}Add $INSTALL_DIR to your \$PATH, then:${r}"
fi
echo " Run ${c}toes${r} to get started."
echo ""

View File

@ -31,6 +31,7 @@ export type App = {
tool?: boolean | string
apps?: boolean
dashboard?: boolean
share?: boolean
tunnelEnabled?: boolean
tunnelUrl?: string
}

View File

@ -11,36 +11,54 @@ interface Listener {
const _listeners = new Set<Listener>()
let _source: EventSource | undefined
let _abort: AbortController | undefined
function ensureConnection() {
if (_source) return
if (_abort) return
const url = `${process.env.TOES_URL}/api/events/stream`
_source = new EventSource(url)
_source.onerror = () => {
if (_source?.readyState === EventSource.CLOSED) {
console.warn('[toes] Event stream closed unexpectedly')
_source = undefined
}
}
_source.onmessage = (e) => {
try {
const event: ToesEvent = JSON.parse(e.data)
_listeners.forEach(l => {
if (l.types.includes(event.type)) l.callback(event)
})
} catch {
// Ignore malformed events
}
}
_abort = new AbortController()
connect(url, _abort.signal)
}
function closeConnection() {
if (_source) {
_source.close()
_source = undefined
if (_abort) {
_abort.abort()
_abort = undefined
}
}
async function connect(url: string, signal: AbortSignal) {
while (!signal.aborted) {
try {
const res = await fetch(url, { signal })
if (!res.ok || !res.body) throw new Error(`SSE ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop()!
for (const part of parts) {
const line = part.split('\n').find(l => l.startsWith('data:'))
if (!line) continue
try {
const event: ToesEvent = JSON.parse(line.slice(5).trim())
_listeners.forEach(l => {
if (l.types.includes(event.type)) l.callback(event)
})
} catch {
// Ignore malformed events
}
}
}
} catch (err) {
if (signal.aborted) return
console.warn('[toes] Event stream error, reconnecting...')
}
if (!signal.aborted) await new Promise(r => setTimeout(r, 2000))
}
}