Compare commits

..

206 Commits

Author SHA1 Message Date
d53e8c8cf1 Add HTTP guide and WebSocket header forwarding 2026-05-14 00:25:05 -07:00
75b40b7ed1 Add env var paste parsing and improve SSE reconnection 2026-05-14 00:17:17 -07:00
be6d4f58ff Fix tar macOS compatibility and add deploy logs 2026-05-13 23:41:27 -07:00
70d9fd6fbb Add --no-xattrs flag and reorder mkdir 2026-05-13 23:33:34 -07:00
a8c4975ed5 Add remote:deploy script for release deployment 2026-05-13 23:32:10 -07:00
e5e82f3085 Add comprehensive documentation for releases and deployment 2026-05-13 23:23:29 -07:00
3baf943a53 Switch from git clone to archive-based install 2026-05-12 20:35:43 -07:00
7935469776 Refactor app reload logic into dedicated function 2026-05-12 18:29:05 -07:00
b303fa1eef Refactor app reload to handle state transitions 2026-05-12 18:19:23 -07:00
c634d488b6 Set Host header for static app tunnel routing 2026-05-11 16:15:49 -07:00
31ca1d9849 Refactor app reload logic into reloadApp function 2026-05-11 15:52:01 -07:00
3743a01c28 Add SERVER_PORT export and fix tunnel port selection 2026-05-11 14:52:44 -07:00
875c89de18 Add app publish/unpublish lifecycle hooks 2026-05-11 14:46:55 -07:00
c0276389eb Add static site support to apps 2026-05-11 14:37:12 -07:00
44dc7527fe Add .rev/ to gitignore 2026-05-11 14:15:29 -07:00
2937fb2372 Add mDNS republish logic with exponential backoff 2026-04-13 16:55:07 -07:00
c66a40df96 Add server uptime tracking to settings 2026-04-13 16:53:41 -07:00
00c37bd9e8 0.0.19 2026-04-04 19:36:32 -07:00
99492a35e8 Fix SSE ping heartbeat message format 2026-04-04 19:36:28 -07:00
cf018004a2 Bump @because/toes to 0.0.18 2026-04-04 15:57:00 -07:00
efead147fb 0.0.18 2026-04-04 15:56:36 -07:00
7ca1f94160 Add payload validation before parsing JSON 2026-04-04 15:46:54 -07:00
e6afc0d797 0.0.17 2026-04-04 15:05:10 -07:00
211e441dd4 Update appUrl import path to @because/toes/tools 2026-04-04 15:05:04 -07:00
184a77f909 0.0.16 2026-04-04 15:01:03 -07:00
70f52a9b55 Add x-app-url header to tunnel requests 2026-04-04 14:56:29 -07:00
eb2e5a4436 Add appUrl() helper and x-app-url header 2026-04-04 14:40:07 -07:00
db46287695 Remove old CLI binary before install 2026-03-27 15:02:45 -07:00
5ab634e245 Enable colored output in CLI 2026-03-27 11:56:16 -07:00
5f4d512cdf Bump @because/toes to 0.0.15 2026-03-27 11:45:05 -07:00
5e13948be3 0.0.15 2026-03-25 21:12:15 -07:00
164abebeba my my 2026-03-25 21:12:13 -07:00
433eb0b990 0.0.14 2026-03-25 21:12:00 -07:00
5ce65b3096 oh, that too 2026-03-25 21:11:59 -07:00
3c3a90b4f5 0.0.13 2026-03-25 21:10:58 -07:00
0e467a1bdf Fix git init failing when no default branch or user config is set 2026-03-24 18:40:06 -07:00
7588ef5564 Reorder setup steps and remove quiet wrapper 2026-03-23 18:26:31 -07:00
df05cbd3aa Add error handling for lsof and pgrep commands 2026-03-23 16:58:44 -07:00
1f0c7bd099 Remove try-catch from perfToggle and fix perf timing flag read
Let errors propagate to the caller instead of catching locally,
simplify the request body construction, and snapshot perf.timing
before the fetch to avoid a TOCTOU race.
2026-03-19 11:50:10 -07:00
4cc0ff2bed Inline PerfState interface and move subscriptions before routes
The PerfState interface was only used twice, so inline it. Move
onChange/onHostLog subscriptions above the route definitions to keep
side-effects grouped. Skip perf.now() in proxy when timing is off.
2026-03-19 11:29:17 -07:00
9a19c0a861 Consolidate perfTiming state into a single perf object
Removes the separate variable and setter in favor of a plain object,
making the mutable state easier to track and eliminating a needless
abstraction.
2026-03-19 11:21:50 -07:00
b9f94a6c98 Move setPerfTiming next to perfTiming and always capture request start time
Colocate the setter with its variable for readability. Remove the
conditional around performance.now() since the call is negligible
and simplifies the timing logic.
2026-03-19 11:09:27 -07:00
c42c73fe70 Simplify perf toggle by deduplicating branching logic
Early-return on invalid input, then unify the GET/POST and display
paths so each concern is handled once instead of per-subcommand.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:02:06 -07:00
c3ad78f1be Add CLI perf command to toggle request timing on proxied apps 2026-03-19 10:50:23 -07:00
1e4d66cbe4 Fix log search filtering to match against plain text instead of ANSI escape codes
Move styles array outside the parse loop in ansiToHtml so styles accumulate across sequences, and use stripAnsi when filtering live logs so search matches visible text.
2026-03-18 11:23:06 -07:00
a824d62058 Refactor ANSI parser to support SGR reset (code 39) and bold/dim styles
Merge color and style maps into a unified STYLES table, hoist the
regex to module scope, export stripAnsi for use in log parsing, and
handle SGR 39 (default foreground) by removing only color styles
instead of clearing all styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 11:14:03 -07:00
33d91747af Combine ANSI style codes into a single span element
Multiple SGR parameters in one escape sequence (e.g. bold + color)
were each opening a new span, losing earlier styles. Collect styles
per sequence and emit one span with all of them.
2026-03-18 10:59:07 -07:00
33d21777d3 Add ANSI color code to HTML conversion for log display
Terminal color codes were rendering as raw escape sequences in the
web UI, making logs hard to read.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:55:31 -07:00
62f936cdef Merge branch 'switch-kleur-ansis' 2026-03-17 21:35:47 -07:00
3a6ed5d546 Handle -c flag position for both shebang and compiled argv layouts
When run via shebang, bun prepends itself so -c appears at argv[2]
instead of argv[1]. Search both positions to support either mode.
2026-03-17 21:35:19 -07:00
3328009af6 Promote status to the primary command over list and info
Hide `list` and `info` as aliases so existing scripts keep working
while surfacing `status` as the canonical entry point in help output.
2026-03-17 19:44:57 -07:00
c12d60119f Add hidden status command for listing and inspecting apps 2026-03-17 19:22:46 -07:00
99a3a25131 Merge branch 'cli' 2026-03-17 19:18:30 -07:00
35a9053308 Reduce nesting in SSH-disabled command setup with early continue 2026-03-17 19:18:26 -07:00
0abf03e64e Disable shell, get, and open commands for SSH sessions
These commands require local access and cannot function when
connected over SSH (USER=cli).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:17:12 -07:00
bbed8c49b7 Add .hushlogin creation to setup-ssh script 2026-03-17 17:46:32 -07:00
a9f8a3885d Remove deploy scripts and npm tasks 2026-03-17 17:42:37 -07:00
21c6c27c92 Add SSH CLI access and update install docs 2026-03-16 16:35:19 -07:00
195be426f1 Convert getAppMetrics to async and replace spawnSync with async Bun.spawn
spawnSync blocks the event loop while waiting for ps and du, which
stalls the SSE metrics stream and other requests. Running these
concurrently with async spawn (and Promise.all for du) keeps the
server responsive under load.
2026-03-16 16:32:17 -07:00
926e57e34e Make install.sh executable 2026-03-16 16:19:11 -07:00
0a7c2b0f1f Add CLI build and install to deploy, handle SSH login shell commands
The deploy script now builds the CLI binary and copies it to /usr/local/bin.
SSH passes commands as `toes -c "command args"`, so parse that form
before falling through to the interactive shell or normal arg handling.
2026-03-16 16:14:31 -07:00
aebafdf496 Remove package.json requirement, auto-deploy bare repos 2026-03-12 14:45:57 -07:00
63b0709649 shout 2026-03-10 15:51:38 -07:00
0471e7b26d yea 2026-03-10 15:43:31 -07:00
18834fcd2b Replace kleur with ansis for CLI colors 2026-03-09 18:39:14 -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
d3b6d97bb6 Simplify app git seeding in install.sh 2026-03-05 20:28:00 -08:00
27860c5e32 Init bare git repos for apps on install 2026-03-05 13:09:25 -08:00
0a8287970d Merge branch 'check-updates' 2026-03-05 13:03:41 -08:00
d2339b8d44 fix update spawn error handling and reset flag 2026-03-05 13:03:35 -08:00
61c0c90695 Delegate update to install/install.sh 2026-03-05 07:58:27 -08:00
08e1df544a fun 2026-03-05 07:58:02 -08:00
e119aed205 just in case 2026-03-05 07:52:09 -08:00
ad8ef49439 Merge branch 'dots-in-project-name' 2026-03-05 07:46:28 -08:00
fafef70a33 Allow uppercase letters in VALID_NAME regex 2026-03-05 07:46:07 -08:00
0af360cef2 Centralize VALID_NAME regex into shared types 2026-03-05 07:45:19 -08:00
079c13e311 upgrade hype in templates 2026-03-05 07:44:07 -08:00
dfdd5c89b4 sha in toes cli 2026-03-05 07:43:59 -08:00
e7dd220106 Allow dots in app and repo names 2026-03-04 19:25:51 -08:00
c2264c42fc Merge branch 'cli-version' 2026-03-04 19:09:58 -08:00
0ae4e6e9b2 Embed git SHA at build time via define flag 2026-03-04 19:09:46 -08:00
f1fc4fcde8 Add error handling and timeout to system operations 2026-03-04 19:09:30 -08:00
dfb865e433 Add git SHA to CLI version string 2026-03-04 19:07:30 -08:00
f54cc401dc real settings 2026-03-04 19:03:29 -08:00
Chris Wanstrath
b152e0d3e8 Add CLI installation instructions to deploy output 2026-03-04 15:38:46 -08:00
da0a67c159 Add post-install success message with CLI hint 2026-03-04 11:56:47 -08:00
845479fa91 idempotent install script 2026-03-04 11:55:16 -08:00
f8c5890e07 Add WiFi config and system info endpoints 2026-03-04 11:39:37 -08:00
8f74f9daa0 Merge branch 'install' 2026-03-03 17:14:00 -08:00
327c7fd35d Merge branch 'new-app' 2026-03-03 17:10:43 -08:00
93d913f278 Use local git port when available for git URL 2026-03-03 17:10:36 -08:00
aee5bb1099 refactor install.sh to match update.sh 2026-03-03 13:19:01 -08:00
7274910a26 Merge branch 'update-sh' 2026-03-03 13:17:05 -08:00
c0571978d2 Add font-family to modal overlay 2026-03-03 13:16:45 -08:00
14ac2ae471 Add install/update script 2026-03-03 13:14:55 -08:00
abdfaf8402 Merge branch 'delete-repo' 2026-03-03 13:11:49 -08:00
8be9fd7912 Rename rerenderModal to renderModal 2026-03-03 13:11:46 -08:00
0aba9bde63 Refactor event stream to use EventSource API 2026-03-03 12:54:02 -08:00
577bec0d5c Simplify modal rendering with dedicated DOM root 2026-03-03 12:46:20 -08:00
002f0a64ef Add template embedding to start script 2026-03-03 12:36:17 -08:00
a4a08bfe65 Merge branch 'git-public-private' 2026-03-03 12:36:07 -08:00
0f197849b6 Use classList.toggle instead of className replace 2026-03-03 12:36:06 -08:00
d29ab8e37f 0.0.10 2026-03-03 12:21:26 -08:00
c81513b0ea Add repo visibility toggle to git app 2026-03-03 08:05:27 -08:00
732b9944d6 Deny git push over sneaker header 2026-03-02 21:42:21 -08:00
03b4634e8b update sneaker 2026-03-02 21:39:11 -08:00
7a0a9fc731 Add template embedding and generation script 2026-03-02 21:35:13 -08:00
6dc7ad8608 Replace scripts/install.sh with install.sh delegate 2026-03-02 09:41:05 -08:00
61ccce7d32 update readme 2026-03-02 09:19:09 -08:00
e17580c366 Fix path normalization in safePath function 2026-03-01 22:37:46 -08:00
c081785d37 Remove version param, add path traversal protection 2026-03-01 22:32:31 -08:00
6e5d665846 Merge branch 'fix-cron-directory' 2026-03-01 22:31:47 -08:00
30ed9b6466 Merge branch 'remove-versions-tool'
# Conflicts:
#	install/install.sh
2026-03-01 22:27:11 -08:00
affd06bdee Add git to bundled apps installation 2026-03-01 22:26:49 -08:00
09f421ecb9 Remove current symlink indirection from cron paths 2026-03-01 22:24:02 -08:00
2dfb6de2ff Add versions app and remove from install scripts 2026-03-01 22:24:01 -08:00
f5c5102fc8 Use git init --bare -b main directly 2026-03-01 22:16:25 -08:00
18c585e6a6 Buffer request body to fix large push deadlock 2026-03-01 22:12:57 -08:00
360f4cedcf Remove kiosk mode, old deploy symlinks 2026-03-01 21:55:05 -08:00
aa167f5e29 Make remote:install idempotent 2026-03-01 21:50:13 -08:00
c1f280a41e you too 2026-03-01 21:48:45 -08:00
30b0ac1fc3 Replace stats with metrics, improve version detection 2026-03-01 21:47:15 -08:00
f475e1791e Fix deploy script quoting 2026-03-01 21:43:32 -08:00
c24c0fac45 Refactor env vars: rename DATA_DIR to DATA_ROOT, add APP_URL 2026-03-01 21:21:23 -08:00
baa3712fa2 Add getApp command and gitUrl helper 2026-03-01 14:57:39 -08:00
c0b48c03da update docs 2026-03-01 14:38:24 -08:00
d0290433f2 suuuuure 2026-03-01 13:35:39 -08:00
56db56976b re-do the whole thing on git 2026-03-01 13:29:01 -08:00
9b150543b0 fix dependency 2026-03-01 10:27:43 -08:00
613898395c 0.0.9 2026-03-01 10:26:30 -08:00
71091f20a1 Add emojis to URLs and Logs tabs 2026-03-01 10:16:39 -08:00
64d5295fde Merge branch 'global-tools' 2026-03-01 10:16:01 -08:00
2b06d9afdf fix 2026-03-01 10:15:56 -08:00
82c8fc42da Add dashboard support for tool apps with iframe embedding 2026-03-01 10:10:49 -08:00
b99dd16343 Add dashboard view for global env vars 2026-03-01 09:57:13 -08:00
52cf99b56d Replace global with apps and dashboard app properties 2026-03-01 09:48:36 -08:00
310994b77c Merge branch 'remove-repo' 2026-03-01 09:46:53 -08:00
0efc25834c Delete git repo on app removal via app:delete event 2026-03-01 09:45:50 -08:00
13fa2b202a .git 2026-03-01 09:42:22 -08:00
65d4fe85cf dont need it 2026-03-01 09:41:20 -08:00
8fba4cccba Merge branch 'app-isolation' 2026-03-01 09:40:45 -08:00
fdc14a5021 fix race condition 2026-03-01 09:40:35 -08:00
5e21323b54 Refactor git UI into reusable page components 2026-03-01 09:39:02 -08:00
c7f8f09ba9 Add global field to filter tool tabs 2026-03-01 09:35:05 -08:00
be7a7bd35b Remove bare git repo on app deletion 2026-03-01 09:35:02 -08:00
35341600c1 Add app-specific git repo view with push instructions 2026-03-01 09:34:58 -08:00
2046af1407 center metrics 2026-03-01 09:29:48 -08:00
4b920a247d Support bare and non-.git URL patterns 2026-03-01 09:01:47 -08:00
7ee9163f76 Merge branch 'ssh-cli-auto' 2026-02-28 23:04:54 -08:00
f9b67c03bb Remove caret from commander version pin 2026-02-28 23:04:48 -08:00
dd5d9254c0 Merge branch 'git' 2026-02-28 22:53:26 -08:00
01f23ace16 Add git HTTP server tool for push-to-deploy
A toes tool that implements the git smart HTTP protocol, allowing
users to push repos via `git push` and clone via `git clone`. On
receive, it extracts the code into a timestamped APPS_DIR directory
and activates it through the toes sync API.
2026-02-28 22:49:45 -08:00
5f1de651eb Use AsyncLocalStorage for abort signal propagation 2026-02-28 22:48:38 -08:00
460d625f60 Simplify SSH access via dedicated cli user 2026-02-28 22:38:39 -08:00
3ad7145229 dumb 2026-02-28 20:27:21 -08:00
a87f0a9651 Add abort signals; rename guest to toes-cli 2026-02-28 13:34:14 -08:00
d2b0eb410f fix tool iframes 2026-02-28 12:58:21 -08:00
ffe1df22e6 domain 2026-02-28 08:29:55 -08:00
7f82a37c63 toes.dev 2026-02-28 08:12:38 -08:00
6055b9798d Replace quickstart with detailed setup docs 2026-02-28 08:04:23 -08:00
f7397dc060 install 2026-02-27 20:40:30 -08:00
d69dc6ae9d Merge branch 'ssh-install' 2026-02-27 19:35:11 -08:00
4853ee4f7a Skip bundled app install if already exists 2026-02-27 19:35:06 -08:00
Chris Wanstrath
74f9062a89 fix reconnect 2026-02-27 15:35:49 -08:00
Chris Wanstrath
55316027c0 heartbeat 2026-02-27 15:14:43 -08:00
cfba207077 no no no 2026-02-27 07:40:19 -08:00
702019279a no 2026-02-27 07:29:23 -08:00
141622f86f Add test123 app and support tunnelUrl in Urls 2026-02-27 07:28:58 -08:00
526678e87a Add active variant flex column styles 2026-02-27 07:26:15 -08:00
dc570cc6e9 Add SSH shell and NSS guest user support 2026-02-27 07:25:46 -08:00
d29e306e61 Merge branch 'mobile' 2026-02-26 20:37:52 -08:00
671f51ca0c Replace app selector modal with mobile sidebar state 2026-02-26 20:37:50 -08:00
604ac96b30 Add paw print emoji to install banner 2026-02-26 20:28:07 -08:00
d082af4e33 Add width 100% to active style 2026-02-26 20:21:51 -08:00
9bce15b871 Add flex layout to LogsSection container 2026-02-26 19:59:37 -08:00
7ab27f2767 Replace chevron with hamburger menu for app selector 2026-02-26 19:58:42 -08:00
45b1903e6b Use URL-based routing instead of local state 2026-02-26 19:43:18 -08:00
68274d8651 Intercept link clicks for client-side routing 2026-02-26 18:49:48 -08:00
98a1c1ad97 Add client-side router, use URLs for navigation 2026-02-26 11:40:50 -08:00
6d02f1db3f Make stopped tiles link to app page instead of nowhere 2026-02-26 07:28:05 -08:00
b0c5a11cde Add hostname setup and kiosk mode to install 2026-02-25 20:44:05 -08:00
029e349c5b add toes installer and install server 2026-02-25 20:38:47 -08:00
1a71656508 app tiles 2026-02-25 20:33:02 -08:00
363a82a845 Add icon span and conditional URL/name display 2026-02-25 19:58:01 -08:00
271bf018b8 Add tabbed dashboard with URLs/Logs/Metrics views 2026-02-25 19:55:19 -08:00
Chris Wanstrath
488c643342 mkdir-p 2026-02-25 16:08:39 -08:00
Chris Wanstrath
8fc54bd349 deploy to any host 2026-02-25 16:06:19 -08:00
Chris Wanstrath
3cbb25a82a yeah 2026-02-25 15:35:01 -08:00
87d0ff50c1 Centralize hostname config in shared module 2026-02-25 12:55:41 -08:00
0499060676 Use dynamic hostname instead of toes.local 2026-02-25 12:11:43 -08:00
151 changed files with 4145 additions and 3555 deletions

4
.gitignore vendored
View File

@ -1,10 +1,14 @@
.sandlot/ .sandlot/
.rev/
# dependencies (bun install) # dependencies (bun install)
node_modules node_modules
pub/client/index.js pub/client/index.js
toes/ toes/
# generated
src/lib/templates.data.ts
# output # output
out out
dist dist

121
CLAUDE.md
View File

@ -16,19 +16,43 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
- **Bun** runtime (not Node) - **Bun** runtime (not Node)
- **Hype** (custom HTTP framework wrapping Hono) from `@because/hype` - **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
- **Forge** (typed CSS-in-JS) from `@because/forge` - **Forge** (typed CSS-in-JS) from `@because/forge`
- **Commander** + **kleur** for CLI - **Commander** + **ansis** for CLI
- TypeScript + Hono JSX - TypeScript + Hono JSX
- Client renders with `hono/jsx/dom` (no build step, served directly) - Client renders with `hono/jsx/dom` (no build step, served directly)
## Running ## Running
```bash ```bash
bun run dev # Hot reload (deletes pub/client/index.js first) bun run dev # Hot reload (rebuilds client bundle on change)
bun run start # Production bun run start # Production (generates templates, then runs server)
bun run check # Type check bun run check # Type check
bun run test # Tests bun run test # Tests
bun run build # Build client JS bundle (pub/client/index.js)
bun run release # Build release tarball for the Pi
``` ```
## Building & Releasing
`bun run release` (runs `scripts/release.sh`) produces a self-contained tarball the Pi can install without building anything:
1. Client JS bundle → `pub/client/index.js`
2. Embedded templates → `src/lib/templates.data.ts` (generated from `templates/` by `scripts/embed-templates.ts`)
3. Pre-built bare git repos for bundled apps → `dist/repos/*.git` (built by `scripts/build-repos.sh`)
4. Cross-compiled CLI binary → `dist/toes` (linux-arm64, built by `scripts/build.ts`)
5. Everything staged and packed into `dist/toes-<version>.tar.gz`
The Pi installer (`install/install.sh`) downloads this tarball, runs `bun install` for the server and all bundled apps (in parallel), copies pre-built repos and CLI binary into place, and starts the systemd service. No git commands, no compilation on the Pi.
### Key scripts
- `scripts/build.sh` -- Builds client JS bundle only (used during dev)
- `scripts/build-repos.sh` -- Pre-builds bare git repos for bundled apps (excludes node_modules, snapshots, logs)
- `scripts/release.sh` -- Full release pipeline: client + templates + repos + CLI → tarball
- `scripts/build.ts` -- CLI binary compiler (current platform or `--all` for cross-compile)
- `scripts/embed-templates.ts` -- Generates `src/lib/templates.data.ts` from `templates/`
- `install/install.sh` -- Pi installer, downloads release tarball and sets everything up
- `scripts/remote-install.sh` -- Runs the installer on a remote Pi over SSH
## Project Structure ## Project Structure
``` ```
@ -47,11 +71,17 @@ Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
### Server (`src/server/`) ### Server (`src/server/`)
- `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields). - `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create), `DELETE /:name`, `PUT /:name/rename`, `PUT /:name/icon`. - `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management.
- `api/sync.ts` -- File sync protocol: manifest comparison, push/pull with hash-based diffing. - `api/events.ts` -- SSE stream for discrete lifecycle events (`/stream`). Used by app processes (not the dashboard). Includes a 60s heartbeat ping.
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps. - `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app delete, app reload (triggered by git tool after deploy), file watch SSE.
- `api/system.ts` -- System info, metrics (CPU/RAM/disk per-app via `ps`/`du`), unified log aggregation, perf timing toggle, update check/apply, server restart.
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), on-demand CLI binary builds (`/dist/:file`), install script endpoint (`/install`), SPA catch-all routes, subdomain proxy (including WebSocket). Initializes apps.
- `mdns.ts` -- mDNS publishing via `avahi-publish` on Linux production. Publishes `<app>.hostname.local` A records pointing to local IP. Auto-republishes on unexpected exit with exponential backoff.
- `proxy.ts` -- Subdomain routing: extracts subdomain from `*.localhost` or `*.X.local`, proxies HTTP requests and WebSocket connections to the app's port. Sets `x-app-url` header. Optional perf timing.
- `shell.tsx` -- Minimal HTML shell for the SPA. - `shell.tsx` -- Minimal HTML shell for the SPA.
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY). - `sync.ts` -- Re-exports `computeHash` and `generateManifest` from `%sync` (lib).
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY, plain logs otherwise). Debounced rendering at 50ms.
- `tunnels.ts` -- Public tunnel management via `@because/sneaker`. Persists tunnel config in `TOES_DIR/tunnels.json`. Auto-reconnects on drop with exponential backoff (max 10 attempts). Manages share/unshare lifecycle.
### Client (`src/client/`) ### Client (`src/client/`)
@ -62,9 +92,11 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
- `api.ts` -- Fetch wrappers for server API calls. - `api.ts` -- Fetch wrappers for server API calls.
- `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication). - `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication).
- `update.tsx` -- SSE connection to `/api/apps/stream` for real-time state updates. - `update.tsx` -- SSE connection to `/api/apps/stream` for real-time state updates.
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection. - `router.ts` -- Client-side router. Intercepts link clicks, handles popstate, maps URL paths to state (`/app/:name/:tab`, `/settings`, dashboard tabs).
- `ansi.ts` -- ANSI escape code handling for log rendering.
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection, DashboardLanding, SettingsPage, Vitals, UnifiedLogs, Urls, emoji-picker, modal.
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs. - `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout). - `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout, logs, misc).
- `themes/` -- Light/dark theme token definitions. - `themes/` -- Light/dark theme token definitions.
### CLI (`src/cli/`) ### CLI (`src/cli/`)
@ -78,31 +110,37 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
- `pager.ts` -- Pipe output through system pager. - `pager.ts` -- Pipe output through system pager.
CLI commands: CLI commands:
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm` - **Apps**: `status` (list or info), `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron` - **Lifecycle**: `start`, `stop`, `restart`, `share`, `unshare`, `logs`, `metrics`, `cron` (list/log/status/run)
- **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash` - **Config**: `env` (list/set/rm, per-app or `--global`), `perf` (toggle request timing)
- **Config**: `config`, `env`, `versions`, `history`, `rollback` - **Hidden**: `list`, `info`, `log`, `version`, `shell`
Some commands (`shell`, `get`, `open`) are disabled when running over SSH (`USER=cli`).
### Shared (`src/shared/`) ### Shared (`src/shared/`)
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser). Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo` - `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`, `DEFAULT_EMOJI`, `VALID_NAME`
- `gitignore.ts` -- `.toesignore` pattern matching - `events.ts` -- `ToesEvent`, `ToesEventType`, `ToesEventInput` type definitions
- `urls.ts` -- `toSubdomain()` and `buildAppUrl()` for subdomain URL construction
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
### Lib (`src/lib/`) ### Lib (`src/lib/`)
Server-side code shared between CLI and server. Can use Node APIs. Server-side code shared between CLI and server. Can use Node APIs.
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa) - `config.ts` -- `HOSTNAME` and `LOCAL_HOST` (`hostname.local`) constants
- `sync.ts` -- Manifest generation, hash computation - `templates.ts` -- Template generation for `toes new` (bare, ssr, spa), reads embedded templates from `templates.data.ts`
- `templates.data.ts` -- Generated file containing embedded template contents (built by `scripts/embed-templates.ts`)
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
### Tools Package (`src/tools/`) ### Tools Package (`src/tools/`)
The `@because/toes` package that apps/tools import. Published exports: The `@because/toes` package that apps/tools import. Published exports:
- `@because/toes` -- re-exports from server (`src/index.ts` -> `src/server/sync.ts`) - `@because/toes` -- re-exports `computeHash`, `generateManifest`, `FileInfo`, `Manifest`, `VALID_NAME` (`src/index.ts` -> `src/tools/index.ts`)
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv` - `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`, `on` (event subscription), `appUrl`, `VALID_NAME`
### Pages (`src/pages/`) ### Pages (`src/pages/`)
@ -112,21 +150,29 @@ Hype page routes. `index.tsx` renders the Shell.
### App Lifecycle ### App Lifecycle
States: `invalid` -> `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped` States: `invalid` | `error` | `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes` - Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
- Spawn: `Bun.spawn()` with `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, plus per-app env vars - Spawn: `Bun.spawn(['bun', 'run', 'toes'])` with `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR`, `DATA_ROOT`, `TOES_URL`, `TOES_DIR`, `NO_AUTOPORT`, plus per-app env vars from `loadAppEnv()`
- Health checks: every 30s to `/ok`, 3 consecutive failures trigger restart - Startup: runs `bun install` first if `node_modules/` missing, then polls `/ok` every 500ms (30s timeout)
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), resets after 60s stable run - Health checks: every 30s to `/ok` (5s timeout), 3 consecutive failures trigger restart
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), max 5 attempts, resets after 60s stable run. State becomes `error` after max attempts.
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL - Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
- On startup, kills stale processes on ports 3001-3100 and orphaned `bun run toes` processes
### Subdomain Proxy
Every app gets a subdomain: `<app>.localhost` (dev) or `<app>.hostname.local` (prod). The server's fetch handler (`index.tsx`) checks for subdomains first and proxies to the app's port. WebSocket connections are also proxied via Bun's `server.upgrade()` with upstream bridging. The `x-app-url` header is set so apps know their public URL.
### Tools vs Apps ### Tools vs Apps
Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server: `/tool/:tool?app=foo` -> `http://host:toolPort/?app=foo`. Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server via subdomain: `/tool/:tool?app=foo` -> `http://<tool>.host/?app=foo`. Tool API calls can also be proxied: `/api/tools/:tool/*` -> `http://localhost:<toolPort>/*`.
The `apps` field in package.json controls whether a tool shows on app detail pages (`false` to hide). The `dashboard` field controls whether a tool shows on the dashboard landing page.
### Versioning ### Versioning
Apps live at `APPS_DIR/<name>/` with timestamped version directories and a `current` symlink. Push creates a new version; rollback moves the symlink. Apps use git for version control. Each app has a bare git repo at `DATA_DIR/repos/<name>.git`. Deploying is a `git push` to the server's git tool, which extracts HEAD into `APPS_DIR/<name>/` and reloads the app. History, diffing, and rollback use standard git commands.
### Environment Variables ### Environment Variables
@ -134,14 +180,14 @@ Per-app env files in `TOES_DIR/env/`:
- `_global.env` -- shared by all apps - `_global.env` -- shared by all apps
- `<appname>.env` -- per-app overrides - `<appname>.env` -- per-app overrides
The server sets these on each app process: `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, `DATA_DIR`. The server sets these on each app process: `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR` (per-app at `TOES_DIR/<name>`), `DATA_ROOT`, `TOES_DIR`, `TOES_URL`, `NO_AUTOPORT`.
### SSE Streaming ### SSE Streaming
Two SSE endpoints serve different consumers: Two SSE endpoints serve different consumers:
- `/api/apps/stream` -- Full app state snapshots on every change. Used by the dashboard UI. Driven by `onChange()` in `apps.ts`. - `/api/apps/stream` -- Full app state snapshots on every change. Used by the dashboard UI. Driven by `onChange()` in `apps.ts`.
- `/api/events/stream` -- Discrete lifecycle events (`app:start`, `app:stop`, `app:activate`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`. - `/api/events/stream` -- Discrete lifecycle events (`app:start`, `app:stop`, `app:reload`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`.
## Coding Guidelines ## Coding Guidelines
@ -213,6 +259,21 @@ function start(app: App): void {
} }
``` ```
## Install & Deployment
The install script (`install/install.sh`) is designed to run on a fresh Pi or as an updater:
1. Installs system packages (git, fish, avahi-utils, etc.) via apt
2. Installs Bun and grants `cap_net_bind_service`
3. Downloads and extracts the release tarball into `~/toes`
4. Runs `bun install` for the server
5. Copies bundled apps to `~/apps/` and runs `bun install` for each (in parallel)
6. Copies pre-built bare repos to `~/data/repos/` (for git-based versioning)
7. Installs the pre-built CLI binary to `/usr/local/bin/toes`
8. Sets up SSH access and the systemd service
The release tarball URL is configured as `RELEASE_URL` at the top of `install/install.sh`.
## Writing Apps and Tools ## Writing Apps and Tools
See `docs/CLAUDE.md` for the guide to writing toes apps and tools. See `docs/GUIDE.md` for the guide to writing toes apps and tools.

View File

@ -1,41 +1,82 @@
# 🐾 Toes # 🐾 Toes
Toes is a personal web server you run in your home. Personal web appliance you run on your home network.
Plug it in, turn it on, and forget about the cloud. Plug it in, turn it on, and forget about the cloud.
## quickstart ## Development
1. Plug in and turn on your Toes computer. ```bash
2. Connect to the **Toes Setup** WiFi network (password: **toessetup**). bun run dev # Hot reload (rebuilds client bundle on change)
A setup page will appear — choose your home WiFi and enter its password. bun run start # Production mode
3. Visit https://toes.local to get started! bun run check # Type check
bun run test # Tests
bun run build # Build client JS bundle
bun run release # Build a release tarball for the Pi
```
## features ### Releasing
- Hosts bun/hono/hype webapps - both SSR and SPA.
- `toes` CLI for pushing and pulling from your server.
- `toes` CLI for local dev mode.
- https://toes.local web UI for managing your projects.
- Per-branch staging environments for Claude.
## cli configuration `bun run release` builds everything the Pi needs into a single tarball:
by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production. 1. Client JS bundle (`pub/client/index.js`)
2. Embedded templates (`src/lib/templates.data.ts`)
3. Pre-built bare git repos for bundled apps (`dist/repos/`)
4. Cross-compiled CLI binary for linux-arm64 (`dist/toes`)
Output: `dist/toes-<version>.tar.gz`
The Pi does zero building — it untars, runs `bun install`, and starts. Upload the tarball to wherever `RELEASE_URL` in `install/install.sh` points (currently `https://toes.dev/release/latest.tar.gz`).
### Scripts
| Script | What it does |
|--------|-------------|
| `scripts/build.sh` | Builds the client JS bundle into `pub/client/index.js` |
| `scripts/build-repos.sh` | Pre-builds bare git repos for bundled apps in `dist/repos/` |
| `scripts/release.sh` | Full release: client + templates + repos + CLI → tarball |
| `scripts/build.ts` | Builds the CLI binary (current platform or cross-compile) |
| `scripts/embed-templates.ts` | Generates `src/lib/templates.data.ts` from `templates/` |
| `scripts/setup-ssh.sh` | Configures SSH access for the `cli` user on the Pi |
| `scripts/remote-install.sh` | Runs the installer on a remote Pi over SSH |
## Setup
Toes runs on a Raspberry Pi 5 with a `toes` user and passwordless sudo.
```bash
curl -fsSL https://toes.dev/install | bash
```
The installer downloads the release tarball, installs bun and system packages, runs `bun install` for the server and all bundled apps (in parallel), copies the pre-built CLI and git repos into place, and starts the systemd service.
Dashboard: `http://<hostname>.local`
## Features
- Hosts Bun/Hype webapps (SSR and SPA)
- `git push` Heroku-style deploys
- Web dashboard with real-time status, logs, and tools
- `toes` CLI (local install or SSH)
- Per-app environment variables, cron jobs, metrics
- Public sharing via tunnels
## SSH CLI
Manage your server from any machine on the network — no install required.
```bash
ssh cli@toes.local # interactive shell with tab completion
ssh cli@toes.local list # run a single command
ssh cli@toes.local logs fog # stream logs for an app
```
## CLI Configuration
By default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
```bash ```bash
toes config # show current host toes config # show current host
TOES_URL=http://192.168.1.50:3000 toes list # connect to IP TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
TOES_URL=http://mypi.local toes list # connect to hostname TOES_URL=http://mypi.local toes list # connect to hostname
``` ```
set `NODE_ENV=production` to default to `toes.local:80`.
## fun stuff
- textOS (TODO, more?)
- Claude that knows about all your toes APIS and your projects.
- non-webapps
## february goal
- [ ] Corey and Chris are running Toes servers on their home networks, hosting personal projects and games.

View File

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

View File

@ -15,7 +15,7 @@ const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false }) const app = new Hype({ prettyHTML: false })
// Styles (follow versions tool pattern) // Styles
const Container = define('Container', { const Container = define('Container', {
fontFamily: theme('fonts-sans'), fontFamily: theme('fonts-sans'),
padding: '20px', padding: '20px',
@ -572,7 +572,7 @@ app.post('/new', async c => {
return c.redirect('/new?error=invalid-name') return c.redirect('/new?error=invalid-name')
} }
const cronDir = join(APPS_DIR, appName, 'current', 'cron') const cronDir = join(APPS_DIR, appName, 'cron')
const filePath = join(cronDir, `${name}.ts`) const filePath = join(cronDir, `${name}.ts`)
// Check if file already exists // Check if file already exists
@ -691,7 +691,7 @@ watch(APPS_DIR, { recursive: true }, (_event, filename) => {
debounceTimer = setTimeout(rediscover, 100) debounceTimer = setTimeout(rediscover, 100)
}) })
on(['app:activate', 'app:delete'], (event) => { on(['app:reload', 'app:delete'], (event) => {
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`) console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
rediscover() rediscover()
}) })

View File

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

View File

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

View File

@ -13,7 +13,7 @@
"dependencies": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.1",
"@because/hype": "^0.0.2", "@because/hype": "^0.0.2",
"@because/toes": "^0.0.8", "@because/toes": "^0.0.18",
"croner": "^9.1.0" "croner": "^9.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -288,6 +288,23 @@ document.querySelectorAll('[data-reveal]').forEach(btn => {
} }
}); });
}); });
document.querySelectorAll('input[name="key"]').forEach(input => {
input.addEventListener('paste', e => {
const text = e.clipboardData?.getData('text') ?? '';
const eqIndex = text.indexOf('=');
if (eqIndex === -1) return;
e.preventDefault();
const key = text.slice(0, eqIndex).trim();
const value = text.slice(eqIndex + 1).trim();
input.value = key;
const valueInput = input.closest('form').querySelector('input[name="value"]');
if (valueInput) {
valueInput.value = value;
valueInput.focus();
}
});
});
` `
app.get('/ok', c => c.text('ok')) app.get('/ok', c => c.text('ok'))
@ -300,9 +317,35 @@ app.get('/', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
if (!appName) { if (!appName) {
// Dashboard view: global env vars only
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
return c.html( return c.html(
<Layout title="Environment Variables"> <Layout title="Global Environment Variables">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox> {globalVars.length === 0 ? (
<EmptyState>No global environment variables</EmptyState>
) : (
<EnvList>
{globalVars.map(v => (
<EnvItem data-env-item>
<EnvKey>{v.key}</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
<form method="post" action={`/delete-global?key=${v.key}`} style="margin:0">
<DangerButton type="submit">Delete</DangerButton>
</form>
</EnvActions>
</EnvItem>
))}
</EnvList>
)}
<Form method="POST" action="/set-global">
<Input type="text" name="key" placeholder="KEY" required />
<Input type="text" name="value" placeholder="value" required />
<Button type="submit">Add</Button>
</Form>
<Hint>Global vars are available to all apps. Changes take effect on next app restart.</Hint>
</Layout> </Layout>
) )
} }
@ -437,7 +480,6 @@ app.post('/delete', async c => {
app.post('/set-global', async c => { app.post('/set-global', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
if (!appName) return c.text('Missing app', 400)
const body = await c.req.parseBody() const body = await c.req.parseBody()
const key = String(body.key).trim().toUpperCase() const key = String(body.key).trim().toUpperCase()
@ -455,17 +497,17 @@ app.post('/set-global', async c => {
} }
writeEnvFile(GLOBAL_ENV_PATH, vars) writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(`/?app=${appName}&tab=global`) return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
}) })
app.post('/delete-global', async c => { app.post('/delete-global', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const key = c.req.query('key') const key = c.req.query('key')
if (!appName || !key) return c.text('Missing app or key', 400) if (!key) return c.text('Missing key', 400)
const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key) const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key)
writeEnvFile(GLOBAL_ENV_PATH, vars) writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(`/?app=${appName}&tab=global`) return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
}) })
export default app.defaults export default app.defaults

View File

@ -10,7 +10,8 @@
}, },
"toes": { "toes": {
"tool": ".env", "tool": ".env",
"icon": "🔑" "icon": "🔑",
"dashboard": true
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

49
apps/git/bun.lock Normal file
View File

@ -0,0 +1,49 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "git",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.12",
},
"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/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/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.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=="],
"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=="],
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
}
}

869
apps/git/index.tsx Normal file
View File

@ -0,0 +1,869 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme, on, VALID_NAME } from '@because/toes/tools'
import { mkdirSync } from 'fs'
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import type { Child } from 'hono/jsx'
const APP_URL = process.env.APP_URL!
const APPS_DIR = process.env.APPS_DIR!
const DATA_DIR = process.env.DATA_DIR!
const DATA_ROOT = process.env.DATA_ROOT!
const TOES_URL = process.env.TOES_URL!
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
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'),
})
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',
alignItems: 'center',
gap: '6px',
padding: '3px 10px',
borderRadius: theme('radius-md'),
border: `1px solid ${theme('colors-border')}`,
backgroundColor: theme('colors-bgElement'),
color: theme('colors-textMuted'),
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.15s ease',
states: {
':hover': { borderColor: theme('colors-textMuted') },
'.public': {
backgroundColor: theme('colors-statusRunning'),
color: 'white',
borderColor: 'transparent',
},
},
})
// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------
interface AppRepoProps {
appName: string
baseUrl: string
branch: string
exists: boolean
commits: boolean
}
interface LayoutProps {
title: string
children: Child
}
interface RepoListPageProps {
baseUrl: string
external: boolean
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility; tool: boolean }>
tunnelUrl?: string
}
type Visibility = 'public' | 'private'
// ---------------------------------------------------------------------------
// Functions
// ---------------------------------------------------------------------------
const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)
// 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): Promise<string | null> {
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, {
method: 'POST',
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const msg = (body as Record<string, string>).error ?? `reload returned ${res.status}`
console.error(`Reload failed for ${name}:`, msg)
return msg
}
return null
}
async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> {
const bare = repoPath(repoName)
if (!(await hasCommits(bare))) {
return { ok: false, error: 'No commits in repository' }
}
// Validate in a temp dir before touching the real app dir
const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`)
await rm(tmpDir, { recursive: true, force: true })
await mkdir(tmpDir, { recursive: true })
// Extract HEAD into the temp directory
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
stdout: 'pipe',
stderr: 'pipe',
})
const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], {
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(tmpDir, { recursive: true, force: true })
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
}
// Stop the app before swapping directories
await stopIfRunning(repoName)
// Validation passed — swap directories (reload endpoint handles restart)
const appDir = join(APPS_DIR, repoName)
await rm(appDir, { recursive: true, force: true })
await rename(tmpDir, appDir)
return { ok: true }
}
// 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 getVisibility(repo: string): Promise<Visibility> {
const all = await loadVisibility()
return all[repo] ?? 'private'
}
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: Uint8Array | 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
}
async function loadVisibility(): Promise<Record<string, Visibility>> {
try {
const data = await readFile(VISIBILITY_PATH, 'utf-8')
return JSON.parse(data)
} catch {
return {}
}
}
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 saveVisibility(repo: string, visibility: Visibility): Promise<void> {
const all = await loadVisibility()
all[repo] = visibility
await writeFile(VISIBILITY_PATH, JSON.stringify(all, null, 2))
}
async function stopIfRunning(name: string): Promise<void> {
const res = await fetch(`${TOES_URL}/api/apps/${name}`)
if (!res.ok) return
const app = await res.json() as { state: string }
if (app.state !== 'running' && app.state !== 'starting') return
await fetch(`${TOES_URL}/api/apps/${name}/stop`, { method: 'POST' })
const maxWait = 10000
const poll = 100
let waited = 0
while (waited < maxWait) {
await new Promise(r => setTimeout(r, poll))
waited += poll
const check = await fetch(`${TOES_URL}/api/apps/${name}`)
if (!check.ok) break
const { state } = await check.json() as { state: string }
if (state !== 'running' && state !== 'stopping' && state !== 'starting') break
}
}
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)
}
}
function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
return (
<Layout title={`Git - ${appName}`}>
{exists && commits ? (
<>
<Heading>Repository</Heading>
<RepoList>
<RepoItem>
<div>
<RepoName>{appName}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{appName}
</HelpText>
</div>
<div style="display: flex; gap: 8px; align-items: center">
<Badge>{branch}</Badge>
<Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
</div>
</RepoItem>
</RepoList>
<Heading>Push Changes</Heading>
<CodeBlock>{[
`git push toes ${branch}`,
'',
'# Or if remote not yet added:',
`git remote add toes ${baseUrl}/${appName}`,
`git push toes ${branch}`,
].join('\n')}</CodeBlock>
</>
) : exists ? (
<>
<Heading>Repository</Heading>
<RepoList>
<RepoItem>
<div>
<RepoName>{appName}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{appName}
</HelpText>
</div>
<Badge>empty</Badge>
</RepoItem>
</RepoList>
<Heading>Push to Deploy</Heading>
<CodeBlock>{[
`git remote add toes ${baseUrl}/${appName}`,
'git push toes main',
].join('\n')}</CodeBlock>
</>
) : (
<>
<Heading>Push to Deploy</Heading>
<HelpText>
No git repository for <strong>{appName}</strong> yet.
Push to create one and deploy.
</HelpText>
<CodeBlock>{[
`git remote add toes ${baseUrl}/${appName}`,
'git push toes main',
].join('\n')}</CodeBlock>
</>
)}
</Layout>
)
}
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 && (
<>
<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 push toes main',
'',
'# Or push an existing repo',
`git push ${baseUrl}/<app-name> main`,
].join('\n')}</CodeBlock>
</>
)}
{repos.length > 0 && appRepos.length > 0 && toolRepos.length > 0 && (
<>
<Heading>Repositories</Heading>
<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" />}
</>
)}
{repos.length === 0 && (
<HelpText>No repositories yet. Push one to get started.</HelpText>
)}
</Layout>
)
}
// ---------------------------------------------------------------------------
// Module init
// ---------------------------------------------------------------------------
mkdirSync(REPOS_DIR, { recursive: true })
// Auto-deploy bare repos that don't have a corresponding app directory
async function deployUndeployedRepos() {
const repos = await listRepos()
for (const name of repos) {
const appDir = join(APPS_DIR, name)
if (await dirExists(appDir)) continue
const bare = repoPath(name)
if (!(await hasCommits(bare))) continue
console.log(`Auto-deploying undeployed repo: ${name}`)
const result = await deploy(name)
if (result.ok) {
await activateApp(name)
} else {
console.error(`Auto-deploy failed for ${name}: ${result.error}`)
}
}
}
deployUndeployedRepos()
on('app:delete', async ({ app: name }) => {
const bare = repoPath(name)
if (await dirExists(bare)) await rm(bare, { recursive: true, force: 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.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/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 (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') {
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.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], async c => {
const repoParam = c.req.param('repo').replace(/\.git$/, '')
if (!validRepoName(repoParam)) {
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)
}
return gitRpc(repoParam, 'git-upload-pack', c.req.raw.body)
})
// POST /:repo[.git]/git-receive-pack
app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'], async c => {
if (c.req.header('x-sneaker')) {
return c.text('Push access denied over sneaker', 403)
}
const repoParam = c.req.param('repo').replace(/\.git$/, '')
if (!validRepoName(repoParam)) {
return c.text('Invalid repository name', 400)
}
await ensureBareRepo(repoParam)
// Buffer the request body before passing to git-receive-pack. Piping a live
// HTTP ReadableStream directly to subprocess stdin deadlocks on large pushes:
// the pipe buffer fills, stalling the stream reader, while git-receive-pack
// can't finish reading stdin to produce stdout — both sides block.
const body = new Uint8Array(await c.req.raw.arrayBuffer())
const response = await gitRpc(repoParam, 'git-receive-pack', 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) {
const err = await activateApp(repoParam)
if (err) {
console.error(`Reload failed for ${repoParam}: ${err}`)
return `Deploy succeeded but reload failed: ${err}`
}
console.log(`Deployed ${repoParam}`)
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.post('/api/visibility/:repo', async c => {
if (c.req.header('x-sneaker')) return c.json({ error: 'Forbidden' }, 403)
const repo = c.req.param('repo')
if (!validRepoName(repo)) return c.json({ error: 'Invalid repository name' }, 400)
const body = await c.req.json<{ visibility: string }>()
if (body.visibility !== 'public' && body.visibility !== 'private') {
return c.json({ error: 'Visibility must be "public" or "private"' }, 400)
}
await saveVisibility(repo, body.visibility)
return c.json({ ok: true })
})
app.get('/', async c => {
const appName = c.req.query('app')
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) {
const bare = repoPath(appName)
const exists = await dirExists(bare)
const [commits, branch] = exists
? await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
: [false, 'main']
return c.html(<AppRepo appName={appName} baseUrl={baseUrl} branch={branch} exists={exists} commits={commits} />)
}
// 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([
hasCommits(bare),
getDefaultBranch(bare),
getVisibility(name),
])
return { name, commits, branch, visibility, tool: toolSet.has(name) }
}))
// Hide private repos from external (sneaker) requests
const filtered = external
? repoData.filter(r => r.visibility === 'public')
: repoData
// 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

@ -1,5 +1,5 @@
{ {
"name": "versions", "name": "git",
"module": "index.tsx", "module": "index.tsx",
"type": "module", "type": "module",
"private": true, "private": true,
@ -10,7 +10,9 @@
}, },
"toes": { "toes": {
"tool": true, "tool": true,
"icon": "📦" "dashboard": true,
"share": true,
"icon": "🔀"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
@ -21,6 +23,6 @@
"dependencies": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.1",
"@because/hype": "^0.0.2", "@because/hype": "^0.0.2",
"@because/toes": "^0.0.5" "@because/toes": "0.0.12"
} }
} }

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

@ -55,6 +55,12 @@ interface ProcessMetrics {
rss: number rss: number
} }
interface SystemMetrics {
cpu: number
ram: { used: number, total: number, percent: number }
disk: { used: number, total: number, percent: number }
}
// ============================================================================ // ============================================================================
// Process Metrics Collection // Process Metrics Collection
// ============================================================================ // ============================================================================
@ -402,6 +408,40 @@ const ChartWrapper = define('ChartWrapper', {
height: '150px', height: '150px',
}) })
const GaugeLabel = define('GaugeLabel', {
fontSize: '13px',
fontWeight: 600,
color: theme('colors-textMuted'),
textTransform: 'uppercase',
letterSpacing: '0.5px',
})
const GaugeValueText = define('GaugeValueText', {
textAlign: 'center',
fontSize: '20px',
fontWeight: 'bold',
marginTop: '-4px',
color: theme('colors-text'),
})
const GaugesCard = define('GaugesCard', {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
padding: '24px',
})
const GaugesGrid = define('GaugesGrid', {
display: 'flex',
justifyContent: 'center',
gap: '40px',
padding: '20px 0',
})
const NoDataMessage = define('NoDataMessage', { const NoDataMessage = define('NoDataMessage', {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -435,6 +475,14 @@ function formatRss(kb?: number): string {
return `${(kb / 1024 / 1024).toFixed(2)} GB` return `${(kb / 1024 / 1024).toFixed(2)} GB`
} }
async function fetchSystemMetrics(): Promise<SystemMetrics> {
try {
const res = await fetch(`${TOES_URL}/api/system/metrics`)
if (res.ok) return await res.json() as SystemMetrics
} catch {}
return { cpu: 0, ram: { used: 0, total: 0, percent: 0 }, disk: { used: 0, total: 0, percent: 0 } }
}
function getStatusColor(state: string): string { function getStatusColor(state: string): string {
switch (state) { switch (state) {
case 'running': case 'running':
@ -477,6 +525,107 @@ function Layout({ title, children }: LayoutProps) {
) )
} }
// ============================================================================
// Gauge Rendering
// ============================================================================
const G_SEGMENTS = 19
const G_START = -225
const G_SWEEP = 270
const G_CX = 60
const G_CY = 60
const G_R = 44
const G_GAP = 3
const G_SW = 8
const G_NL = 38
const gToRad = (deg: number) => (deg * Math.PI) / 180
const gSegColor = (i: number): string => {
const t = i / (G_SEGMENTS - 1)
if (t < 0.4) return '#4caf50'
if (t < 0.6) return '#8bc34a'
if (t < 0.75) return '#ffc107'
if (t < 0.9) return '#ff9800'
return '#f44336'
}
function renderGauge(value: number, id: string) {
const segSweep = G_SWEEP / G_SEGMENTS
const active = Math.round((value / 100) * G_SEGMENTS)
const innerR = G_R - G_SW / 2
const outerR = G_R + G_SW / 2
const segments = []
for (let i = 0; i < G_SEGMENTS; i++) {
const s = G_START + i * segSweep + G_GAP / 2
const e = G_START + (i + 1) * segSweep - G_GAP / 2
const x1 = G_CX + outerR * Math.cos(gToRad(s))
const y1 = G_CY + outerR * Math.sin(gToRad(s))
const x2 = G_CX + outerR * Math.cos(gToRad(e))
const y2 = G_CY + outerR * Math.sin(gToRad(e))
const x3 = G_CX + innerR * Math.cos(gToRad(e))
const y3 = G_CY + innerR * Math.sin(gToRad(e))
const x4 = G_CX + innerR * Math.cos(gToRad(s))
const y4 = G_CY + innerR * Math.sin(gToRad(s))
segments.push(
<path
key={i}
data-segment={i}
d={`M ${x1} ${y1} A ${outerR} ${outerR} 0 0 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 0 0 ${x4} ${y4} Z`}
fill={i < active ? gSegColor(i) : 'var(--colors-border)'}
/>
)
}
const angle = G_START + (value / 100) * G_SWEEP
const nx = G_CX + G_NL * Math.cos(gToRad(angle))
const ny = G_CY + G_NL * Math.sin(gToRad(angle))
const pa = angle + 90
const bw = 3
const bx1 = G_CX + bw * Math.cos(gToRad(pa))
const by1 = G_CY + bw * Math.sin(gToRad(pa))
const bx2 = G_CX - bw * Math.cos(gToRad(pa))
const by2 = G_CY - bw * Math.sin(gToRad(pa))
return (
<GaugesCard>
<GaugeLabel>{id}</GaugeLabel>
<svg id={`gauge-${id}`} viewBox="10 10 100 55" width="140" height="80" style="overflow: visible">
{segments}
<polygon data-needle points={`${nx},${ny} ${bx1},${by1} ${bx2},${by2}`} fill="var(--colors-text)" />
<circle cx={G_CX} cy={G_CY} r="4" fill="var(--colors-textMuted)" />
</svg>
<GaugeValueText id={`value-${id}`}>{value}%</GaugeValueText>
</GaugesCard>
)
}
const gaugeScript = `
(function() {
var S=19,ST=-225,SW=270,CX=60,CY=60,R=44,W=8,NL=38,GAP=3;
var iR=R-W/2, oR=R+W/2;
function rad(d){return d*Math.PI/180}
function sc(i){var t=i/(S-1);return t<.4?'#4caf50':t<.6?'#8bc34a':t<.75?'#ffc107':t<.9?'#ff9800':'#f44336'}
function upd(id,v){
var svg=document.getElementById('gauge-'+id);if(!svg)return;
var a=Math.round((v/100)*S);
svg.querySelectorAll('[data-segment]').forEach(function(p,i){p.setAttribute('fill',i<a?sc(i):'var(--colors-border)')});
var ang=ST+(v/100)*SW,nx=CX+NL*Math.cos(rad(ang)),ny=CY+NL*Math.sin(rad(ang));
var pa=ang+90,bw=3;
var bx1=CX+bw*Math.cos(rad(pa)),by1=CY+bw*Math.sin(rad(pa));
var bx2=CX-bw*Math.cos(rad(pa)),by2=CY-bw*Math.sin(rad(pa));
var n=svg.querySelector('[data-needle]');if(n)n.setAttribute('points',nx+','+ny+' '+bx1+','+by1+' '+bx2+','+by2);
var el=document.getElementById('value-'+id);if(el)el.textContent=v+'%';
}
setInterval(function(){
fetch('/api/system').then(function(r){return r.json()}).then(function(m){
upd('cpu',m.cpu);upd('ram',m.ram.percent);upd('disk',m.disk.percent);
}).catch(function(){});
},2000);
})();
`
// ============================================================================ // ============================================================================
// App // App
// ============================================================================ // ============================================================================
@ -510,6 +659,11 @@ app.get('/api/data-history/:name', c => {
return c.json(history) return c.json(history)
}) })
app.get('/api/system', async c => {
const metrics = await fetchSystemMetrics()
return c.json(metrics)
})
app.get('/api/history/:name', c => { app.get('/api/history/:name', c => {
const name = c.req.param('name') const name = c.req.param('name')
const history = getHistory(name) const history = getHistory(name)
@ -956,67 +1110,17 @@ app.get('/', async c => {
) )
} }
// All apps view // Dashboard view: system metrics gauges
const metrics = await getAppMetrics() const sys = await fetchSystemMetrics()
// Sort: running first, then by name
metrics.sort((a, b) => {
if (a.state === 'running' && b.state !== 'running') return -1
if (a.state !== 'running' && b.state === 'running') return 1
return a.name.localeCompare(b.name)
})
if (metrics.length === 0) {
return c.html(
<Layout title="Metrics">
<EmptyState>No apps found</EmptyState>
</Layout>
)
}
const running = metrics.filter(s => s.state === 'running')
const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
const totalData = metrics.reduce((sum, s) => sum + (s.dataSize ?? 0), 0)
return c.html( return c.html(
<Layout title="Metrics"> <Layout title="Metrics">
<Table> <GaugesGrid>
<thead> {renderGauge(sys.cpu, 'cpu')}
<tr> {renderGauge(sys.ram.percent, 'ram')}
<Th>Name</Th> {renderGauge(sys.disk.percent, 'disk')}
<Th>State</Th> </GaugesGrid>
<ThRight>PID</ThRight> <script dangerouslySetInnerHTML={{ __html: gaugeScript }} />
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr>
</thead>
<tbody>
{metrics.map(s => (
<Tr>
<Td>
{s.name}
{s.tool && <ToolBadge>[tool]</ToolBadge>}
</Td>
<Td>
<StatusBadge style={`color: ${getStatusColor(s.state)}`}>
{s.state}
</StatusBadge>
</Td>
<TdRight>{s.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(s.cpu)}</TdRight>
<TdRight>{formatPercent(s.memory)}</TdRight>
<TdRight>{formatRss(s.rss)}</TdRight>
<TdRight>{formatBytes(s.dataSize)}</TdRight>
</Tr>
))}
</tbody>
</Table>
<Summary>
{running.length} running &middot; {formatPercent(totalCpu)} CPU &middot; {formatRss(totalRss)} RSS &middot; {formatBytes(totalData)} data
</Summary>
</Layout> </Layout>
) )
}) })

View File

@ -10,7 +10,8 @@
}, },
"toes": { "toes": {
"tool": true, "tool": true,
"icon": "📊" "icon": "📊",
"dashboard": true
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

View File

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

View File

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

View File

@ -5,48 +5,48 @@
"": { "": {
"name": "@because/toes", "name": "@because/toes",
"dependencies": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.7",
"@because/hype": "^0.0.2", "@because/hype": "^0.0.9",
"@because/sneaker": "^0.0.3", "@because/sneaker": "^0.0.5",
"commander": "^14.0.3", "@because/toes": "^0.0.15",
"diff": "^8.0.3", "ansis": "^4.2.0",
"kleur": "^4.1.5", "commander": "14.0.3",
"diff": "^8.0.4",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/diff": "^8.0.0", "@types/diff": "^8.0.0",
}, },
"peerDependencies": {
"typescript": "^5.9.3",
},
}, },
}, },
"packages": { "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/forge": ["@because/forge@0.0.7", "https://npm.nose.space/@because/forge/-/forge-0.0.7.tgz", {}, "sha512-vrpo9/l3YbpJikr4eGNBbXDoXa1q0TtretyXwlJys/5qWEuHJJ3F8sFQ8SEeqWq3j0k9LQfS1278YE6a9mpv6g=="],
"@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/hype": ["@because/hype@0.0.9", "https://npm.nose.space/@because/hype/-/hype-0.0.9.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" } }, "sha512-pCaGAP0d4JDkeuVDR+8x0AemaC/EjFNNBNHhC8eKcw7soQVoXG0kYJW4bIBlEBrY8pd2TBXmMEzRIcW4QnwXNw=="],
"@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.5", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.5.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" } }, "sha512-GAvsh/i6N+KRV5mK/YosOMlZ3Lnm/y8kppQiKTZ5HlBDBnJfuxGJSp7aLyHZlHtSQi1jcGJWsx6NJ4RqCD7hNA=="],
"@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=="], "@because/toes": ["@because/toes@0.0.15", "https://npm.nose.space/@because/toes/-/toes-0.0.15.tgz", { "dependencies": { "@because/forge": "^0.0.7", "@because/hype": "^0.0.9", "@because/sneaker": "^0.0.5", "@because/toes": "^0.0.15", "ansis": "^4.2.0", "commander": "14.0.3", "diff": "^8.0.4" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-vHCMIx3w7AK1buWIKXTTWb2oxKCrOdXj0/p4+r+prjjfp1Q6RPmkfPFy1GVRZ4P1kVr8LO7PpDITCHNMr8cAlQ=="],
"@types/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@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=="], "@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=="],
"@types/node": ["@types/node@25.2.3", "https://npm.nose.space/@types/node/-/node-25.2.3.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], "@types/node": ["@types/node@25.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "ansis": ["ansis@4.2.0", "https://npm.nose.space/ansis/-/ansis-4.2.0.tgz", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "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=="], "diff": ["diff@8.0.4", "https://npm.nose.space/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], "hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "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=="],
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="], "unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
} }

View File

@ -6,10 +6,8 @@ An app is an HTTP server that runs on its assigned port.
``` ```
apps/<name>/ apps/<name>/
<timestamp>/ # YYYYMMDD-HHMMSS package.json
package.json index.tsx
index.tsx
current -> <timestamp> # symlink to active version
``` ```
**package.json** must have `scripts.toes`: **package.json** must have `scripts.toes`:

View File

@ -9,7 +9,7 @@ The cron tool discovers jobs from all apps and runs them automatically.
Add a file to `cron/` in any app: Add a file to `cron/` in any app:
```ts ```ts
// apps/my-app/current/cron/daily-cleanup.ts // apps/my-app/cron/daily-cleanup.ts
export const schedule = "day" export const schedule = "day"
export default async function() { export default async function() {
@ -73,7 +73,7 @@ Jobs track:
## discovery ## discovery
The cron tool: The cron tool:
1. Scans `APPS_DIR/*/current/cron/*.ts` 1. Scans `APPS_DIR/*/cron/*.ts`
2. Imports each file to read `schedule` 2. Imports each file to read `schedule`
3. Validates the schedule 3. Validates the schedule
4. Registers with croner 4. Registers with croner

View File

@ -19,15 +19,14 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
- [CLI Reference](#cli-reference) - [CLI Reference](#cli-reference)
- [App Management](#app-management) - [App Management](#app-management)
- [Lifecycle](#lifecycle) - [Lifecycle](#lifecycle)
- [Syncing Code](#syncing-code) - [Deploying Code](#deploying-code)
- [Environment Variables](#environment-variables) - [Environment Variables](#environment-variables)
- [Versioning](#versioning)
- [Cron Jobs](#cron-jobs-1) - [Cron Jobs](#cron-jobs-1)
- [Metrics](#metrics) - [Metrics](#metrics)
- [Sharing](#sharing) - [Sharing](#sharing)
- [Configuration](#configuration)
- [Environment Variables](#environment-variables-1) - [Environment Variables](#environment-variables-1)
- [Health Checks](#health-checks) - [Health Checks](#health-checks)
- [Running over HTTP](#running-over-http)
- [App Lifecycle](#app-lifecycle) - [App Lifecycle](#app-lifecycle)
- [Cron Jobs](#cron-jobs) - [Cron Jobs](#cron-jobs)
- [Data Persistence](#data-persistence) - [Data Persistence](#data-persistence)
@ -40,7 +39,7 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
# Install the CLI # Install the CLI
curl -fsSL http://toes.local/install | bash curl -fsSL http://toes.local/install | bash
# Create a new app # Create a new app (scaffolds, inits git, and pushes to server)
toes new my-app toes new my-app
# Enter the directory, install deps, and develop locally # Enter the directory, install deps, and develop locally
@ -48,8 +47,9 @@ cd my-app
bun install bun install
bun dev bun dev
# Push to the server # Deploy changes (standard git)
toes push git add . && git commit -m "my changes"
git push toes main
# Open in browser # Open in browser
toes open toes open
@ -57,7 +57,7 @@ toes open
Your app is now running at `http://my-app.toes.local`. Your app is now running at `http://my-app.toes.local`.
> **Tip:** Add `.toes` to your `.gitignore`. This file tracks local sync state and shouldn't be committed. `toes new` automatically sets up a `toes` git remote pointing at the server. Pushing to it triggers a deploy.
--- ---
@ -85,8 +85,8 @@ A generated SSR app looks like this:
``` ```
my-app/ my-app/
.gitignore # Files to exclude from sync and deploy
.npmrc # Points to the private registry .npmrc # Points to the private registry
.toesignore # Files to exclude from sync (like .gitignore)
package.json # Must have scripts.toes package.json # Must have scripts.toes
tsconfig.json # TypeScript config tsconfig.json # TypeScript config
index.tsx # Entry point (re-exports from src/server) index.tsx # Entry point (re-exports from src/server)
@ -457,13 +457,11 @@ app.get('/', c => {
const appName = c.req.query('app') const appName = c.req.query('app')
if (!appName) return c.html(<p>No app selected</p>) if (!appName) return c.html(<p>No app selected</p>)
const appPath = join(APPS_DIR, appName, 'current') const appPath = join(APPS_DIR, appName)
// Read files from appPath... // Read files from appPath...
}) })
``` ```
Always go through the `current` symlink — never access version directories directly.
**Calling the Toes API:** **Calling the Toes API:**
```tsx ```tsx
@ -522,19 +520,21 @@ toes new my-app --bare # Minimal template
toes new my-app --spa # SPA template toes new my-app --spa # SPA template
``` ```
Creates the app locally, then pushes it to the server. If run without a name, scaffolds the current directory. Scaffolds the app locally, initializes a git repo with a `toes` remote pointing at the server, and pushes. The git push triggers a deploy. If run without a name, scaffolds the current directory.
**`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime). **`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime).
**`toes get <name>`** — Download an app from the server to your local machine. **`toes get <name>`** — Clone an app from the server to your local machine.
```bash ```bash
toes get my-app # Creates ./my-app/ with all files toes get my-app # Clones into ./my-app/
cd my-app cd my-app
bun install bun install
bun dev # Develop locally bun dev # Develop locally
``` ```
The clone comes with a `toes` remote already configured, so `git push toes main` deploys.
**`toes open [name]`** — Open a running app in your browser. **`toes open [name]`** — Open a running app in your browser.
**`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation. **`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation.
@ -562,54 +562,38 @@ toes logs my-app -f -g error # Follow and filter
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months). Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
### Syncing Code ### Deploying Code
Toes uses a manifest-based sync protocol. Each file is tracked by SHA-256 hash. The server stores versioned snapshots with timestamps. Toes uses git for deployments. Each app has a `toes` remote that points to the server's git tool. Pushing to it extracts the latest commit and deploys it.
**`toes push`** — Push local changes to the server.
```bash ```bash
toes push # Push changes (fails if server changed) # Make changes, commit, and deploy
toes push --force # Overwrite server changes git add .
git commit -m "update homepage"
git push toes main
``` ```
Creates a new version on the server, uploads changed files, deletes removed files, then activates the new version. The app auto-restarts. The git push triggers the server to:
1. Store the commit in a bare repo at `DATA_DIR/repos/<name>.git`
2. Extract HEAD into the app directory
3. Run `bun install` and restart the app
**`toes pull`** — Pull changes from the server. Use standard git commands for history, diffing, and rollback:
```bash ```bash
toes pull # Pull changes (fails if you have local changes) git log # View deploy history
toes pull --force # Overwrite local changes git diff HEAD~1 # See what changed
git revert HEAD # Undo last deploy
git push toes main # Deploy the revert
``` ```
**`toes status`** — Show what would be pushed or pulled. To clone an existing app from the server:
```bash ```bash
toes status git clone http://git.toes.local/my-app
# Changes to push: cd my-app
# * index.tsx bun install
# + new-file.ts bun dev # Develop locally
# - removed-file.ts
```
**`toes diff`** — Show a line-by-line diff of changed files.
**`toes sync`** — Watch for changes and sync bidirectionally in real-time. Useful during development when editing on the server.
**`toes clean`** — Remove local files that don't exist on the server.
```bash
toes clean # Interactive confirmation
toes clean --force # No confirmation
toes clean --dry-run # Show what would be removed
```
**`toes stash`** — Stash local changes (like `git stash`).
```bash
toes stash # Save local changes
toes stash pop # Restore stashed changes
toes stash list # List all stashes
``` ```
### Environment Variables ### Environment Variables
@ -640,26 +624,12 @@ toes env rm -g API_KEY # Remove global var
### Versioning ### Versioning
Every push creates a timestamped version. The server keeps the last 5 versions. Every `git push toes main` creates a new deploy. Version history is managed through git.
**`toes versions [name]`** — List deployed versions.
```bash ```bash
toes versions my-app git log --oneline # List deploys
# Versions for my-app: git revert HEAD # Undo last change
# git push toes main # Deploy the revert
# → 20260219-143022 2/19/2026, 2:30:22 PM (current)
# 20260218-091500 2/18/2026, 9:15:00 AM
# 20260217-160845 2/17/2026, 4:08:45 PM
```
**`toes history [name]`** — Show file changes between versions.
**`toes rollback [name]`** — Rollback to a previous version.
```bash
toes rollback my-app # Interactive version picker
toes rollback my-app -v 20260218-091500 # Rollback to specific version
``` ```
### Cron Jobs ### Cron Jobs
@ -712,14 +682,24 @@ toes metrics my-app # Single app
```bash ```bash
toes share my-app toes share my-app
# ↗ Sharing my-app... https://abc123.trycloudflare.com # ↗ Sharing my-app... https://myapp.toes.space
``` ```
**`toes unshare [name]`** — Stop sharing an app. **`toes unshare [name]`** — Stop sharing an app.
### Configuration Every request to your app includes an `x-app-url` header with the app's public-facing URL. When shared, this is the tunnel URL (e.g., `https://myapp.toes.space`). When not shared, it's the local URL (e.g., `http://myapp.toes.local`). This works whether the request arrives through the local proxy or through a tunnel.
**`toes config`** — Show the current server URL and sync state. Use `appUrl()` from `@because/toes/tools` to read it — never hardcode your app's URL:
```tsx
import { appUrl } from '@because/toes/tools'
app.get('/callback', c => {
const url = appUrl(c.req.raw)
// "https://myapp.toes.space" when shared, "http://myapp.toes.local" otherwise
return c.redirect(`${url}/done`)
})
```
--- ---
@ -732,8 +712,9 @@ Toes injects these variables into every app process automatically:
| `PORT` | Assigned port (3001-3100). Your app must listen on this port. | | `PORT` | Assigned port (3001-3100). Your app must listen on this port. |
| `APPS_DIR` | Path to the apps directory on the server. | | `APPS_DIR` | Path to the apps directory on the server. |
| `DATA_DIR` | Per-app data directory for persistent storage. | | `DATA_DIR` | Per-app data directory for persistent storage. |
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local:3000`). | | `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local`). |
| `TOES_DIR` | Path to the Toes config directory. | | `TOES_DIR` | Path to the Toes config directory. |
| `APP_URL` | The app's local URL (e.g., `http://myapp.toes.local`). For the public URL that accounts for sharing, use `appUrl(req)` from `@because/toes` (see [Sharing](#sharing)). |
You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals. You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals.
@ -767,6 +748,92 @@ app.get('/ok', c => c.text('ok'))
--- ---
## Running over HTTP
Toes serves apps over plain HTTP (`http://<app>.toes.local`), not HTTPS. This is fine for a home network appliance, but a few browser features assume HTTPS and will silently break if you're not aware of them.
> **Note:** `localhost` gets a special pass — browsers treat it as a secure context even over HTTP. But `.local` domains don't get that exemption, so these gotchas apply when accessing your apps at `<app>.toes.local` from another device.
### Cookies
If you set cookies with the `Secure` flag, browsers will silently ignore them — the cookie just won't be stored.
Don't do this:
```tsx
c.header('Set-Cookie', 'session=abc123; HttpOnly; Secure; SameSite=Lax')
```
Do this instead:
```tsx
c.header('Set-Cookie', 'session=abc123; HttpOnly; SameSite=Lax')
```
If you're using a cookie library, make sure `secure` is set to `false` (or omitted):
```tsx
import { setCookie } from 'hono/cookie'
setCookie(c, 'session', token, {
httpOnly: true,
sameSite: 'Lax',
secure: false, // toes apps run over HTTP
})
```
### Clipboard API
`navigator.clipboard.writeText()` and `navigator.clipboard.readText()` require a secure context. They'll throw on `.local` domains.
Use the legacy fallback instead:
```tsx
function copyToClipboard(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
```
### Service Workers
Service workers only register on HTTPS origins (plus `localhost`). If you're building a PWA or want offline caching, it won't work on `.local`. This is a hard browser restriction with no workaround.
### Web Push Notifications
The Push API and `Notification.requestPermission()` require a secure context. For notifications on the local network, consider polling or SSE instead:
```tsx
app.sse('/notifications', (send, c) => {
// push updates over SSE instead of Web Push
send({ title: 'New item', body: 'Something happened' })
return () => {}
})
```
### Geolocation & Camera/Mic
`navigator.geolocation` and `navigator.mediaDevices.getUserMedia()` require a secure context. These won't work on `.local` domains.
### Web Crypto
`crypto.subtle` (for hashing, encryption, key generation) requires a secure context. Use a library like `tweetnacl` if you need crypto in the browser, or do it server-side:
```tsx
// Server-side — works fine, no secure context needed
const hash = new Bun.CryptoHasher('sha256').update(data).digest('hex')
```
### What about `toes share`?
`toes share` tunnels your app through HTTPS, so all of the above works when accessed through the tunnel URL. But since your app should also work locally, don't rely on secure-context APIs unless you're okay with them only working when shared.
---
## App Lifecycle ## App Lifecycle
Apps move through these states: Apps move through these states:

View File

@ -44,21 +44,17 @@ app.get('/', c => {
## accessing app files ## accessing app files
Always go through the `current` symlink:
```ts ```ts
const APPS_DIR = process.env.APPS_DIR! const APPS_DIR = process.env.APPS_DIR!
const appPath = join(APPS_DIR, appName, 'current') const appPath = join(APPS_DIR, appName)
``` ```
Not `APPS_DIR/appName` directly.
## linking to tools ## linking to tools
Use `/tool/:name` URLs to link directly to tools with params: Use `/tool/:name` URLs to link directly to tools with params:
```html ```html
<a href="/tool/code?app=my-app&version=20260130-000000"> <a href="/tool/code?app=my-app">
View in Code View in Code
</a> </a>
``` ```

81
docs/WEBHOOKS.md Normal file
View File

@ -0,0 +1,81 @@
# Rev Webhooks for Toes
Deploy Toes apps by saving to rev.host — no manual deploy step, no rsync scripts.
## How It Works
```
rev save "fix combat" → rev.host → relay (sneaker.toes.space) → toes.local pulls + deploys
```
toes.local can't receive inbound connections (home NAT), so it maintains an outbound connection to a relay — same tunnel infrastructure used by `toes share`.
## Setup Flow
1. In the Toes dashboard (or `toes` CLI via SSH), enable rev webhooks for an app
2. Toes connects to the relay and gets a stable webhook URL
3. Add that URL in rev.host project settings as a webhook endpoint
4. Done — `rev save` and `rev merge` now trigger deploys
## What Toes Does on Webhook
1. Receives event from relay (repo, ref, timestamp)
2. Pulls latest from rev.host (needs a rev auth token stored in Toes env)
3. Runs `scripts.predeploy` if defined in package.json (type-check, build, etc.)
4. Runs `bun install`
5. Restarts the app
## CLI
```bash
# Enable/disable rev webhooks
toes webhook enable [name] # Shows the relay URL to paste into rev.host
toes webhook disable [name]
# Manual trigger (pull latest and deploy now)
toes deploy [name]
# Check webhook status
toes webhook status [name]
```
## Settings UI
App settings page gets a "Rev Webhooks" section:
- Toggle to enable/disable
- Displays the relay URL (copy button)
- Field for rev.host auth token
- Last deploy timestamp + status
- "Deploy Now" button (manual trigger)
## Auth
Toes needs read access to pull from rev.host. Store a rev API token per-app (or globally):
```bash
toes env set -g REV_TOKEN rt_abc123
# or per-app
toes env set my-app REV_TOKEN rt_abc123
```
## Predeploy Scripts
Project-specific build steps go in package.json:
```json
{
"scripts": {
"toes": "bun run --watch index.tsx",
"predeploy": "bunx tsc --noEmit && bun build client/main.tsx --outdir dist --minify"
}
}
```
Toes runs `predeploy` after pulling but before restarting. If it exits non-zero, the deploy is aborted and the previous version stays running.
## Open Questions
- Should the relay URL be per-app or per-Toes-instance? (Per-instance with app routing via path seems simpler: `https://sneaker.toes.space/hooks/<instance-id>/<app-name>`)
- Webhook secret/signature verification — rev.host should sign payloads so the relay can't be spoofed
- Should `toes deploy` work without webhooks enabled? (Just pull from rev.host on demand — useful as a migration path from deploy.sh)
- Rollback: `toes rollback [name]` to revert to previous rev version?

26
install/bun.lock Normal file
View 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=="],
}
}

135
install/install.sh Executable file
View File

@ -0,0 +1,135 @@
#!/usr/bin/env bash
set -euo pipefail
##
# toes installer
# Usage: curl -fsSL https://toes.dev/install | sh
#
# Installs or updates toes on a Raspberry Pi.
# Must be run as the 'toes' user with passwordless sudo.
RELEASE_URL="https://toes.dev/release/latest.tar.gz"
DEST=~/toes
APPS_DIR=~/apps
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 " ${d}>>${r} $1"; }
fail() { echo " ${y}ERROR:${r} $1" >&2; exit 1; }
# ── Preflight ────────────────────────────────────────────
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."
sudo -n true 2>/dev/null || fail "Requires passwordless sudo."
# ── System packages ──────────────────────────────────────
info "Updating system packages"
quiet sudo apt-get update
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
info "Setting fish as default shell"
quiet sudo chsh -s /usr/bin/fish toes
fi
# ── Bun ──────────────────────────────────────────────────
BUN="$HOME/.bun/bin/bun"
if [ ! -x "$BUN" ]; then
info "Installing bun"
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
[ -x "$BUN" ] || fail "bun installation failed."
fi
sudo ln -sf "$BUN" /usr/local/bin/bun
sudo setcap 'cap_net_bind_service=+ep' "$BUN"
# ── Download ─────────────────────────────────────────────
info "Downloading toes"
mkdir -p "$DEST"
curl -fsSL "$RELEASE_URL" | tar xz --strip-components=1 -C "$DEST"
# ── Directories ──────────────────────────────────────────
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
# ── Dependencies ─────────────────────────────────────────
cd "$DEST"
info "Installing dependencies"
quiet bun install
# ── Bundled apps ─────────────────────────────────────────
REPOS_DIR="$DATA_DIR/repos"
mkdir -p "$REPOS_DIR"
info "Installing bundled apps"
pids=()
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
echo " $app"
(
cp -a "$app_dir" "$APPS_DIR/$app"
quiet bun install --frozen-lockfile --cwd "$APPS_DIR/$app" || quiet bun install --cwd "$APPS_DIR/$app"
) &
pids+=("$!")
done
for pid in "${pids[@]}"; do
wait "$pid" || fail "A bundled app failed to install."
done
# Copy pre-built bare repos for git-based versioning
cp -a "$DEST"/dist/repos/*.git "$REPOS_DIR/"
# ── CLI + SSH ────────────────────────────────────────────
info "Setting up SSH access"
sudo bash "$DEST/scripts/setup-ssh.sh"
info "Installing CLI"
sudo install -m 755 "$DEST/dist/toes" /usr/local/bin/toes
# ── Systemd ──────────────────────────────────────────────
info "Installing toes service"
sudo install -m 644 "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
sudo systemctl daemon-reload
sudo systemctl enable toes
info "Restarting toes"
sudo systemctl restart toes
# ── Done ─────────────────────────────────────────────────
VERSION=$(grep '"version"' "$DEST/package.json" | head -1 | sed 's/.*"version": *"\(.*\)".*/\1/')
echo ""
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://$(hostname).local${r}"
echo " SSH CLI: ${c}ssh cli@$(hostname).local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"
echo ""

16
install/package.json Normal file
View 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"
}
}

25
install/server.ts Normal file
View File

@ -0,0 +1,25 @@
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" },
})
}
if (new URL(req.url).pathname === "/shout") {
return Response.redirect(
"https://git.nose.space/defunkt/go-shout/raw/branch/main/install.sh",
302
)
}
return new Response("404 Not Found", { status: 404 })
},
})
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)

43
install/tsconfig.json Normal file
View 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/*"
]
}
}
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@because/toes", "name": "@because/toes",
"version": "0.0.8", "version": "0.0.19",
"description": "personal web appliance - turn it on and forget about the cloud", "description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
@ -15,38 +15,38 @@
"toes": "src/cli/index.ts" "toes": "src/cli/index.ts"
}, },
"scripts": { "scripts": {
"check": "bunx tsc --noEmit", "check": "bun run templates && bunx tsc --noEmit",
"build": "./scripts/build.sh", "build": "./scripts/build.sh",
"release": "./scripts/release.sh",
"cli:build": "bun run scripts/build.ts", "cli:build": "bun run scripts/build.ts",
"cli:build:all": "bun run scripts/build.ts --all", "cli:build:all": "bun run scripts/build.ts --all",
"cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin", "cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin",
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes", "cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes",
"cli:uninstall": "sudo rm /usr/local/bin", "cli:uninstall": "sudo rm /usr/local/bin",
"deploy": "./scripts/deploy.sh",
"debug": "DEBUG=1 bun run dev", "debug": "DEBUG=1 bun run dev",
"dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx", "dev": "bun run templates && rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
"remote:deploy": "./scripts/deploy.sh", "remote:migrate": "bun run scripts/migrate.ts",
"remote:deploy": "./scripts/remote-deploy.sh",
"remote:install": "./scripts/remote-install.sh", "remote:install": "./scripts/remote-install.sh",
"remote:logs": "./scripts/remote-logs.sh", "remote:logs": "./scripts/remote-logs.sh",
"remote:restart": "./scripts/remote-restart.sh", "remote:restart": "./scripts/remote-restart.sh",
"remote:start": "./scripts/remote-start.sh", "remote:start": "./scripts/remote-start.sh",
"remote:stop": "./scripts/remote-stop.sh", "remote:stop": "./scripts/remote-stop.sh",
"start": "bun run src/server/index.tsx", "start": "bun run templates && bun run src/server/index.tsx",
"templates": "bun run scripts/embed-templates.ts",
"test": "bun test" "test": "bun test"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/diff": "^8.0.0" "@types/diff": "^8.0.0"
}, },
"peerDependencies": {
"typescript": "^5.9.3"
},
"dependencies": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.7",
"@because/hype": "^0.0.2", "@because/hype": "^0.0.9",
"@because/sneaker": "^0.0.3", "@because/sneaker": "^0.0.5",
"commander": "^14.0.3", "@because/toes": "^0.0.15",
"diff": "^8.0.3", "ansis": "^4.2.0",
"kleur": "^4.1.5" "commander": "14.0.3",
"diff": "^8.0.4"
} }
} }

36
scripts/build-repos.sh Executable file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Pre-builds bare git repos for bundled apps so the install script
# doesn't need to run any git commands.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$SCRIPT_DIR/.."
APPS_DIR="$ROOT/apps"
OUT_DIR="$ROOT/dist/repos"
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
for app_dir in "$APPS_DIR"/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
tmp=$(mktemp -d)
tar -C "$app_dir" \
--exclude='node_modules' \
--exclude='logs' \
--exclude='current' \
--exclude='[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' \
-cf - . | tar -C "$tmp" -xf -
git -C "$tmp" init -b main -q
git -C "$tmp" add -A
git -C "$tmp" -c user.name=toes -c user.email=toes@localhost commit -q -m "install"
git clone --bare -q "$tmp" "$OUT_DIR/$app.git"
git -C "$OUT_DIR/$app.git" config http.receivepack true
rm -rf "$tmp"
echo " $app"
done
echo ">> Bare repos built in dist/repos/"

View File

@ -16,3 +16,4 @@ bun build src/client/index.tsx \
echo ">> Client bundle created at pub/client/index.js" echo ">> Client bundle created at pub/client/index.js"
ls -lh pub/client/index.js ls -lh pub/client/index.js

View File

@ -9,6 +9,7 @@ import { join } from 'path'
const DIST_DIR = join(import.meta.dir, '..', 'dist') const DIST_DIR = join(import.meta.dir, '..', 'dist')
const ENTRY_POINT = join(import.meta.dir, '..', 'src', 'cli', 'index.ts') const ENTRY_POINT = join(import.meta.dir, '..', 'src', 'cli', 'index.ts')
const GIT_SHA = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD']).stdout.toString().trim() || 'unknown'
interface BuildTarget { interface BuildTarget {
arch: string arch: string
@ -23,47 +24,6 @@ const TARGETS: BuildTarget[] = [
{ os: 'linux', arch: 'x64', name: 'toes-linux-x64' }, { os: 'linux', arch: 'x64', name: 'toes-linux-x64' },
] ]
// Ensure dist directory exists
if (!existsSync(DIST_DIR)) {
mkdirSync(DIST_DIR, { recursive: true })
}
// Parse command line args
const args = process.argv.slice(2)
const buildAll = args.includes('--all')
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
async function buildTarget(target: BuildTarget) {
console.log(`Building ${target.name}...`)
const output = join(DIST_DIR, target.name)
const proc = Bun.spawn([
'bun',
'build',
ENTRY_POINT,
'--compile',
'--target',
`bun-${target.os}-${target.arch}`,
'--minify',
'--sourcemap=external',
'--outfile',
output,
], {
stdout: 'inherit',
stderr: 'inherit',
})
const exitCode = await proc.exited
if (exitCode === 0) {
console.log(`✓ Built ${target.name}`)
} else {
console.error(`✗ Failed to build ${target.name}`)
process.exit(exitCode)
}
}
async function buildCurrent() { async function buildCurrent() {
const platform = process.platform const platform = process.platform
const arch = process.arch const arch = process.arch
@ -81,6 +41,7 @@ async function buildCurrent() {
'bun', 'bun',
'--minify', '--minify',
'--sourcemap=external', '--sourcemap=external',
`--define=__GIT_SHA__="${GIT_SHA}"`,
'--outfile', '--outfile',
output, output,
], { ], {
@ -100,6 +61,58 @@ async function buildCurrent() {
} }
} }
async function buildTarget(target: BuildTarget) {
console.log(`Building ${target.name}...`)
const output = join(DIST_DIR, target.name)
const proc = Bun.spawn([
'bun',
'build',
ENTRY_POINT,
'--compile',
'--target',
`bun-${target.os}-${target.arch}`,
'--minify',
'--sourcemap=external',
`--define=__GIT_SHA__="${GIT_SHA}"`,
'--outfile',
output,
], {
stdout: 'inherit',
stderr: 'inherit',
})
const exitCode = await proc.exited
if (exitCode === 0) {
console.log(`✓ Built ${target.name}`)
} else {
console.error(`✗ Failed to build ${target.name}`)
process.exit(exitCode)
}
}
// Embed template files before compiling
const embedProc = Bun.spawn(['bun', 'run', join(import.meta.dir, 'embed-templates.ts')], {
stdout: 'inherit',
stderr: 'inherit',
})
if (await embedProc.exited !== 0) {
console.error('✗ Failed to embed templates')
process.exit(1)
}
// Ensure dist directory exists
if (!existsSync(DIST_DIR)) {
mkdirSync(DIST_DIR, { recursive: true })
}
// Parse command line args
const args = process.argv.slice(2)
const buildAll = args.includes('--all')
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
// Main build logic // Main build logic
if (buildAll) { if (buildAll) {
console.log('Building for all targets...\n') console.log('Building for all targets...\n')

View File

@ -2,8 +2,10 @@
# 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}"

View File

@ -1,39 +0,0 @@
#!/usr/bin/env bash
set -e
# Get absolute path of this script's directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
# Load config
source "$ROOT_DIR/scripts/config.sh"
git push origin main
# SSH to target: pull, build, sync apps, restart
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
set -e
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
echo "=> Syncing default apps..."
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
for version_dir in "$app_dir"*/; do
[ -d "$version_dir" ] || continue
version=$(basename "$version_dir")
[ -f "$version_dir/package.json" ] || continue
target="$APPS_DIR/$app/$version"
mkdir -p "$target"
cp -a "$version_dir"/. "$target"/
rm -f "$APPS_DIR/$app/current"
echo " $app/$version"
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
done
done
sudo systemctl restart toes.service
SCRIPT
echo "=> Deployed to $HOST"
echo "=> Visit $URL"

View File

@ -0,0 +1,71 @@
#!/usr/bin/env bun
// Generates src/lib/templates.data.ts with embedded template file contents.
// Run: bun run templates
import { readdirSync, readFileSync, statSync } from 'fs'
import { extname, join, relative } from 'path'
const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.svg'])
const TEMPLATES_DIR = join(import.meta.dir, '..', 'templates')
const binary: Record<string, Record<string, string>> = {}
const shared: Record<string, string> = {}
const templates: Record<string, Record<string, string>> = {}
const isBinary = (path: string) =>
BINARY_EXTENSIONS.has(extname(path))
function readDir(dir: string): string[] {
const files: string[] = []
for (const entry of readdirSync(dir)) {
const path = join(dir, entry)
if (statSync(path).isDirectory()) {
files.push(...readDir(path))
} else {
files.push(path)
}
}
return files.sort()
}
// First pass: collect shared files (root level)
for (const entry of readdirSync(TEMPLATES_DIR).sort()) {
const path = join(TEMPLATES_DIR, entry)
if (!statSync(path).isDirectory()) {
shared[entry] = readFileSync(path, 'utf-8')
}
}
// Second pass: build template maps with shared files folded in
for (const entry of readdirSync(TEMPLATES_DIR).sort()) {
const path = join(TEMPLATES_DIR, entry)
if (statSync(path).isDirectory()) {
templates[entry] = { ...shared }
binary[entry] = {}
for (const filePath of readDir(path)) {
const filename = relative(path, filePath)
if (isBinary(filePath)) {
binary[entry]![filename] = readFileSync(filePath).toString('base64')
} else {
templates[entry]![filename] = readFileSync(filePath, 'utf-8')
}
}
if (Object.keys(binary[entry]!).length === 0) {
delete binary[entry]
}
}
}
// Generate TypeScript module
const lines: string[] = [
'// Auto-generated by scripts/embed-templates.ts',
'// Run `bun run templates` to regenerate',
'',
`export const TEMPLATES: Record<string, Record<string, string>> = ${JSON.stringify(templates, null, 2)}`,
'',
`export const BINARY: Record<string, Record<string, string>> = ${JSON.stringify(binary, null, 2)}`,
'',
]
const outPath = join(import.meta.dir, '..', 'src', 'lib', 'templates.data.ts')
await Bun.write(outPath, lines.join('\n'))
console.log(`✓ Embedded templates → ${relative(join(import.meta.dir, '..'), outPath)}`)

View File

@ -1,126 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
## ##
# installs systemd files to keep toes running on your Raspberry Pi # installs toes on your Raspberry Pi
# delegates to the canonical installer at install/install.sh
set -euo pipefail set -euo pipefail
quiet() { "$@" > /dev/null 2>&1; } exec "$(dirname "$0")/../install/install.sh"
SYSTEMD_DIR="/etc/systemd/system"
SERVICE_NAME="toes"
SERVICE_FILE="$(dirname "$0")/${SERVICE_NAME}.service"
SYSTEMD_PATH="${SYSTEMD_DIR}/${SERVICE_NAME}.service"
BUN_SYMLINK="/usr/local/bin/bun"
BUN_REAL="$HOME/.bun/bin/bun"
echo ">> Updating system libraries"
quiet sudo apt-get update
quiet sudo apt-get install -y libcap2-bin
quiet sudo apt-get install -y avahi-utils
quiet sudo apt-get install -y dnsmasq
quiet sudo apt-get install -y fish
echo ">> Setting fish as default shell for toes user"
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
quiet sudo chsh -s /usr/bin/fish toes
echo "Default shell changed to fish"
else
echo "fish already set as default shell"
fi
echo ">> Ensuring bun is available in /usr/local/bin"
if [ ! -x "$BUN_SYMLINK" ]; then
if [ -x "$BUN_REAL" ]; then
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
else
echo ">> Installing bun at $BUN_REAL"
quiet sudo apt install unzip
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
if [ ! -x "$BUN_REAL" ]; then
echo "ERROR: bun installation failed - $BUN_REAL not found"
exit 1
fi
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
fi
else
echo "bun already available at $BUN_SYMLINK"
fi
echo ">> Setting CAP_NET_BIND_SERVICE on $BUN_REAL"
quiet sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
quiet /usr/sbin/getcap "$BUN_REAL" || true
echo ">> Creating data and apps directories"
mkdir -p ~/data
mkdir -p ~/apps
echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do
if [ -d "apps/$app" ]; then
echo " Installing $app..."
cp -r "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
if ! (cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1; then
echo " WARNING: bun install failed for $app, trying without lockfile..."
(cd ~/apps/$app/current && bun install) > /dev/null 2>&1 || echo " ERROR: bun install failed for $app"
fi
else
echo " WARNING: no version directory found for $app, skipping"
fi
fi
done
echo ">> Installing dependencies"
bun install
echo ">> Building client bundle"
bun run build
echo ">> Installing toes service"
quiet sudo install -m 644 -o root -g root "$SERVICE_FILE" "$SYSTEMD_PATH"
echo ">> Reloading systemd daemon"
quiet sudo systemctl daemon-reload
echo ">> Enabling $SERVICE_NAME to start at boot"
quiet sudo systemctl enable "$SERVICE_NAME"
echo ">> Starting (or restarting) $SERVICE_NAME"
quiet sudo systemctl restart "$SERVICE_NAME"
echo ">> Enabling kiosk mode"
sudo raspi-config nonint do_boot_behaviour B4
# labwc (older RPi OS / manual installs)
mkdir -p ~/.config/labwc
cat > ~/.config/labwc/autostart <<'EOF'
chromium --noerrdialogs --disable-infobars --kiosk http://localhost
EOF
# Wayfire (RPi OS Bookworm default)
WAYFIRE_CONFIG="$HOME/.config/wayfire.ini"
if [ -f "$WAYFIRE_CONFIG" ]; then
# Remove existing chromium autostart if present
sed -i '/^chromium = /d' "$WAYFIRE_CONFIG"
# Add to existing [autostart] section or create it
if grep -q '^\[autostart\]' "$WAYFIRE_CONFIG"; then
sed -i '/^\[autostart\]/a chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost' "$WAYFIRE_CONFIG"
else
cat >> "$WAYFIRE_CONFIG" <<'EOF'
[autostart]
chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost
EOF
fi
fi
echo ">> Done! Rebooting in 5 seconds..."
systemctl status "$SERVICE_NAME" --no-pager -l || true
sleep 5
sudo reboot

86
scripts/release.sh Executable file
View File

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Builds a release tarball with all artifacts pre-built.
# The Pi just untars, runs bun install, and starts.
#
# Usage: bun run release
# Output: dist/toes-<version>.tar.gz
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\(.*\)".*/\1/')
STAGING="$ROOT/dist/toes-$VERSION"
echo ">> Building toes $VERSION release"
# ── Clean ────────────────────────────────────────────────
rm -rf dist
mkdir -p dist
# ── Client bundle ────────────────────────────────────────
echo ">> Building client bundle"
rm -rf pub/client
mkdir -p pub/client
bun build src/client/index.tsx \
--outfile pub/client/index.js \
--target browser \
--minify
# ── Embedded templates ───────────────────────────────────
echo ">> Embedding templates"
bun run scripts/embed-templates.ts
# ── Bare repos ───────────────────────────────────────────
echo ">> Building bundled app repos"
bash scripts/build-repos.sh
# ── CLI binary (linux-arm64 for Pi) ──────────────────────
echo ">> Building CLI for linux-arm64"
bun build src/cli/index.ts \
--compile \
--target bun-linux-arm64 \
--minify \
--sourcemap=external \
--define="__GIT_SHA__=\"$VERSION\"" \
--outfile dist/toes-linux-arm64
# ── Stage release ────────────────────────────────────────
echo ">> Staging release"
mkdir -p "$STAGING"
# Source (excluding dev artifacts)
COPYFILE_DISABLE=1 tar -C "$ROOT" \
--no-xattrs \
--exclude='node_modules' \
--exclude='.git' \
--exclude='dist' \
--exclude='apps/*/node_modules' \
--exclude='apps/*/logs' \
--exclude='apps/*/current' \
--exclude='apps/*/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' \
-cf - . | tar -C "$STAGING" -xf -
# Pre-built artifacts
mkdir -p "$STAGING/pub" "$STAGING/dist"
cp -a pub/client "$STAGING/pub/client"
cp -a dist/repos "$STAGING/dist/repos"
cp dist/toes-linux-arm64 "$STAGING/dist/toes"
# Generated templates (so the server doesn't need to generate them)
cp src/lib/templates.data.ts "$STAGING/src/lib/templates.data.ts"
# ── Tarball ──────────────────────────────────────────────
echo ">> Creating tarball"
COPYFILE_DISABLE=1 tar -C dist --no-xattrs -czf "dist/toes-$VERSION.tar.gz" "toes-$VERSION"
rm -rf "$STAGING"
SIZE=$(du -h "dist/toes-$VERSION.tar.gz" | cut -f1)
echo ">> dist/toes-$VERSION.tar.gz ($SIZE)"

66
scripts/remote-deploy.sh Executable file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Build a release tarball, upload it to the Pi, and install it.
# Usage: bun run remote:deploy
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
# Build release tarball
"$ROOT_DIR/scripts/release.sh"
# Find the tarball
TARBALL=$(ls -1 "$ROOT_DIR"/dist/toes-*.tar.gz 2>/dev/null | head -1)
[ -f "$TARBALL" ] || { echo "ERROR: No tarball found in dist/"; exit 1; }
FILENAME=$(basename "$TARBALL")
echo ""
echo ">> Uploading $FILENAME to $SSH_HOST"
scp "$TARBALL" "$SSH_HOST:/tmp/$FILENAME"
echo ">> Installing on $SSH_HOST"
ssh "$SSH_HOST" bash <<SCRIPT
set -euo pipefail
DEST=~/toes
APPS_DIR=~/apps
DATA_DIR=~/data
echo ">> Extracting"
mkdir -p "\$DEST"
tar xzf "/tmp/$FILENAME" --strip-components=1 -C "\$DEST"
rm "/tmp/$FILENAME"
echo ">> Installing dependencies"
cd "\$DEST"
~/.bun/bin/bun install > /dev/null 2>&1
# Bundled apps (parallel)
REPOS_DIR="\$DATA_DIR/repos"
mkdir -p "\$REPOS_DIR" "\$APPS_DIR"
echo ">> Installing bundled apps"
pids=()
for app_dir in "\$DEST"/apps/*/; do
app=\$(basename "\$app_dir")
[ -f "\$app_dir/package.json" ] || continue
(
cp -a "\$app_dir" "\$APPS_DIR/\$app"
~/.bun/bin/bun install --frozen-lockfile --cwd "\$APPS_DIR/\$app" > /dev/null 2>&1 \
|| ~/.bun/bin/bun install --cwd "\$APPS_DIR/\$app" > /dev/null 2>&1
) &
pids+=("\$!")
done
for pid in "\${pids[@]}"; do wait "\$pid"; done
echo ">> Installing CLI and repos"
cp -a "\$DEST"/dist/repos/*.git "\$REPOS_DIR/" 2>/dev/null || true
sudo install -m 755 "\$DEST/dist/toes" /usr/local/bin/toes
echo ">> Restarting toes"
sudo systemctl restart toes
echo ">> Done"
SCRIPT

View File

@ -9,4 +9,7 @@ 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" bash <<'SCRIPT'
set -e
curl -fsSL https://toes.dev/install | sh
SCRIPT

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

71
scripts/setup-ssh.sh Executable file
View File

@ -0,0 +1,71 @@
#!/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. Suppresses login banner via .hushlogin
# 3. Sets an empty password on `cli` for passwordless SSH
# 4. Adds a Match block in sshd_config to allow empty passwords for `cli`
# 5. Adds /usr/local/bin/toes to /etc/shells
# 6. 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. Suppress login banner (MOTD, last login, etc.)
touch /home/cli/.hushlogin
chown cli:cli /home/cli/.hushlogin 2>/dev/null || true
echo " Created .hushlogin"
# 3. Set empty password
passwd -d cli
echo " Set empty password on cli"
# 4. 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
# 5. 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
# 6. Restart sshd
echo " Restarting sshd..."
systemctl restart sshd || service ssh restart || true
echo "==> Done. Connect with: ssh cli@toes.local"

View File

@ -1,15 +0,0 @@
# dnsmasq config for Toes WiFi setup captive portal
# Redirect ALL DNS queries to the hotspot gateway IP
# Only listen on the hotspot interface
interface=wlan0
bind-interfaces
# Resolve everything to our IP (captive portal)
address=/#/10.42.0.1
# Don't use /etc/resolv.conf
no-resolv
# Don't read /etc/hosts
no-hosts

View File

@ -1,6 +1,6 @@
import type { LogLine } from '@types' import type { LogLine } from '@types'
import color from 'kleur' import color from 'ansis'
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

View File

@ -1,4 +1,4 @@
import color from 'kleur' import color from 'ansis'
import { del, get, handleError, post } from '../http' import { del, get, handleError, post } from '../http'
import { resolveAppName } from '../name' import { resolveAppName } from '../name'
@ -43,7 +43,7 @@ async function globalEnvSet(keyOrKeyValue: string, valueArg?: string) {
export async function envList(name: string | undefined, opts: { global?: boolean }) { export async function envList(name: string | undefined, opts: { global?: boolean }) {
if (opts.global) { if (opts.global) {
const vars = await get<EnvVar[]>('/api/env') const vars = await get<EnvVar[]>('/api/env')
console.log(color.bold().cyan('Global Environment Variables')) console.log(color.bold.cyan('Global Environment Variables'))
console.log() console.log()
if (!vars || vars.length === 0) { if (!vars || vars.length === 0) {
console.log(color.gray(' No global environment variables set')) console.log(color.gray(' No global environment variables set'))
@ -68,7 +68,7 @@ export async function envList(name: string | undefined, opts: { global?: boolean
return return
} }
console.log(color.bold().cyan(`Environment Variables for ${appName}`)) console.log(color.bold.cyan(`Environment Variables for ${appName}`))
console.log() console.log()
const appKeys = new Set(vars.map(v => v.key)) const appKeys = new Set(vars.map(v => v.key))

View File

@ -2,7 +2,7 @@ export { cronList, cronLog, cronRun, cronStatus } from './cron'
export { envList, envRm, envSet } from './env' export { envList, envRm, envSet } from './env'
export { logApp } from './logs' export { logApp } from './logs'
export { export {
configShow, getApp,
infoApp, infoApp,
listApps, listApps,
newApp, newApp,
@ -16,4 +16,4 @@ export {
unshareApp, unshareApp,
} from './manage' } from './manage'
export { metricsApp } from './metrics' export { metricsApp } from './metrics'
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync' export { perfToggle } from './perf'

View File

@ -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

View File

@ -1,14 +1,12 @@
import type { App } from '@types' import type { App } from '@types'
import { generateTemplates, type TemplateType } from '%templates' import { generateTemplates, type TemplateType } from '%templates'
import { readSyncState } from '%sync' import color from 'ansis'
import color from 'kleur'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { basename, join } from 'path' import { basename, join } from 'path'
import { buildAppUrl } from '@urls' import { buildAppUrl } from '@urls'
import { del, get, getManifest, HOST, post } from '../http' import { del, get, getManifest, gitUrl, HOST, post } from '../http'
import { confirm, prompt } from '../prompts' import { confirm, prompt } from '../prompts'
import { resolveAppName } from '../name' import { resolveAppName } from '../name'
import { pushApp } from './sync'
export const STATE_ICONS: Record<string, string> = { export const STATE_ICONS: Record<string, string> = {
error: color.red('●'), error: color.red('●'),
@ -36,15 +34,6 @@ async function waitForState(name: string, target: string, timeout: number): Prom
return app?.state return app?.state
} }
export async function configShow() {
console.log(`Host: ${color.bold(HOST)}`)
const syncState = readSyncState(process.cwd())
if (syncState) {
console.log(`Version: ${color.bold(syncState.version)}`)
}
}
export async function infoApp(arg?: string) { export async function infoApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return if (!name) return
@ -184,17 +173,42 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
writeFileSync(join(appPath, filename), content) writeFileSync(join(appPath, filename), content)
} }
process.chdir(appPath) // Initialize git repo and push to server (git push creates the app via the git tool)
await pushApp() const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: appPath, stdout: 'ignore', stderr: 'ignore' }).exited
await run(['git', 'init'])
await run(['git', 'add', '.'])
await run(['git', 'commit', '-m', 'init'])
await run(['git', 'remote', 'add', 'toes', gitUrl(appName)])
await run(['git', 'push', 'toes', 'main'])
console.log(color.green(`✓ Created ${appName}`)) console.log(color.green(`✓ Created ${appName}`))
console.log()
console.log('Next steps:')
if (name) { if (name) {
console.log(` cd ${name}`) console.log(`\n cd ${name}`)
} }
console.log(' bun install') }
console.log(' bun dev')
export async function getApp(name: string, directory?: string) {
const target = directory ?? name
if (existsSync(target)) {
console.error(`Directory already exists: ${target}`)
return
}
const url = gitUrl(name)
const args = ['git', 'clone', url]
if (directory) args.push(directory)
const proc = Bun.spawn(args, { stdout: 'inherit', stderr: 'inherit' })
const exitCode = await proc.exited
if (exitCode !== 0) {
console.error(color.red(`Failed to clone ${name}`))
return
}
console.log(color.green(`✓ Cloned ${name}`))
console.log(`\n cd ${target}\n bun install`)
} }
export async function openApp(arg?: string) { export async function openApp(arg?: string) {

View File

@ -1,4 +1,4 @@
import color from 'kleur' import color from 'ansis'
import { get } from '../http' import { get } from '../http'
import { resolveAppName } from '../name' import { resolveAppName } from '../name'

17
src/cli/commands/perf.ts Normal file
View File

@ -0,0 +1,17 @@
import color from 'ansis'
import { get, post } from '../http'
export async function perfToggle(onOff?: string) {
if (onOff && !['on', 'off', 'status'].includes(onOff)) {
console.error('Usage: toes perf [on|off|status]')
process.exit(1)
}
const body = onOff && onOff !== 'status' ? { on: onOff === 'on' } : {}
const res = onOff === 'status'
? await get<{ perfTiming: boolean }>('/api/system/perf')
: await post<{ perfTiming: boolean }>('/api/system/perf', body)
if (res) {
const label = res.perfTiming ? color.green('on') : color.red('off')
console.log(`Perf timing ${onOff === 'status' ? 'is ' : ''}${label}`)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,9 @@
import type { Manifest } from '@types' import type { Manifest } from '@types'
import { buildAppUrl } from '@urls'
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 +21,14 @@ export const HOST = process.env.TOES_URL
? normalizeUrl(process.env.TOES_URL) ? normalizeUrl(process.env.TOES_URL)
: DEFAULT_HOST : DEFAULT_HOST
export const gitUrl = (name: string) => `${buildAppUrl('git', HOST)}/${name}`
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 +48,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}`
@ -49,14 +60,13 @@ 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 } | 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 manifest = await res.json()
const { version, ...manifest } = data return { exists: true, manifest }
return { exists: true, manifest, version }
} catch (error) { } catch (error) {
handleError(error) handleError(error)
return null return null
@ -69,6 +79,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 +97,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 +114,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 +130,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()

View File

@ -1,4 +1,23 @@
#!/usr/bin/env bun #!/usr/bin/env bun
process.env.FORCE_COLOR = '1'
import { program } from './setup' import { program } from './setup'
program.parse() const isCliUser = process.env.USER === 'cli'
const noArgs = process.argv.length <= 2
const isTTY = !!process.stdin.isTTY
// SSH login shell passes commands as: toes -c "command args"
// With shebang, argv is [bun, script, -c, cmd]; compiled, it's [toes, -c, cmd]
const cIndex = process.argv[1] === '-c' ? 1 : process.argv[2] === '-c' ? 2 : -1
const shellExec = cIndex !== -1 ? process.argv.slice(cIndex + 1).join(' ') : null
if (shellExec) {
const tokens = shellExec.split(/\s+/).filter(Boolean)
program.parse(['node', 'toes', ...tokens])
} else if (isCliUser && noArgs && isTTY) {
const { shell } = await import('./shell')
await shell()
} else {
program.parse()
}

View File

@ -1,52 +1,40 @@
import { program } from 'commander' import { program } from 'commander'
import color from 'kleur' import color from 'ansis'
import pkg from '../../package.json' import pkg from '../../package.json'
import { withPager } from './pager' import { SHA } from './sha'
import { import {
cleanApp,
configShow,
cronList, cronList,
cronLog, cronLog,
cronRun, cronRun,
cronStatus, cronStatus,
diffApp,
envList, envList,
envRm, envRm,
envSet, envSet,
getApp, getApp,
historyApp,
infoApp, infoApp,
listApps, listApps,
logApp, logApp,
metricsApp,
newApp, newApp,
openApp, openApp,
pullApp, perfToggle,
pushApp,
renameApp, renameApp,
restartApp, restartApp,
rmApp, rmApp,
rollbackApp,
stashApp,
stashListApp,
stashPopApp,
startApp,
metricsApp,
shareApp, shareApp,
statusApp, startApp,
stopApp, stopApp,
syncApp,
unshareApp, unshareApp,
versionsApp,
} from './commands' } from './commands'
program program
.name('toes') .name('toes')
.version(`v${pkg.version}`, '-v, --version') .version(`v${pkg.version}-${SHA}`, '-v, --version')
.addHelpText('beforeAll', (ctx) => { .addHelpText('beforeAll', (ctx) => {
if (ctx.command === program) { if (ctx.command === program) {
return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n') return color.bold.cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
} }
return '' return ''
}) })
@ -66,17 +54,25 @@ program
// Apps // Apps
program program
.command('list') .command('status')
.helpGroup('Apps:') .helpGroup('Apps:')
.description('List all apps') .description('Show status of all apps, or details for a specific app')
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-t, --tools', 'show only tools')
.option('-a, --apps', 'show only apps (exclude tools)')
.action((name?: string, options?: { apps?: boolean; tools?: boolean }) => {
if (name) return infoApp(name)
return listApps(options ?? {})
})
program
.command('list', { hidden: true })
.option('-t, --tools', 'show only tools') .option('-t, --tools', 'show only tools')
.option('-a, --apps', 'show only apps (exclude tools)') .option('-a, --apps', 'show only apps (exclude tools)')
.action(listApps) .action(listApps)
program program
.command('info') .command('info', { hidden: true })
.helpGroup('Apps:')
.description('Show info for an app')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(infoApp) .action(infoApp)
@ -93,8 +89,9 @@ program
program program
.command('get') .command('get')
.helpGroup('Apps:') .helpGroup('Apps:')
.description('Download an app from server') .description('Clone an app from the server')
.argument('<name>', 'app name') .argument('<name>', 'app name')
.argument('[directory]', 'target directory (defaults to app name)')
.action(getApp) .action(getApp)
program program
@ -209,72 +206,8 @@ cron
.argument('<job>', 'job identifier (app:name)') .argument('<job>', 'job identifier (app:name)')
.action(cronRun) .action(cronRun)
// Sync
program
.command('push')
.helpGroup('Sync:')
.description('Push local changes to server')
.option('-f, --force', 'overwrite remote changes')
.action(pushApp)
program
.command('pull')
.helpGroup('Sync:')
.description('Pull changes from server')
.option('-f, --force', 'overwrite local changes')
.action(pullApp)
program
.command('status')
.helpGroup('Sync:')
.description('Show what would be pushed/pulled')
.action(statusApp)
program
.command('diff')
.helpGroup('Sync:')
.description('Show diff of changed files')
.action(() => withPager(diffApp))
program
.command('sync')
.helpGroup('Sync:')
.description('Watch and sync changes bidirectionally')
.action(syncApp)
program
.command('clean')
.helpGroup('Sync:')
.description('Remove local files not on server')
.option('-f, --force', 'skip confirmation')
.option('-n, --dry-run', 'show what would be removed')
.action(cleanApp)
const stash = program
.command('stash')
.helpGroup('Sync:')
.description('Stash local changes')
.action(stashApp)
stash
.command('pop')
.description('Restore stashed changes')
.action(stashPopApp)
stash
.command('list')
.description('List all stashes')
.action(stashListApp)
// Config // Config
program
.command('config')
.helpGroup('Config:')
.description('Show current host configuration')
.action(configShow)
const env = program const env = program
.command('env') .command('env')
.helpGroup('Config:') .helpGroup('Config:')
@ -301,25 +234,35 @@ env
.action(envRm) .action(envRm)
program program
.command('versions') .command('perf')
.helpGroup('Config:') .helpGroup('Config:')
.description('List deployed versions') .description('Toggle request timing for proxied app requests')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[on|off|status]', 'enable, disable, or check status (toggles if omitted)')
.action(versionsApp) .action(perfToggle)
// Shell
program program
.command('history') .command('shell')
.helpGroup('Config:') .description('Interactive shell')
.description('Show file changes between versions') .action(async () => {
.argument('[name]', 'app name (uses current directory if omitted)') const { shell } = await import('./shell')
.action(historyApp) await shell()
})
program // Hide and disable commands that don't work over SSH
.command('rollback') if (process.env.USER === 'cli') {
.helpGroup('Config:') const disabled = ['shell', 'get', 'open']
.description('Rollback to a previous version') for (const name of disabled) {
.argument('[name]', 'app name (uses current directory if omitted)') const cmd = program.commands.find((c) => c.name() === name)
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)') if (!cmd) continue
.action((name, options) => rollbackApp(name, options.version)) cmd.helpInformation = () => ''
;(cmd as any)._hidden = true
cmd.action(() => {
console.error(`"${name}" is not available over SSH`)
process.exit(1)
})
}
}
export { program } export { program }

3
src/cli/sha.ts Normal file
View File

@ -0,0 +1,3 @@
declare var __GIT_SHA__: string | undefined
export const SHA: string = typeof __GIT_SHA__ !== 'undefined' ? __GIT_SHA__ : 'dev'

227
src/cli/shell.ts Normal file
View File

@ -0,0 +1,227 @@
import type { App } from '@types'
import * as readline from 'readline'
import color from 'ansis'
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) => !(cmd as any)._hidden)
.map((cmd) => 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()
}

71
src/client/ansi.ts Normal file
View File

@ -0,0 +1,71 @@
const ESC = /\x1b\[([0-9;]*)m/g
const STYLES: Record<number, string> = {
1: 'font-weight:bold',
2: 'opacity:0.7',
30: 'color:#666',
31: 'color:#f87171',
32: 'color:#4ade80',
33: 'color:#facc15',
34: 'color:#60a5fa',
35: 'color:#c084fc',
36: 'color:#22d3ee',
37: 'color:#e5e5e5',
90: 'color:#999',
91: 'color:#fca5a5',
92: 'color:#86efac',
93: 'color:#fde047',
94: 'color:#93c5fd',
95: 'color:#d8b4fe',
96: 'color:#67e8f9',
97: 'color:#fff',
}
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
export const stripAnsi = (s: string) =>
s.replace(/\x1b\[[0-9;]*m/g, '')
export function ansiToHtml(text: string): string {
if (!text.includes('\x1b')) return escape(text)
ESC.lastIndex = 0
let result = ''
let last = 0
let open = false
const styles: string[] = []
let match: RegExpExecArray | null
while ((match = ESC.exec(text)) !== null) {
result += escape(text.slice(last, match.index))
last = match.index + match[0].length
const codes = match[1] ? match[1].split(';').map(Number) : [0]
for (const code of codes) {
if (code === 0) {
styles.length = 0
if (open) { result += '</span>'; open = false }
} else if (code === 39) {
const filtered = styles.filter(s => !s.startsWith('color:'))
styles.length = 0
styles.push(...filtered)
if (open) { result += '</span>'; open = false }
} else if (STYLES[code]) {
styles.push(STYLES[code])
}
}
if (styles.length) {
if (open) result += '</span>'
result += `<span style="${styles.join(';')}">`
open = true
}
}
result += escape(text.slice(last))
if (open) result += '</span>'
return result
}

View File

@ -1,32 +1,29 @@
import type { ConnectResult, WifiNetwork, WifiStatus } from '../shared/types'
export const connectToWifi = (ssid: string, password?: string): Promise<ConnectResult> =>
fetch('/api/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, password }),
}).then(r => r.json())
export const getLogDates = (name: string): Promise<string[]> => export const getLogDates = (name: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json()) fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
export const getLogsForDate = (name: string, date: string): Promise<string[]> => export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json()) fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
export const getWifiStatus = (): Promise<WifiStatus & { setupMode: boolean, url: string }> => export const getSystemInfo = (): Promise<{ version: string, sha: string, uptime: number }> =>
fetch('/api/wifi/status').then(r => r.json()) fetch('/api/system/info').then(r => r.json())
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
export const scanWifiNetworks = (): Promise<WifiNetwork[]> =>
fetch('/api/wifi/scan').then(r => r.json())
export const shareApp = (name: string) => export const shareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' }) fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
export const unshareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
export const applyUpdate = () =>
fetch('/api/system/update', { method: 'POST' }).then(r => { if (!r.ok) throw new Error('update failed'); return r.json() })
export const checkForUpdate = (): Promise<{ available: boolean, current: string, latest: string, commits: string[] }> =>
fetch('/api/system/update').then(r => { if (!r.ok) throw new Error('check failed'); return r.json() })
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
export const restartServer = () =>
fetch('/api/system/restart', { method: 'POST' }).then(r => { if (!r.ok) throw new Error('restart failed'); return r.json() })
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
export const unshareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })

View File

@ -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,
@ -44,25 +45,26 @@ const OpenEmojiPicker = define('OpenEmojiPicker', {
}) })
export function AppDetail({ app, render }: { app: App, render: () => void }) { export function AppDetail({ app, render }: { app: App, render: () => void }) {
// Find all tools // Find tools that show on app pages (apps !== false)
const tools = apps.filter(a => a.tool) const tools = apps.filter(a => a.tool && a.apps !== false)
const selectedTab = getSelectedTab(app.name) const selectedTab = getSelectedTab(app.name)
return ( return (
<Main> <Main>
<MainHeader> <MainHeader>
<MainTitle> <MainTitle>
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
&nbsp;
<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 || app.share) && (
app.tunnelUrl app.tunnelUrl
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button> ? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
: app.tunnelEnabled : app.tunnelEnabled

View File

@ -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' }} />
</> </>

View File

@ -1,13 +1,47 @@
import { Styles } from '@because/forge' import { Styles } from '@because/forge'
import { apps, currentView, isNarrow, selectedApp, setupMode } 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 { 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} />
@ -18,9 +52,8 @@ export function Dashboard({ render }: { render: () => void }) {
return ( return (
<Layout> <Layout>
<Styles /> <Styles />
{!isNarrow && !setupMode && <Sidebar render={render} />} {!isNarrow && <Sidebar render={render} />}
<MainContent render={render} /> <MainContent render={render} />
<Modal />
</Layout> </Layout>
) )
} }

View File

@ -1,36 +1,44 @@
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 { apps, dashboardTab, isNarrow, setMobileSidebar } from '../state'
import { import {
AppSelectorChevron, HamburgerButton,
HamburgerLine,
DashboardContainer, DashboardContainer,
DashboardHeader, DashboardHeader,
DashboardTitle, DashboardTitle,
Section,
SettingsGear, SettingsGear,
StatusDot, Tab,
StatusDotLink, TabBar,
StatusDotsRow, TabContent,
} from '../styles' } from '../styles'
import { update } from '../update' import { theme } from '../themes'
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs' import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
import { Vitals, initVitals } from './Vitals' import { Urls } from './Urls'
import { 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 dashboardTools = apps.filter(a => a.tool && a.dashboard)
const openSettings = () => { const openSettings = () => {
setSelectedApp(null) navigate('/settings')
setCurrentView('settings')
render()
} }
const switchTab = (tab: string) => {
navigate(tab === 'urls' ? '/' : `/${tab}`)
if (tab === 'logs') scrollLogsToBottom()
}
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
return ( return (
<DashboardContainer narrow={narrow} relative> <DashboardContainer narrow={narrow} relative>
<SettingsGear <SettingsGear
@ -40,43 +48,68 @@ 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} {dashboardTools.map(tool => {
data-tooltip={app.name} const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
tooltipVisible={activeTooltip === app.name || undefined} return (
onClick={(e: Event) => { <Tab
e.preventDefault() key={tool.name}
if (isNarrow && activeTooltip !== app.name) { active={dashboardTab === tool.name || undefined}
activeTooltip = app.name onClick={() => switchTab(tool.name)}
render() >
return {tool.icon} {titlecase(toolName)}
} </Tab>
activeTooltip = null )
setSelectedApp(app.name) })}
update() </TabBar>
}}
>
<StatusDot state={app.state} data-app={app.name} />
</StatusDotLink>
))}
</StatusDotsRow>
<Vitals /> <TabContent active={dashboardTab === 'urls' || undefined}>
<Urls render={render} />
</TabContent>
<UnifiedLogs /> <TabContent active={dashboardTab === 'logs' || undefined}>
<UnifiedLogs />
</TabContent>
{dashboardTools.map(tool => {
const isSelected = dashboardTab === tool.name
return (
<TabContent key={tool.name} active={isSelected || undefined}>
<Section>
{tool.state !== 'running' && (
<p style={{ color: theme('colors-textFaint') }}>
Tool is {tool.state}
</p>
)}
{tool.state === 'running' && (
<div
data-tool-target={isSelected ? tool.name : undefined}
style={{ width: '100%', height: '600px' }}
/>
)}
</Section>
</TabContent>
)
})}
</DashboardContainer> </DashboardContainer>
) )
} }

Some files were not shown because too many files have changed in this diff Show More