Compare commits

...

268 Commits

Author SHA1 Message Date
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
51e42dc538 Fix memory usage via /proc/meminfo on Linux 2026-02-24 19:05:04 -08:00
4d42b48c8f clean dist before build in deploy 2026-02-24 10:42:58 -08:00
f910664828 always exclude sandlot 2026-02-24 10:27:35 -08:00
365b5d2365 Add global gitignore support to file exclusion logic 2026-02-22 07:48:41 -08:00
520606ccb9 Merge branch 'docs-gitignore-toes' 2026-02-20 08:56:06 -08:00
26409010a8 Add .toes gitignore tip to getting started guide 2026-02-20 08:04:06 -08:00
236e8ff38e normalize app names to valid subdomains via toSubdomain utility 2026-02-19 20:04:23 -08:00
4aebd6a087 ensure app data directory exists before spawning process 2026-02-19 19:51:39 -08:00
36c7913b6c Move Install CLI section below WiFi settings 2026-02-19 19:41:13 -08:00
f26b382fa6 reset bun.lock before pulling to prevent merge conflicts during deploy 2026-02-19 19:39:14 -08:00
b0323c3655 Skip server-changed check for new apps with no remote manifest 2026-02-19 19:33:50 -08:00
7ea806b778 Add paw print emoji to install.sh status messages 2026-02-19 19:20:12 -08:00
9e4629ac2f Merge branch 'the-dist' 2026-02-19 19:16:27 -08:00
aaf4660816 Delegate build logic to external script, simplify build target representation 2026-02-19 19:16:12 -08:00
18cf4243fa Build CLI binaries on-demand when requested via /dist/:file endpoint 2026-02-19 13:33:58 -08:00
a041f137c0 Add centered layout variant to Settings page header and content 2026-02-19 13:13:32 -08:00
c16fdaa2a2 Fix cross-compilation target by passing os/arch via --target flag instead of env vars 2026-02-19 11:25:03 -08:00
fca779b064 Reorganize collapsed sidebar to show hamburger button without logo and add dashboard shortcut icon to AppSelector 2026-02-19 10:12:48 -08:00
8e71699ceb Move install CLI command from dashboard header to settings page 2026-02-19 10:07:33 -08:00
dc1decafec fun stuff 2026-02-19 10:05:46 -08:00
5510432b42 move 2026-02-19 10:05:11 -08:00
881517a88f Merge branch 'guide' 2026-02-19 10:04:58 -08:00
5477470551 Add comprehensive user guide for Toes platform 2026-02-19 10:04:56 -08:00
5b1a970da1 Merge branch 'toes-in-sidebar' 2026-02-19 09:40:10 -08:00
09e21c738b Keep logo link visible when sidebar is collapsed, showing icon only 2026-02-19 09:39:58 -08:00
971ebef21c dashboard 2026-02-19 09:28:15 -08:00
071f1a02b5 install toes cli 2026-02-18 20:46:56 -08:00
7b12dc9a9b ignore sandlot 2026-02-18 16:05:59 -08:00
c5672e57bd cleanup 2026-02-17 14:47:47 -08:00
b538626baa upgrade sneaker 2026-02-17 08:35:30 -08:00
3004845eee debugging sneaker 2026-02-17 07:56:44 -08:00
888f12a8f1 tweak proxy 2026-02-16 21:04:11 -08:00
96083b640f all apps are http apps 2026-02-16 13:25:24 -08:00
fecc074757 fix proxy bugs 2026-02-16 13:10:04 -08:00
3736202020 again 2026-02-16 09:43:47 -08:00
82ff55ba99 guess and check 2026-02-16 09:36:10 -08:00
1dc7b76b31 proxy fixes 2026-02-16 09:34:19 -08:00
caac6877d7 dashboard mobile fixes 2026-02-16 09:22:26 -08:00
86dacb0a74 subdomains 2026-02-15 17:30:41 -08:00
9c0762c882 mobile dashboard 2026-02-15 17:22:52 -08:00
f085d78fc1 fix starting and restarts 2026-02-15 13:51:58 -08:00
6f2f07059d we handle reconnect elsewhere 2026-02-15 12:16:27 -08:00
6ba3cdaf14 share CLI, persistent tunnels 2026-02-15 10:33:03 -08:00
7f2343fc04 fix app updating 2026-02-15 09:13:53 -08:00
f3cc26252c better status check 2026-02-15 09:10:04 -08:00
6b70af2943 big upgrade 2026-02-15 09:09:43 -08:00
09f77f099f yup 2026-02-15 09:05:04 -08:00
d1caf3fbf4 0.0.8 2026-02-15 09:03:45 -08:00
8fc226ce09 need new api 2026-02-15 08:46:23 -08:00
fbb860091c 0.0.7 2026-02-15 08:45:26 -08:00
1015e20cf9 [cron] reload jobs on renames/deploys 2026-02-15 08:44:48 -08:00
565f4924e8 we do it better now 2026-02-15 08:43:58 -08:00
c49cc2e078 tailscale docs 2026-02-15 08:37:21 -08:00
bf14ba4ba1 new event API 2026-02-15 08:36:58 -08:00
271ff151a1 single out toes logs on dashboard 2026-02-15 07:47:41 -08:00
3eef4c2a0e fix ts errors 2026-02-15 07:40:58 -08:00
9649666195 claude too 2026-02-14 08:07:01 -08:00
fabdd084cb default .gitignore for templates 2026-02-14 08:06:40 -08:00
65e19d27e2 show total disk usage for each app 2026-02-14 08:06:30 -08:00
6afefcec5b simplify toes config 2026-02-14 07:37:56 -08:00
1b563106fe max versions, remove old node_modules 2026-02-13 20:34:20 -08:00
c10ebe3c98 kill old processes on boot 2026-02-13 09:59:20 -08:00
2f4d4f5c19 new emoji 2026-02-13 09:40:07 -08:00
720c0e76fb dashboard 2026-02-13 09:02:21 -08:00
Chris Wanstrath
8b31fa3f19
Merge pull request #5 from defunkt/claude/add-dashboard-landing-8UBAd
Add dashboard landing page with app statistics
2026-02-13 08:46:20 -08:00
Claude
50e5c97beb
Add system vitals gauges and unified log stream to dashboard
- Add /api/system endpoints for CPU, RAM, and disk metrics (SSE stream)
- Add /api/system/logs for unified log stream from all apps (SSE stream)
- Create Vitals component with three gauges: arc (CPU), bar (RAM), circular (Disk)
- Create UnifiedLogs component with real-time scrolling logs and status highlighting
- Update DashboardLanding with stats, vitals, and activity sections

Design follows Dieter Rams / Teenage Engineering aesthetic with neutral palette.

https://claude.ai/code/session_013L9HKHxMEoub76B1zuKive
2026-02-13 16:41:21 +00:00
543b5d08bc
favicon 2026-02-13 16:41:20 +00:00
Claude
a91f400100
Add dashboard landing page with clickable logo navigation
The Toes logo now links to a system-wide dashboard view that shows
app and tool counts. This is the default view when first opening
the web app.

https://claude.ai/code/session_013L9HKHxMEoub76B1zuKive
2026-02-13 16:41:20 +00:00
Chris Wanstrath
14d758ef42 bun remote:logs 2026-02-12 16:33:44 -08:00
Chris Wanstrath
cb822bfddc hmmmmmmm 2026-02-12 16:32:59 -08:00
Chris Wanstrath
322c20bb72 hmm 2026-02-12 16:32:07 -08:00
Chris Wanstrath
6912bc0cdf bad 2026-02-12 16:30:41 -08:00
Chris Wanstrath
4a31d7bb69 tunnels 2026-02-12 16:24:45 -08:00
75af5f3d31 toes start/stop/restart feedback 2026-02-12 12:36:42 -08:00
ecac19a07f [cron] schedule times like "7am" 2026-02-12 12:35:24 -08:00
512d9fe96b Merge remote-tracking branch 'github/main' 2026-02-12 12:16:50 -08:00
Chris Wanstrath
ee9c4a1d0a
Merge pull request #12 from defunkt/claude/auto-start-app-on-push-zklQA
Add app startup handling in activate endpoint
2026-02-12 08:46:10 -08:00
Chris Wanstrath
bbdcefd1f7
Merge pull request #10 from defunkt/claude/rename-stats-add-filesize-4nMTq
Rename stats to metrics and add disk usage tracking
2026-02-12 08:43:20 -08:00
Claude
7a79133d78
Add DATA chart with daily X axis and show 2 charts per row
Track data size history daily (up to 30 days) in memory, with a
dedicated /api/data-history/:name endpoint. The Data Size chart uses
day labels (e.g. "Feb 12") instead of minute-based timestamps.
Charts are now displayed in a 2-column grid.

https://claude.ai/code/session_013agP8J1cCfrWZkueZ33jQB
2026-02-12 16:20:42 +00:00
Claude
a7d4e210c2
Auto-start stopped/errored apps on push activate
Previously, pushing a new version would only restart apps that were
already running. Apps in stopped or invalid state (e.g. due to a
previous startup error) were left unchanged, requiring a manual start.

Now the activate endpoint calls startApp() for stopped/invalid apps,
so pushing a code fix automatically attempts to start the app.

https://claude.ai/code/session_014UvBEvHbnhaoMLebdRFzm6
2026-02-12 16:17:45 +00:00
Chris Wanstrath
f0bef491a6
Merge pull request #11 from defunkt/claude/fix-app-rename-shutdown-IzD79
Make renameApp async and fix race condition on app restart
2026-02-12 08:15:35 -08:00
Claude
2f4d609290
Fix app rename failing with "port is taken" error
renameApp() killed the old process with .kill() but didn't wait for it
to actually exit before restarting on the same port. The OS still had
the port bound, causing the new process to fail with "port is taken".

Additionally, the old process's exit handler would fire after the rename
and corrupt the app's state—releasing the new process's port, setting
state to 'invalid', and nullifying the proc reference.

Fix by:
- Making renameApp async and awaiting proc.exited before proceeding
- Guarding the exit handler to bail out when a newer process has taken over

https://claude.ai/code/session_01W9GF8Cy7T6V2rnVcoNd1Nc
2026-02-12 16:13:59 +00:00
a96aa1d2dc ruby on rails 2026-02-12 08:07:23 -08:00
14281a1bf5 don't diff binary files 2026-02-12 07:51:51 -08:00
Claude
eb8ef0dd4d
Rename stats to metrics and add data size metric
Rename the "stats" CLI command, tool app, and all internal references
to "metrics". Add file size tracking from each app's DATA_DIR as a new
metric, shown in both the CLI table and web UI.

https://claude.ai/code/session_013agP8J1cCfrWZkueZ33jQB
2026-02-12 15:28:20 +00:00
0e3699da5a show version in config 2026-02-11 21:08:38 -08:00
9c128eaddc tweak claude 2026-02-11 21:03:10 -08:00
6fa03413bd update claude 2026-02-11 20:55:59 -08:00
28e8d0db2c new claudes 2026-02-11 20:50:18 -08:00
2d7ec7d53a old apps 2026-02-11 19:55:03 -08:00
681a3f2f9e way simpler 2026-02-11 19:51:43 -08:00
b6e9ec73de .toes 2026-02-11 19:13:34 -08:00
10154dfd4f respect gitignore in toes status 2026-02-11 18:16:34 -08:00
Chris Wanstrath
c183fe42e9 set TOES_URL 2026-02-11 16:09:14 -08:00
Chris Wanstrath
74d7d2f578 shuffle 2026-02-10 11:15:16 -08:00
Chris Wanstrath
d94a4421f9 integrated cron logs, cron cli 2026-02-10 11:12:57 -08:00
Chris Wanstrath
c9986277ab scroll logs to bottom 2026-02-10 11:01:32 -08:00
Chris Wanstrath
f10830ee9b we did it 2026-02-10 10:50:23 -08:00
89ea6f586a toes diff pager 2026-02-10 08:09:00 -08:00
725f893592 reveal full env vals 2026-02-10 07:42:01 -08:00
579b082b48 legacy 2026-02-09 22:35:43 -08:00
bffa4236e7 detect renames 2026-02-09 22:03:49 -08:00
4a2223d3d7 smarter sync 2026-02-09 21:59:02 -08:00
79a0471383 toes history 2026-02-09 21:42:40 -08:00
115d3199e8 toes diff improvements 2026-02-09 21:40:48 -08:00
891b08ecd8 try to better detect failed process start 2026-02-09 21:32:48 -08:00
302ef63485 round duration to the second 2026-02-09 21:32:11 -08:00
955aab2152 update tools package 2026-02-09 21:20:04 -08:00
cb0e748068 0.0.6 2026-02-09 21:17:46 -08:00
7c04aceef9 [cron] setup app env properly when running tasks 2026-02-09 21:17:02 -08:00
47030d7d36 stream cron output 2026-02-09 21:13:05 -08:00
081c728d12 enter pword once 2026-02-09 21:00:46 -08:00
d89a58c0ab deploy builtin tools 2026-02-09 20:59:10 -08:00
8f37274cee toes env -g 2026-02-09 20:58:07 -08:00
e8a638d11d that's fine 2026-02-09 20:52:08 -08:00
86a91469be organize --help 2026-02-09 20:47:17 -08:00
9517f6d4b2 kill errant newline 2026-02-09 20:37:24 -08:00
d43e1c1c17 simpler sync 2026-02-09 20:36:58 -08:00
1685cc135d show cron errors 2026-02-09 20:36:46 -08:00
d4e8975200 fix emoji updating 2026-02-09 19:48:12 -08:00
Chris Wanstrath
823cbb2317 bun remote:deploy 2026-02-09 17:00:26 -08:00
Chris Wanstrath
b447f7d0ca better error messages 2026-02-09 16:59:00 -08:00
Chris Wanstrath
128afcfef8 DATA_DIR 2026-02-09 16:50:13 -08:00
Chris Wanstrath
f96c599f49 show invalid cron jobs w/ feedback 2026-02-09 16:46:38 -08:00
Chris Wanstrath
4d3083764a change cron discovery to use regex 2026-02-09 16:23:32 -08:00
Chris Wanstrath
a1aa37297f DATA_DIR 2026-02-09 16:08:44 -08:00
Chris Wanstrath
d6ae39ac15 global env variables 2026-02-09 10:50:21 -08:00
168 changed files with 8570 additions and 3644 deletions

6
.gitignore vendored
View File

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

580
CLAUDE.md
View File

@ -1,6 +1,4 @@
# Toes - Claude Code Guide
## What It Is
# Toes
Personal web appliance that auto-discovers and runs multiple web apps on your home network.
@ -8,151 +6,141 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
## How It Works
1. Host server scans `/apps` directory for valid apps
2. Valid app = has `package.json` with `scripts.toes` entry
3. Each app spawned as child process with unique port (3001+)
4. Dashboard UI shows all apps with current status, logs, and links
## Key Files
### Server (`src/server/`)
- `apps.ts` - **The heart**: app discovery, process management, health checks, auto-restart
- `api/apps.ts` - REST API for app lifecycle (start/stop/restart, logs, icons, rename)
- `api/sync.ts` - File sync protocol (manifest, push/pull, watch)
- `index.tsx` - Entry point (minimal, initializes Hype)
- `shell.tsx` - HTML shell for web UI
### Client (`src/client/`)
- `components/` - Dashboard, Sidebar, AppDetail, Nav
- `modals/` - NewApp, RenameApp, DeleteApp dialogs
- `styles/` - Forge CSS-in-JS (themes, buttons, forms, layout)
- `state.ts` - Client state management
- `api.ts` - API client
### CLI (`src/cli/`)
- `commands/manage.ts` - list, start, stop, restart, info, new, rename, delete, open
- `commands/sync.ts` - push, pull, sync
- `commands/logs.ts` - log viewing with tail support
### Shared (`src/shared/`)
- Code shared between frontend (browser) and backend (server)
- `types.ts` - App, AppState, Manifest interfaces
- IMPORTANT: Cannot use filesystem or Node APIs (runs in browser)
### Lib (`src/lib/`)
- Code shared between CLI and server (server-side only)
- `templates.ts` - Template generation for new apps
- Can use filesystem and Node APIs (never runs in browser)
### Other
- `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script
- `TODO.txt` - Task list
## Tools
Tools are special apps that appear as tabs in the dashboard rather than standalone entries in the sidebar. They integrate directly into the Toes UI and can interact with the currently selected app.
### Creating a Tool
Add `toes.tool` to your app's `package.json`:
```json
{
"toes": {
"icon": "🔧",
"tool": true
},
"scripts": {
"toes": "bun run --watch index.tsx"
}
}
```
### How Tools Work
- Tools run as regular apps (spawned process with unique port)
- Displayed as iframes overlaying the tab content area
- Receive `?app=<name>` query parameter for the currently selected app
- Iframes are cached per tool+app combination (never recreated once loaded)
- Tool state persists across tab switches
- **App paths**: When accessing app files, tools must use `APPS_DIR/<app>/current` (not just `APPS_DIR/<app>`) to resolve through the version symlink
### CLI Flags
```bash
toes list # Lists all apps including tools
toes list --tools # Lists tools only
toes list --apps # Lists regular apps only (excludes tools)
```
### Tool vs App
| Aspect | Regular App | Tool |
|--------|-------------|------|
| `toes.tool` | absent/false | true |
| UI location | Sidebar | Tab bar |
| Rendering | New browser tab | Iframe in dashboard |
| Context | Standalone | Knows selected app via query param |
1. Server scans `APPS_DIR` for directories with a `package.json` containing a `scripts.toes` entry
2. Each app is spawned as a child process with a unique port (3001-3100)
3. Dashboard UI shows all apps with status, logs, and links via SSE
4. CLI communicates with the server over HTTP
## Tech Stack
- **Bun** runtime (not Node)
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
- **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
- **Forge** (typed CSS-in-JS) from `@because/forge`
- **Commander** + **kleur** for CLI
- TypeScript + Hono JSX
- Client renders with `hono/jsx/dom` (no build step, served directly)
## Running
```bash
bun run --hot src/server/index.tsx # Dev mode with hot reload
bun run dev # Hot reload (deletes pub/client/index.js first)
bun run start # Production
bun run check # Type check
bun run test # Tests
```
## App Structure
```tsx
// apps/example/index.tsx
import { Hype } from "@because/hype"
const app = new Hype()
app.get("/", (c) => c.html(<h1>Content</h1>))
export default app.defaults
```
## Conventions
- Apps get `PORT` env var from host
- Each app is isolated process with own dependencies
- No path-based routing - apps run on separate ports
- `DATA_DIR` env controls where apps are discovered
- Path aliases: `$` → server, `@` → shared, `%` → lib
## Environment Variables
Env vars are stored per-app in `TOES_DIR/env/`:
## Project Structure
```
${DATA_DIR}/toes/env/
clock.env # env vars for clock app
todo.env # env vars for todo app
src/
server/ # HTTP server and process management ($)
client/ # Browser-side dashboard
shared/ # Types shared between server and client (@)
lib/ # Code shared between CLI and server (%)
cli/ # CLI tool
tools/ # @because/toes package exports
pages/ # Hype page routes
```
`TOES_DIR` defaults to `${DATA_DIR}/toes`. Apps cannot access this directory directly.
Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
## Current State
### Server (`src/server/`)
### Infrastructure (Complete)
- App discovery, spawn, watch, auto-restart with exponential backoff
- Health checks every 30s (3 failures trigger restart)
- Port pool (3001-3100), sticky allocation per app
- SSE streams for real-time app state and log updates
- File sync protocol with hash-based manifests
- `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 via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management.
- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app reload (triggered by git tool after deploy), file watch SSE.
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps.
- `shell.tsx` -- Minimal HTML shell for the SPA.
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
### CLI
- Full management: `toes list|start|stop|restart|info|new|rename|delete|open`
- File sync: `toes push|pull|sync`
- Logs: `toes logs [-f] <app>`
### Client (`src/client/`)
Check `TODO.txt` for planned features
Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx` files directly.
- `index.tsx` -- Entry point. Initializes rendering, SSE connection, theme, tool iframes.
- `state.ts` -- Mutable module-level state (`apps`, `selectedApp`, `sidebarCollapsed`, etc.) with localStorage persistence. Components import state directly.
- `api.ts` -- Fetch wrappers for server API calls.
- `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication).
- `update.tsx` -- SSE connection to `/api/apps/stream` for real-time state updates.
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection.
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout).
- `themes/` -- Light/dark theme token definitions.
### CLI (`src/cli/`)
- `index.ts` -- Entry point (`#!/usr/bin/env bun`).
- `setup.ts` -- Commander program definition with all commands.
- `commands/` -- Command implementations.
- `http.ts` -- HTTP client for talking to the toes server.
- `name.ts` -- App name resolution (argument or current directory).
- `prompts.ts` -- Interactive prompts.
- `pager.ts` -- Pipe output through system pager.
CLI commands:
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`, `share`, `unshare`
- **Config**: `env`
### Shared (`src/shared/`)
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
### Lib (`src/lib/`)
Server-side code shared between CLI and server. Can use Node APIs.
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
### Tools Package (`src/tools/`)
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/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`
### Pages (`src/pages/`)
Hype page routes. `index.tsx` renders the Shell.
## Key Concepts
### App Lifecycle
States: `invalid` -> `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
- 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
- Health checks: every 30s to `/ok`, 3 consecutive failures trigger restart
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), resets after 60s stable run
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
### 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`.
### Versioning
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
Per-app env files in `TOES_DIR/env/`:
- `_global.env` -- shared by all apps
- `<appname>.env` -- per-app overrides
The server sets these on each app process: `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, `DATA_DIR`.
### SSE Streaming
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/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`.
## Coding Guidelines
@ -224,338 +212,6 @@ function start(app: App): void {
}
```
## Guide to Writing Tools
## Writing Apps and Tools
This guide explains how to write a Toes tool.
A tool is just an app that is displayed in a tab and an iframe on each app's page. When the iframe is loaded, it's passed ?app=<app-name>.
### Minimal Tool Structure
A tool needs four files at minimum:
**.npmrc**
```
registry=https://npm.nose.space
```
**tsconfig.json**
```json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": ["src/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
}
}
```
**package.json**
```json
{
"name": "my-tool",
"private": true,
"module": "index.tsx",
"type": "module",
"scripts": {
"toes": "bun run --watch index.tsx"
},
"toes": {
"tool": true, // can also be set to a string to control the tab's label
"icon": "🔧"
},
"dependencies": {
"@because/forge": "*",
"@because/hype": "*",
"@because/toes": "*"
},
"devDependencies": {
"@types/bun": "latest"
}
}
```
**index.tsx**
```tsx
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false })
// Define styled components using Forge
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
// Layout wrapper (every tool needs this pattern)
function Layout({ title, children }: { title: string; children: Child }) {
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>
)
}
// Serve styles (required)
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
// Main route
app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) {
return c.html(
<Layout title="My Tool">
<p>No app selected</p>
</Layout>
)
}
return c.html(
<Layout title="My Tool">
<h1>Tool for {appName}</h1>
</Layout>
)
})
export default app.defaults
```
### Required Imports
Every tool imports from three packages:
```tsx
// HTTP helpers
import { Hype } from '@because/hype'
// Structured CSS
import { define, stylesToCSS } from '@because/forge'
// Tool helpers
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
```
### Environment Variables
Tools have access to these environment variables:
| Variable | Description |
|----------|-------------|
| `APPS_DIR` | Path to the apps directory (e.g., `/home/toes/apps`) |
| `TOES_URL` | Base URL of the Toes server (e.g., `http://192.168.1.100:3000`) |
| `PORT` | Port assigned to this tool |
### Query Parameters
Tools receive `?app=<name>` when an app is selected. Add custom params as needed:
```tsx
const appName = c.req.query('app') // Always present when app selected
const version = c.req.query('version') // Custom param (code tool)
const file = c.req.query('file') // Custom param (code tool)
```
### Styling with Forge
Use `define()` to create styled components and `theme()` for design tokens:
```tsx
const Button = define('Button', {
base: 'button', // HTML element (optional, defaults to 'div')
padding: '8px 16px',
backgroundColor: theme('colors-primary'),
color: 'white',
border: 'none',
borderRadius: theme('radius-md'),
cursor: 'pointer',
states: {
':hover': { opacity: 0.9 },
':disabled': { opacity: 0.5, cursor: 'not-allowed' },
},
})
const List = define('List', {
listStyle: 'none',
padding: 0,
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
selectors: {
'& > li:last-child': { borderBottom: 'none' },
},
})
```
Common theme values:
- `colors-text`, `colors-textMuted`, `colors-bg`, `colors-bgHover`, `colors-bgElement`, `colors-bgSubtle`
- `colors-border`, `colors-link`, `colors-primary`, `colors-error`
- `colors-statusRunning`, `colors-statusStopped`
- `fonts-sans`, `fonts-mono`
- `radius-md`
### ToolScript
Always include `<ToolScript />` in your layout's body. It handles:
1. Theme detection (light/dark mode based on system preference)
2. Iframe height communication with parent dashboard
### Accessing App Files
When accessing files in an app, always use the `current` symlink:
```tsx
import { join } from 'path'
import { readdir } from 'fs/promises'
const appPath = join(APPS_DIR, appName, 'current') // ✓ Correct
// NOT: join(APPS_DIR, appName) // ✗ Wrong
```
### Linking Between Tools
Use `TOES_URL` to create links to other tools:
```tsx
const TOES_URL = process.env.TOES_URL!
// Link from versions tool to code tool
<a href={`${TOES_URL}/tool/code?app=${appName}&version=${version}`}>
View Code
</a>
```
### Tool Complexity Levels
**Simple (single file)** - `versions` tool
- Everything in `index.tsx`
- Good for straightforward display tools
**Medium (re-export)** - `code` tool
- `index.tsx` re-exports: `export { default } from './src/server'`
- Actual implementation in `src/server/index.tsx`
- Good when you want cleaner organization
**Complex (multiple modules)** - `cron` tool
- Main server in `index.tsx`
- Business logic in `lib/` directory
- Good for tools with significant logic (discovery, scheduling, state management)
### Common Patterns
**Error states:**
```tsx
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
})
if (!appName) {
return c.html(
<Layout title="My Tool">
<ErrorBox>Please specify an app with ?app=&lt;name&gt;</ErrorBox>
</Layout>
)
}
```
**Empty states:**
```tsx
const EmptyState = define('EmptyState', {
padding: '40px 20px',
textAlign: 'center',
color: theme('colors-textMuted'),
})
{items.length === 0 ? (
<EmptyState>No items found</EmptyState>
) : (
<List>...</List>
)}
```
**Form handling:**
```tsx
app.get('/new', async c => {
return c.html(<Layout><form method="post" action="/new">...</form></Layout>)
})
app.post('/new', async c => {
const body = await c.req.parseBody()
const name = body.name as string
// Process form...
return c.redirect('/')
})
```
**File watching (for live updates):**
```tsx
import { watch } from 'fs'
let debounceTimer: Timer | null = null
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
if (!filename?.includes('/target/')) return
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(handleChange, 100)
})
```
### Checklist for New Tools
1. [ ] `.npmrc` contains `registry=https://npm.nose.space`
2. [ ] `tsconfig.json` matches the exact config shown above (do not improvise)
3. [ ] `package.json` has `toes.tool: true` and `toes.icon`
4. [ ] `scripts.toes` uses `bun run --watch index.tsx`
5. [ ] Dependencies include `@because/forge`, `@because/hype`, `@because/toes`
6. [ ] Import `baseStyles`, `ToolScript`, `theme` from `@because/toes/tools`
7. [ ] Layout body includes `<ToolScript />`
8. [ ] Styles served at `/styles.css` with `baseStyles + stylesToCSS()`
9. [ ] Main route handles missing `?app` parameter gracefully
10. [ ] Uses `APPS_DIR/<app>/current` for file paths
11. [ ] Exports `app.defaults` as default export
See `docs/GUIDE.md` for the guide to writing toes apps and tools.

374
PID.md
View File

@ -1,374 +0,0 @@
# PID File Tracking for Robust Process Management
## Problem Statement
When the Toes host process crashes unexpectedly (OOM, SIGKILL, power loss, kernel panic), child app processes continue running as orphans. On restart, Toes has no knowledge of these processes:
- **Port conflicts**: Orphans hold ports, new instances fail to bind
- **Resource waste**: Zombie processes consume memory/CPU
- **State confusion**: App appears "stopped" but is actually running
- **Data corruption**: Multiple instances may write to same files
Currently, Toes only handles graceful shutdown (SIGTERM/SIGINT). There's no recovery mechanism for ungraceful termination.
## Proposed Solution: PID File Tracking
### Design
Store PID files in `TOES_DIR/pids/`:
```
${TOES_DIR}/pids/
clock.pid # Contains: 12345
todo.pid # Contains: 12389
weather.pid # Contains: 12402
```
### Lifecycle
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ App Start │────▶│ Write PID │────▶│ Running │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐ │
│ Delete PID │◀──────────┘
└─────────────┘ App Exit
```
On host startup:
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Host Init │────▶│ Scan PIDs │────▶│Kill Orphans │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐
│Clean Stale │
│ PID Files │
└─────────────┘
```
### Implementation
#### 1. PID Directory Setup
```typescript
const PIDS_DIR = join(TOES_DIR, 'pids')
function ensurePidsDir() {
if (!existsSync(PIDS_DIR)) {
mkdirSync(PIDS_DIR, { recursive: true })
}
}
```
#### 2. Write PID on Start
In `runApp()`, after spawning:
```typescript
const proc = Bun.spawn(['bun', 'run', 'toes'], { ... })
app.proc = proc
// Write PID file
const pidFile = join(PIDS_DIR, `${dir}.pid`)
writeFileSync(pidFile, String(proc.pid))
```
#### 3. Delete PID on Exit
In the `proc.exited.then()` handler:
```typescript
proc.exited.then(code => {
// Remove PID file
const pidFile = join(PIDS_DIR, `${dir}.pid`)
if (existsSync(pidFile)) {
unlinkSync(pidFile)
}
// ... existing cleanup
})
```
#### 4. Orphan Cleanup on Startup
New function called during `initApps()`:
```typescript
function cleanupOrphanProcesses() {
ensurePidsDir()
for (const file of readdirSync(PIDS_DIR)) {
if (!file.endsWith('.pid')) continue
const appName = file.replace('.pid', '')
const pidFile = join(PIDS_DIR, file)
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10)
if (isNaN(pid)) {
// Invalid PID file, remove it
unlinkSync(pidFile)
hostLog(`Removed invalid PID file: ${file}`)
continue
}
if (isProcessRunning(pid)) {
// Orphan found - kill it
hostLog(`Found orphan process for ${appName} (PID ${pid}), terminating...`)
try {
process.kill(pid, 'SIGTERM')
// Give it 5 seconds, then SIGKILL
setTimeout(() => {
if (isProcessRunning(pid)) {
hostLog(`Orphan ${appName} (PID ${pid}) didn't terminate, sending SIGKILL`)
process.kill(pid, 'SIGKILL')
}
}, 5000)
} catch (e) {
// Process may have exited between check and kill
hostLog(`Failed to kill orphan ${appName}: ${e}`)
}
}
// Remove stale PID file
unlinkSync(pidFile)
}
}
function isProcessRunning(pid: number): boolean {
try {
// Sending signal 0 checks if process exists without killing it
process.kill(pid, 0)
return true
} catch {
return false
}
}
```
#### 5. Integration Point
Update `initApps()`:
```typescript
export function initApps() {
initPortPool()
setupShutdownHandlers()
cleanupOrphanProcesses() // <-- Add here, before discovery
rotateLogs()
createAppSymlinks()
discoverApps()
runApps()
}
```
### Edge Cases
| Scenario | Handling |
|----------|----------|
| PID reused by OS | Check if process command matches expected pattern before killing |
| PID file corrupted | Delete invalid files, log warning |
| Multiple Toes instances | Use file locking or instance ID in PID path |
| App renamed while running | Old PID file orphaned; cleanup handles it |
| Permission denied on kill | Log error, continue with other orphans |
### Enhanced: Validate Process Identity
To avoid killing an unrelated process that reused the PID:
```typescript
function isOurProcess(pid: number, appName: string): boolean {
try {
// On macOS/Linux, check /proc or use ps
const result = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'args='])
const cmd = new TextDecoder().decode(result.stdout).trim()
// Check if it looks like a Toes app process
return cmd.includes('bun') && cmd.includes('toes')
} catch {
return false
}
}
```
---
## Related Recommendations
### 1. Store Port in PID File
Extend PID files to include port for faster recovery:
```
# clock.pid
12345
3001
```
Or use JSON:
```json
{"pid": 12345, "port": 3001, "started": 1706900000000}
```
This allows Toes to reclaim the exact port on restart, avoiding port shuffling.
### 2. Circuit Breaker for Crash Loops
Add crash tracking to prevent infinite restart loops:
```typescript
interface CrashRecord {
timestamp: number
exitCode: number
}
// Store in TOES_DIR/crashes/<app>.json
const CRASH_WINDOW = 3600000 // 1 hour
const MAX_CRASHES = 10
function recordCrash(appName: string, exitCode: number) {
const file = join(TOES_DIR, 'crashes', `${appName}.json`)
const crashes: CrashRecord[] = existsSync(file)
? JSON.parse(readFileSync(file, 'utf-8'))
: []
// Add new crash
crashes.push({ timestamp: Date.now(), exitCode })
// Prune old crashes
const cutoff = Date.now() - CRASH_WINDOW
const recent = crashes.filter(c => c.timestamp > cutoff)
writeFileSync(file, JSON.stringify(recent))
return recent.length
}
function shouldCircuitBreak(appName: string): boolean {
const file = join(TOES_DIR, 'crashes', `${appName}.json`)
if (!existsSync(file)) return false
const crashes: CrashRecord[] = JSON.parse(readFileSync(file, 'utf-8'))
const cutoff = Date.now() - CRASH_WINDOW
const recent = crashes.filter(c => c.timestamp > cutoff)
return recent.length >= MAX_CRASHES
}
```
### 3. Track Restart Timer for Cancellation
Store scheduled restart timers on the app object:
```typescript
export type App = SharedApp & {
// ... existing fields
restartTimer?: Timer // <-- Add this
}
```
Update `scheduleRestart()`:
```typescript
function scheduleRestart(app: App, dir: string) {
// Cancel any existing scheduled restart
if (app.restartTimer) {
clearTimeout(app.restartTimer)
}
// ... existing delay calculation ...
app.restartTimer = setTimeout(() => {
app.restartTimer = undefined
// ... existing restart logic
}, delay)
}
```
Update `clearTimers()`:
```typescript
const clearTimers = (app: App) => {
// ... existing timer cleanup ...
if (app.restartTimer) {
clearTimeout(app.restartTimer)
app.restartTimer = undefined
}
}
```
### 4. Exit Code Classification
```typescript
function classifyExit(code: number | null): 'restart' | 'invalid' | 'stop' {
if (code === null) return 'restart' // Killed by signal
if (code === 0) return 'stop' // Clean exit
if (code === 2) return 'invalid' // Bad arguments/config
if (code >= 128) {
// Killed by signal (128 + signal number)
const signal = code - 128
if (signal === 9) return 'restart' // SIGKILL (OOM?)
if (signal === 15) return 'stop' // SIGTERM (intentional)
}
return 'restart' // Default: try again
}
```
### 5. Install Timeout
Wrap `bun install` with a timeout:
```typescript
async function installWithTimeout(cwd: string, timeout = 60000): Promise<boolean> {
const install = Bun.spawn(['bun', 'install'], {
cwd,
stdout: 'pipe',
stderr: 'pipe'
})
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
install.kill()
reject(new Error('Install timeout'))
}, timeout)
})
try {
await Promise.race([install.exited, timeoutPromise])
return install.exitCode === 0
} catch (e) {
return false
}
}
```
---
## Implementation Priority
| Change | Effort | Impact | Priority |
|--------|--------|--------|----------|
| PID file tracking | Medium | High | **1** |
| Orphan cleanup on startup | Medium | High | **1** |
| Track restart timer | Low | Medium | **2** |
| Install timeout | Low | Medium | **2** |
| Circuit breaker | Medium | Medium | **3** |
| Exit code classification | Low | Low | **4** |
| Process identity validation | Medium | Low | **5** |
---
## Testing Checklist
- [ ] Host crashes while apps running → orphans cleaned on restart
- [ ] App crashes → PID file removed, restart scheduled
- [ ] App stopped manually → PID file removed, no restart
- [ ] Stale PID file (process gone) → file cleaned up
- [ ] PID reused by unrelated process → not killed (with identity check)
- [ ] Multiple rapid restarts → circuit breaker triggers
- [ ] Rename app while running → handled gracefully
- [ ] `bun install` hangs → times out, app marked failed

View File

@ -1,21 +1,38 @@
# 🐾 Toes
Toes is a personal web server you run in your home.
Toes is a personal web appliance you run on your home network.
Plug it in, turn it on, and forget about the cloud.
## quickstart
## setup
1. Plug in and turn on your Toes computer.
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
3. Visit https://toes.local to get started!
Toes runs on a Raspberry Pi. You'll need:
- A Raspberry Pi 5 running the latest Raspberry Pi OS
- A `toes` user with passwordless sudo
SSH into your Pi as the `toes` user and run:
```bash
curl -fsSL https://toes.dev/install | bash
```
This will:
1. Install system dependencies (git, fish shell, networking tools)
2. Install Bun and grant it network binding capabilities
3. Clone and build the toes server
4. Set up bundled apps and tools (clock, code, cron, env, stats)
5. Install and enable a systemd service for auto-start
Once complete, visit `http://<hostname>.local` on your local network.
## features
- Hosts bun/hono/hype webapps - both SSR and SPA.
- `toes` CLI for pushing and pulling from your server.
- `toes` CLI for local dev mode.
- Effortlessly hosts bun/hype webapps - both SSR and SPA.
- `git push`, Heroku-style deploys
- https://toes.local web UI for managing your projects.
- Per-branch staging environments for Claude.
- `toes` CLI for managing your projects.
## cli configuration
@ -27,14 +44,11 @@ TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
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.
- HTTPS Tunnel for sharing your apps with the world.
- Charts and graphs in the webUI.
- non-webapps
## february goal

View File

@ -1,55 +0,0 @@
# toes
## server
[x] start toes server
[x] scans for apps/**/package.json, scripts.toes
[x] runs that for each, giving it a PORT
[x] has GET / page that shows all the running apps/status/port
[x] watch each app and restart it on update
[x] watches for and adds/removes apps
[ ] run on rpi on boot
[ ] found at http://toes.local
[ ] https?
[ ] apps are subdomains (verify if this works w/ chrome+safari)
[ ] if not: apps get ports but on the proper domain ^^
## apps
[x] truism
[x] big clock
[ ] shrimp repl(?)
[ ] dungeon party
## cli
[x] `toes --help`
[x] `toes --version`
[x] `toes list`
[x] `toes start <app>`
[x] `toes stop <app>`
[x] `toes restart <app>`
[x] `toes open <app>`
[x] `toes logs <app>`
[x] `toes logs -f <app>`
[x] `toes info <app>`
[x] `toes new`
[x] `toes pull`
[x] `toes push`
[x] `toes sync`
[x] `toes new --spa`
[x] `toes new --ssr`
[x] `toes new --bare`
[ ] needs to either check toes.local or take something like TOES_URL
## webui
[x] list projects
[x] start/stop/restart project
[x] create project
[x] todo.txt
[x] tools
[x] code browser
[x] versioned pushes
[x] version browser
[ ] ...

View File

@ -1,11 +0,0 @@
import { Hype } from '@because/hype'
const app = new Hype
app.get('/', c => c.html(<h1>Hi there!</h1>))
app.get('/ok', c => c.text('ok'))
const apps = () => {
}
export default app.defaults

View File

@ -1,21 +0,0 @@
{
"name": "basic",
"module": "src/index.ts",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx",
"start": "bun toes",
"dev": "bun run --hot index.tsx"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.2"
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1"
}
}

View File

@ -3,11 +3,18 @@ import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, stat } from 'fs/promises'
import { readFileSync } from 'fs'
import { join, extname, basename } from 'path'
import { join, resolve, extname, basename } from 'path'
import type { Child } from 'hono/jsx'
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 Container = define('Container', {
@ -257,21 +264,15 @@ const fileMemoryScript = `
var params = new URLSearchParams(window.location.search);
var app = params.get('app');
var file = params.get('file');
var version = params.get('version') || 'current';
if (!app) return;
var key = 'code-app:' + app + ':' + version + ':file';
var key = 'code-app:' + app + ':file';
if (params.has('file')) {
// Explicit file param (even empty) - save it
if (file) localStorage.setItem(key, file);
else localStorage.removeItem(key);
} else {
// No file param - restore saved location
var saved = localStorage.getItem(key);
if (saved) {
var url = '/?app=' + encodeURIComponent(app);
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
url += '&file=' + encodeURIComponent(saved);
window.location.replace(url);
window.location.replace('/?app=' + encodeURIComponent(app) + '&file=' + encodeURIComponent(saved));
}
}
})();
@ -327,14 +328,14 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
app.get('/raw', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file')
if (!appName || !filePath) {
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)
if (!await file.exists()) {
@ -346,14 +347,14 @@ app.get('/raw', async c => {
app.post('/save', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file')
if (!appName || !filePath) {
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()
try {
@ -385,10 +386,9 @@ async function listFiles(appPath: string, subPath: string = '') {
interface BreadcrumbProps {
appName: string
filePath: string
versionParam: string
}
function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
const parts = filePath ? filePath.split('/').filter(Boolean) : []
const crumbs: { name: string; path: string }[] = []
@ -401,7 +401,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
return (
<Breadcrumb>
{crumbs.length > 0 ? (
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=`}>{appName}</BreadcrumbLink>
<BreadcrumbLink href={`/?app=${appName}&file=`}>{appName}</BreadcrumbLink>
) : (
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
)}
@ -411,7 +411,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
{i === crumbs.length - 1 ? (
<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 => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') || ''
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 {
await stat(appPath)
} catch {
return c.html(
<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>
)
}
const fullPath = join(appPath, filePath)
let fileStats
try {
@ -515,18 +529,16 @@ app.get('/', async c => {
)
}
const versionParam = version !== 'current' ? `&version=${version}` : ''
if (fileStats.isFile()) {
const filename = basename(fullPath)
const fileType = getFileType(filename)
const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
const rawUrl = `/raw?app=${appName}&file=${filePath}`
const downloadUrl = `${rawUrl}&download=1`
if (fileType === 'image') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
@ -543,7 +555,7 @@ app.get('/', async c => {
if (fileType === 'audio') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
@ -560,7 +572,7 @@ app.get('/', async c => {
if (fileType === 'video') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
@ -615,7 +627,7 @@ saveBtn.onclick = async () => {
status.textContent = 'Saving...';
try {
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
const res = await fetch('/save?app=${appName}&file=${filePath}', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: jar.toString()
@ -641,14 +653,14 @@ document.addEventListener('keydown', (e) => {
`
return c.html(
<Layout title={`${appName}/${filePath}`} editable>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<CodeBlock>
<CodeHeader>
<span>{filename}</span>
<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>
<EditButton id="save-btn">Save</EditButton>
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}`}>Done</EditLink>
<EditLink href={`/?app=${appName}&file=${filePath}`}>Done</EditLink>
</div>
</CodeHeader>
<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(
<Layout title={`${appName}/${filePath}`} highlight>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<CodeBlock>
<CodeHeader>
<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>
<pre><code class={`language-${language}`}>{content}</code></pre>
</CodeBlock>
@ -676,11 +688,11 @@ document.addEventListener('keydown', (e) => {
return c.html(
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<FileList>
{files.map(file => (
<FileItem>
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
<FileLink href={`/?app=${appName}&file=${file.path}`}>
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
<span>{file.name}</span>
</FileLink>

View File

@ -1,65 +0,0 @@
import { readdir } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { isValidSchedule, toCronExpr, type CronJob, type Schedule } from './schedules'
const APPS_DIR = process.env.APPS_DIR!
export async function getApps(): Promise<string[]> {
const entries = await readdir(APPS_DIR, { withFileTypes: true })
const apps: string[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
// Check if it has a current symlink (valid app)
if (existsSync(join(APPS_DIR, entry.name, 'current'))) {
apps.push(entry.name)
}
}
return apps.sort()
}
export async function discoverCronJobs(): Promise<CronJob[]> {
const jobs: CronJob[] = []
const apps = await readdir(APPS_DIR, { withFileTypes: true })
for (const app of apps) {
if (!app.isDirectory()) continue
const cronDir = join(APPS_DIR, app.name, 'current', 'cron')
if (!existsSync(cronDir)) continue
const files = await readdir(cronDir)
for (const file of files) {
if (!file.endsWith('.ts')) continue
const filePath = join(cronDir, file)
const name = file.replace(/\.ts$/, '')
try {
const mod = await import(filePath)
const schedule = mod.schedule as Schedule
if (!isValidSchedule(schedule)) {
console.error(`Invalid schedule in ${filePath}: ${schedule}`)
continue
}
jobs.push({
id: `${app.name}:${name}`,
app: app.name,
name,
file: filePath,
schedule,
cronExpr: toCronExpr(schedule),
state: 'idle',
})
} catch (e) {
console.error(`Failed to load cron file ${filePath}:`, e)
}
}
}
return jobs
}

View File

@ -1,50 +0,0 @@
import { join } from 'path'
import type { CronJob } from './schedules'
import { getNextRun } from './scheduler'
const APPS_DIR = process.env.APPS_DIR!
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
if (job.state === 'disabled') return
job.state = 'running'
job.lastRun = Date.now()
onUpdate()
const cwd = join(APPS_DIR, job.app, 'current')
try {
const proc = Bun.spawn(['bun', 'run', job.file], {
cwd,
env: { ...process.env },
stdout: 'pipe',
stderr: 'pipe',
})
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
const code = await proc.exited
job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = code
job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined
job.state = 'idle'
job.nextRun = getNextRun(job.id)
// Log result
console.log(`[cron] ${job.id} finished: code=${code} duration=${job.lastDuration}ms`)
if (stdout) console.log(stdout)
if (stderr) console.error(stderr)
} catch (e) {
job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = 1
job.lastError = e instanceof Error ? e.message : String(e)
job.state = 'idle'
console.error(`[cron] ${job.id} failed:`, e)
}
onUpdate()
}

View File

@ -7,7 +7,7 @@
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5",
"@because/toes": "^0.0.8",
"croner": "^9.1.0",
},
"devDependencies": {
@ -20,11 +20,13 @@
"@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=="],
"@because/sneaker": ["@because/sneaker@0.0.1", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.1.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-rN9hc13ofap+7SvfShJkTJQYBcViCiElyfb8FBMzP1SKIe8B71csZeLh+Ujye/5538ojWfM/5hRRPJ+Aa/0A+g=="],
"@because/toes": ["@because/toes@0.0.8", "https://npm.nose.space/@because/toes/-/toes-0.0.8.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.1", "commander": "^14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-ei4X+yX97dCRqAHSfsVnE4vAIAMkhG9v1WKW3whlo+BMm3TNdKuEv1o2PQpVfIChSGzO/05Y/YFbd/XdI7p/Kg=="],
"@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.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
"@types/node": ["@types/node@25.2.2", "https://npm.nose.space/@types/node/-/node-25.2.2.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
"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=="],
@ -34,12 +36,14 @@
"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=="],
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"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=="],
"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

@ -1,11 +1,11 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { baseStyles, on, ToolScript, theme } from '@because/toes/tools'
import { discoverCronJobs } from './lib/discovery'
import { scheduleJob, stopJob } from './lib/scheduler'
import { executeJob } from './lib/executor'
import { setJobs, getJob, getAllJobs, broadcast } from './lib/state'
import { SCHEDULES, type CronJob } from './lib/schedules'
import { setJobs, setInvalidJobs, getJob, getAllJobs, getInvalidJobs, broadcast } from './lib/state'
import { SCHEDULES, type CronJob, type InvalidJob } from './lib/schedules'
import type { Child } from 'hono/jsx'
import { join } from 'path'
import { mkdir, writeFile } from 'fs/promises'
@ -15,7 +15,7 @@ const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false })
// Styles (follow versions tool pattern)
// Styles
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
@ -74,6 +74,7 @@ const Time = define('Time', {
const RunButton = define('RunButton', {
base: 'button',
padding: '4px 10px',
marginTop: '10px',
fontSize: '12px',
backgroundColor: theme('colors-primary'),
color: 'white',
@ -92,6 +93,24 @@ const EmptyState = define('EmptyState', {
color: theme('colors-textMuted'),
})
const InvalidItem = define('InvalidItem', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
gap: '15px',
opacity: 0.7,
states: {
':last-child': { borderBottom: 'none' },
},
})
const ErrorText = define('ErrorText', {
fontSize: '12px',
color: theme('colors-error'),
flex: 1,
})
const ActionRow = define('ActionRow', {
display: 'flex',
justifyContent: 'flex-end',
@ -172,13 +191,111 @@ const CancelButton = define('CancelButton', {
},
})
const BackLink = define('BackLink', {
base: 'a',
fontSize: '13px',
color: theme('colors-textMuted'),
textDecoration: 'none',
states: {
':hover': { color: theme('colors-text') },
},
})
const DetailHeader = define('DetailHeader', {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '20px',
})
const DetailTitle = define('DetailTitle', {
base: 'h1',
fontFamily: theme('fonts-mono'),
fontSize: '18px',
fontWeight: 600,
margin: 0,
flex: 1,
})
const DetailMeta = define('DetailMeta', {
display: 'flex',
gap: '20px',
marginBottom: '20px',
fontSize: '13px',
color: theme('colors-textMuted'),
})
const MetaItem = define('MetaItem', {
display: 'flex',
gap: '6px',
})
const MetaLabel = define('MetaLabel', {
fontWeight: 500,
color: theme('colors-text'),
})
const OutputSection = define('OutputSection', {
marginTop: '20px',
})
const OutputLabel = define('OutputLabel', {
fontSize: '13px',
fontWeight: 500,
marginBottom: '8px',
})
const OutputBlock = define('OutputBlock', {
base: 'pre',
fontFamily: theme('fonts-mono'),
fontSize: '12px',
lineHeight: 1.5,
padding: '12px',
backgroundColor: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflowX: 'auto',
overflowY: 'auto',
maxHeight: '60vh',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
})
const ErrorBlock = define('ErrorBlock', {
base: 'pre',
fontFamily: theme('fonts-mono'),
fontSize: '12px',
lineHeight: 1.5,
padding: '12px',
backgroundColor: theme('colors-bgElement'),
border: `1px solid ${theme('colors-error')}`,
borderRadius: theme('radius-md'),
color: theme('colors-error'),
overflowX: 'auto',
overflowY: 'auto',
maxHeight: '60vh',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
})
const StatusBadge = define('StatusBadge', {
base: 'span',
fontSize: '12px',
padding: '2px 8px',
borderRadius: '9999px',
fontWeight: 500,
})
// Layout
function Layout({ title, children }: { title: string; children: Child }) {
function Layout({ title, children, refresh }: { title: string; children: Child; refresh?: boolean }) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{refresh && <meta http-equiv="refresh" content="2" />}
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
@ -223,22 +340,75 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
// JSON API
app.get('/api/jobs', c => {
const appFilter = c.req.query('app')
let jobs = getAllJobs()
if (appFilter) jobs = jobs.filter(j => j.app === appFilter)
jobs.sort((a, b) => a.id.localeCompare(b.id))
return c.json(jobs.map(j => ({
app: j.app,
name: j.name,
schedule: j.schedule,
state: j.state,
status: statusLabel(j),
lastRun: j.lastRun,
lastDuration: j.lastDuration,
lastExitCode: j.lastExitCode,
nextRun: j.nextRun,
})))
})
app.get('/api/jobs/:app/:name', c => {
const id = `${c.req.param('app')}:${c.req.param('name')}`
const job = getJob(id)
if (!job) return c.json({ error: 'Job not found' }, 404)
return c.json({
app: job.app,
name: job.name,
schedule: job.schedule,
state: job.state,
status: statusLabel(job),
lastRun: job.lastRun,
lastDuration: job.lastDuration,
lastExitCode: job.lastExitCode,
lastError: job.lastError,
lastOutput: job.lastOutput,
nextRun: job.nextRun,
})
})
app.post('/api/jobs/:app/:name/run', async c => {
const id = `${c.req.param('app')}:${c.req.param('name')}`
const job = getJob(id)
if (!job) return c.json({ error: 'Job not found' }, 404)
if (job.state === 'running') return c.json({ error: 'Job is already running' }, 409)
executeJob(job, broadcast)
return c.json({ ok: true, message: `Started ${id}` })
})
app.get('/', async c => {
const appFilter = c.req.query('app')
let jobs = getAllJobs()
let invalid = getInvalidJobs()
if (appFilter) {
jobs = jobs.filter(j => j.app === appFilter)
invalid = invalid.filter(j => j.app === appFilter)
}
jobs.sort((a, b) => a.id.localeCompare(b.id))
invalid.sort((a, b) => a.id.localeCompare(b.id))
const hasAny = jobs.length > 0 || invalid.length > 0
const anyRunning = jobs.some(j => j.state === 'running')
return c.html(
<Layout title="Cron Jobs">
<Layout title="Cron Jobs" refresh={anyRunning}>
<ActionRow>
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
</ActionRow>
{jobs.length === 0 ? (
{!hasAny ? (
<EmptyState>
No cron jobs found.
<br />
@ -249,7 +419,11 @@ app.get('/', async c => {
{jobs.map(job => (
<JobItem>
<StatusDot style={{ backgroundColor: statusColor(job) }} />
<JobName>{job.app}/{job.name}</JobName>
<JobName>
<a href={`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`} style={{ color: 'inherit', textDecoration: 'none' }}>
{job.app}/{job.name}
</a>
</JobName>
<Schedule>{job.schedule}</Schedule>
<Time title="Last run">{formatRelative(job.lastRun)}</Time>
<Time title="Next run">{formatRelative(job.nextRun)}</Time>
@ -260,12 +434,99 @@ app.get('/', async c => {
</form>
</JobItem>
))}
{invalid.map(job => (
<InvalidItem>
<StatusDot style={{ backgroundColor: theme('colors-error') }} />
<JobName>{job.app}/{job.name}</JobName>
<ErrorText>{job.error}</ErrorText>
</InvalidItem>
))}
</JobList>
)}
</Layout>
)
})
function statusBadgeStyle(job: CronJob): Record<string, string> {
if (job.state === 'running') return { backgroundColor: theme('colors-statusRunning'), color: 'white' }
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return { backgroundColor: theme('colors-error'), color: 'white' }
return { backgroundColor: theme('colors-bgElement'), color: theme('colors-textMuted') }
}
function statusLabel(job: CronJob): string {
if (job.state === 'running') return 'running'
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return `exit ${job.lastExitCode}`
if (job.lastRun) return 'ok'
return 'idle'
}
function formatDuration(ms?: number): string {
if (!ms) return '-'
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${Math.round(ms / 1000)}s`
return `${Math.round(ms / 60000)}m`
}
app.get('/job/:app/:name', async c => {
const id = `${c.req.param('app')}:${c.req.param('name')}`
const job = getJob(id)
const appFilter = c.req.query('app')
const backUrl = appFilter ? `/?app=${appFilter}` : '/'
if (!job) {
return c.html(
<Layout title="Job Not Found">
<BackLink href={backUrl}>&#8592; Back</BackLink>
<EmptyState>Job not found: {id}</EmptyState>
</Layout>
)
}
return c.html(
<Layout title={`${job.app}/${job.name}`} refresh={job.state === 'running'}>
<BackLink href={backUrl}>&#8592; Back</BackLink>
<DetailHeader>
<StatusDot style={{ backgroundColor: statusColor(job) }} />
<DetailTitle>{job.app}/{job.name}</DetailTitle>
<StatusBadge style={statusBadgeStyle(job)}>{statusLabel(job)}</StatusBadge>
<form method="post" action={`/run/${job.app}/${job.name}?return=detail&app=${appFilter || ''}`}>
<RunButton type="submit" disabled={job.state === 'running'}>
{job.state === 'running' ? 'Running...' : 'Run Now'}
</RunButton>
</form>
</DetailHeader>
<DetailMeta>
<MetaItem><MetaLabel>Schedule</MetaLabel> {job.schedule}</MetaItem>
<MetaItem><MetaLabel>Last run</MetaLabel> {job.state === 'running' ? 'now' : formatRelative(job.lastRun)}</MetaItem>
<MetaItem><MetaLabel>Duration</MetaLabel> {job.state === 'running' ? formatDuration(Date.now() - job.lastRun!) : formatDuration(job.lastDuration)}</MetaItem>
<MetaItem><MetaLabel>Next run</MetaLabel> {formatRelative(job.nextRun)}</MetaItem>
</DetailMeta>
{job.lastError && (
<OutputSection>
<OutputLabel>Error</OutputLabel>
<ErrorBlock>{job.lastError}</ErrorBlock>
</OutputSection>
)}
{job.lastOutput ? (
<OutputSection>
<OutputLabel>Output</OutputLabel>
<OutputBlock id="output">{job.lastOutput}</OutputBlock>
</OutputSection>
) : job.state === 'running' ? (
<OutputSection>
<OutputLabel>Output</OutputLabel>
<OutputBlock id="output" style={{ color: theme('colors-textMuted') }}>Waiting for output...</OutputBlock>
</OutputSection>
) : job.lastRun && !job.lastError ? (
<OutputSection>
<EmptyState>No output</EmptyState>
</OutputSection>
) : null}
<script dangerouslySetInnerHTML={{ __html: `var o=document.getElementById('output');if(o)o.scrollTop=o.scrollHeight` }} />
</Layout>
)
})
app.get('/new', async c => {
const appName = c.req.query('app') || ''
@ -311,7 +572,7 @@ app.post('/new', async c => {
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`)
// Check if file already exists
@ -336,8 +597,9 @@ export default async function() {
console.log(`[cron] Created ${appName}:${name}`)
// Trigger rediscovery
const jobs = await discoverCronJobs()
const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs)
setInvalidJobs(invalid)
for (const job of jobs) {
if (job.id === `${appName}:${name}`) {
scheduleJob(job, broadcast)
@ -356,29 +618,39 @@ app.post('/run/:app/:name', async c => {
return c.redirect('/?error=not-found')
}
await executeJob(job, broadcast)
// Fire-and-forget so the redirect happens immediately
executeJob(job, broadcast)
const returnTo = c.req.query('return')
const appFilter = c.req.query('app')
if (returnTo === 'detail') {
return c.redirect(`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`)
}
return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
})
// Initialize
async function init() {
const jobs = await discoverCronJobs()
const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs)
console.log(`[cron] Discovered ${jobs.length} jobs`)
setInvalidJobs(invalid)
console.log(`[cron] Discovered ${jobs.length} jobs, ${invalid.length} invalid`)
for (const job of jobs) {
scheduleJob(job, broadcast)
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
}
for (const job of invalid) {
console.log(`[cron] Invalid ${job.id}: ${job.error}`)
}
}
// Watch for cron file changes
let debounceTimer: Timer | null = null
async function rediscover() {
const jobs = await discoverCronJobs()
const { jobs, invalid } = await discoverCronJobs()
const existing = getAllJobs()
// Stop removed jobs
@ -402,11 +674,13 @@ async function rediscover() {
job.lastDuration = old.lastDuration
job.lastExitCode = old.lastExitCode
job.lastError = old.lastError
job.lastOutput = old.lastOutput
job.nextRun = old.nextRun
}
}
setJobs(jobs)
setInvalidJobs(invalid)
}
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
@ -417,6 +691,11 @@ watch(APPS_DIR, { recursive: true }, (_event, filename) => {
debounceTimer = setTimeout(rediscover, 100)
})
on(['app:reload', 'app:delete'], (event) => {
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
rediscover()
})
init()
export default app.defaults

View File

@ -0,0 +1,81 @@
import { readdir, readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { isValidSchedule, toCronExpr, type CronJob, type InvalidJob, type Schedule } from './schedules'
const APPS_DIR = process.env.APPS_DIR!
const SCHEDULE_RE = /export\s+const\s+schedule\s*=\s*['"]([^'"]+)['"]/
export async function getApps(): Promise<string[]> {
const entries = await readdir(APPS_DIR, { withFileTypes: true })
const apps: string[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
if (existsSync(join(APPS_DIR, entry.name, 'package.json'))) {
apps.push(entry.name)
}
}
return apps.sort()
}
export type DiscoveryResult = {
jobs: CronJob[]
invalid: InvalidJob[]
}
export async function discoverCronJobs(): Promise<DiscoveryResult> {
const jobs: CronJob[] = []
const invalid: InvalidJob[] = []
const apps = await readdir(APPS_DIR, { withFileTypes: true })
for (const app of apps) {
if (!app.isDirectory()) continue
const cronDir = join(APPS_DIR, app.name, 'cron')
if (!existsSync(cronDir)) continue
const files = await readdir(cronDir)
for (const file of files) {
if (!file.endsWith('.ts')) continue
const filePath = join(cronDir, file)
const name = file.replace(/\.ts$/, '')
const id = `${app.name}:${name}`
try {
const source = await readFile(filePath, 'utf-8')
const match = source.match(SCHEDULE_RE)
if (!match) {
invalid.push({ id, app: app.name, name, file: filePath, error: 'Missing schedule export' })
continue
}
const schedule = match[1] as Schedule
if (!isValidSchedule(schedule)) {
invalid.push({ id, app: app.name, name, file: filePath, error: `Invalid schedule: "${match[1]}"` })
continue
}
jobs.push({
id,
app: app.name,
name,
file: filePath,
schedule,
cronExpr: toCronExpr(schedule),
state: 'idle',
})
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
invalid.push({ id, app: app.name, name, file: filePath, error: msg })
}
}
}
return { jobs, invalid }
}

96
apps/cron/lib/executor.ts Normal file
View File

@ -0,0 +1,96 @@
import { join } from 'path'
import { loadAppEnv } from '@because/toes/tools'
import type { CronJob } from './schedules'
import { getNextRun } from './scheduler'
const APPS_DIR = process.env.APPS_DIR!
const TOES_DIR = process.env.TOES_DIR!
const TOES_URL = process.env.TOES_URL!
const RUNNER = join(import.meta.dir, 'runner.ts')
function forwardLog(app: string, text: string, stream: 'stdout' | 'stderr' = 'stdout') {
fetch(`${TOES_URL}/api/apps/${app}/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, stream }),
}).catch(() => {})
}
async function readStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void) {
const reader = stream.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
append(decoder.decode(value, { stream: true }))
}
}
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
if (job.state === 'disabled') return
job.state = 'running'
job.lastRun = Date.now()
job.lastOutput = undefined
job.lastError = undefined
job.lastExitCode = undefined
job.lastDuration = undefined
onUpdate()
const cwd = join(APPS_DIR, job.app)
forwardLog(job.app, `[cron] Running ${job.name}`)
try {
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
cwd,
env: { ...process.env, ...loadAppEnv(job.app), DATA_DIR: join(TOES_DIR, job.app) },
stdout: 'pipe',
stderr: 'pipe',
})
// Stream output incrementally into job fields
await Promise.all([
readStream(proc.stdout as ReadableStream<Uint8Array>, text => {
job.lastOutput = (job.lastOutput || '') + text
for (const line of text.split('\n').filter(Boolean)) {
forwardLog(job.app, `[cron:${job.name}] ${line}`)
}
}),
readStream(proc.stderr as ReadableStream<Uint8Array>, text => {
job.lastError = (job.lastError || '') + text
for (const line of text.split('\n').filter(Boolean)) {
forwardLog(job.app, `[cron:${job.name}] ${line}`, 'stderr')
}
}),
])
const code = await proc.exited
job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = code
if (!job.lastError && code !== 0) job.lastError = 'Non-zero exit'
if (code === 0) job.lastError = undefined
if (!job.lastOutput) job.lastOutput = undefined
job.state = 'idle'
job.nextRun = getNextRun(job.id)
// Log result
const status = code === 0 ? 'ok' : `failed (code=${code})`
const summary = `[cron] ${job.name} finished: ${status} duration=${job.lastDuration}ms`
console.log(summary)
forwardLog(job.app, summary, code === 0 ? 'stdout' : 'stderr')
if (job.lastOutput) console.log(job.lastOutput)
if (job.lastError) console.error(job.lastError)
} catch (e) {
job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = 1
job.lastError = e instanceof Error ? e.message : String(e)
job.state = 'idle'
console.error(`[cron] ${job.id} failed:`, e)
forwardLog(job.app, `[cron] ${job.name} failed: ${job.lastError}`, 'stderr')
}
onUpdate()
}

16
apps/cron/lib/runner.ts Normal file
View File

@ -0,0 +1,16 @@
export {}
Error.stackTraceLimit = 50
const file = process.argv[2]!
const { default: fn } = await import(file)
try {
await fn()
} catch (e) {
if (e instanceof Error) {
console.error(e.stack || e.message)
} else {
console.error(e)
}
process.exit(1)
}

View File

@ -4,6 +4,7 @@ export type Schedule =
| "30 minutes" | "15 minutes" | "5 minutes" | "1 minute"
| "30minutes" | "15minutes" | "5minutes" | "1minute"
| 30 | 15 | 5 | 1
| (string & {}) // time strings like "7am", "7:30pm", "14:00"
export type CronJob = {
id: string // "appname:filename"
@ -17,9 +18,18 @@ export type CronJob = {
lastDuration?: number
lastExitCode?: number
lastError?: string
lastOutput?: string
nextRun?: number
}
export type InvalidJob = {
id: string
app: string
name: string
file: string
error: string
}
export const SCHEDULES = [
'1 minute',
'5 minutes',
@ -62,19 +72,48 @@ const SCHEDULE_MAP: Record<string, string> = {
'1minute': '* * * * *',
}
export function toCronExpr(schedule: Schedule): string {
if (typeof schedule === 'number') {
return SCHEDULE_MAP[`${schedule}minutes`]!
}
return SCHEDULE_MAP[schedule]!
}
export function isValidSchedule(value: unknown): value is Schedule {
if (typeof value === 'number') {
return [1, 5, 15, 30].includes(value)
}
if (typeof value === 'string') {
return value in SCHEDULE_MAP
return value in SCHEDULE_MAP || parseTime(value) !== null
}
return false
}
function parseTime(s: string): { hour: number, minute: number } | null {
// 12h: "7am", "7pm", "7:30am", "7:30pm", "12am", "12:00pm"
const m12 = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i)
if (m12) {
let hour = parseInt(m12[1]!)
const minute = m12[2] ? parseInt(m12[2]) : 0
const period = m12[3]!.toLowerCase()
if (hour < 1 || hour > 12 || minute > 59) return null
if (period === 'am' && hour === 12) hour = 0
else if (period === 'pm' && hour !== 12) hour += 12
return { hour, minute }
}
// 24h: "14:00", "0:00", "23:59"
const m24 = s.match(/^(\d{1,2}):(\d{2})$/)
if (m24) {
const hour = parseInt(m24[1]!)
const minute = parseInt(m24[2]!)
if (hour > 23 || minute > 59) return null
return { hour, minute }
}
return null
}
export function toCronExpr(schedule: Schedule): string {
if (typeof schedule === 'number') {
return SCHEDULE_MAP[`${schedule}minutes`]!
}
if (schedule in SCHEDULE_MAP) {
return SCHEDULE_MAP[schedule]!
}
const time = parseTime(schedule)!
return `${time.minute} ${time.hour} * * *`
}

View File

@ -1,8 +1,10 @@
import type { CronJob } from './schedules'
import type { CronJob, InvalidJob } from './schedules'
const jobs = new Map<string, CronJob>()
const listeners = new Set<() => void>()
let invalidJobs: InvalidJob[] = []
export function setJobs(newJobs: CronJob[]) {
jobs.clear()
for (const job of newJobs) {
@ -11,6 +13,10 @@ export function setJobs(newJobs: CronJob[]) {
broadcast()
}
export function setInvalidJobs(newInvalid: InvalidJob[]) {
invalidJobs = newInvalid
}
export function getJob(id: string): CronJob | undefined {
return jobs.get(id)
}
@ -19,6 +25,10 @@ export function getAllJobs(): CronJob[] {
return Array.from(jobs.values())
}
export function getInvalidJobs(): InvalidJob[] {
return invalidJobs
}
export function broadcast() {
listeners.forEach(cb => cb())
}

View File

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

View File

@ -7,9 +7,38 @@ import type { Child } from 'hono/jsx'
const TOES_DIR = process.env.TOES_DIR ?? join(process.env.HOME!, '.toes')
const ENV_DIR = join(TOES_DIR, 'env')
const GLOBAL_ENV_PATH = join(ENV_DIR, '_global.env')
const app = new Hype({ prettyHTML: false })
const Badge = define('Badge', {
base: 'span',
fontSize: '11px',
padding: '2px 6px',
borderRadius: '3px',
backgroundColor: theme('colors-bgSubtle'),
color: theme('colors-textMuted'),
fontFamily: theme('fonts-sans'),
fontWeight: 'normal',
marginLeft: '8px',
})
const Button = define('Button', {
base: 'button',
padding: '6px 12px',
fontSize: '13px',
borderRadius: theme('radius-md'),
border: `1px solid ${theme('colors-border')}`,
backgroundColor: theme('colors-bgElement'),
color: theme('colors-text'),
cursor: 'pointer',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
@ -19,13 +48,34 @@ const Container = define('Container', {
color: theme('colors-text'),
})
const EnvList = define('EnvList', {
listStyle: 'none',
padding: 0,
margin: 0,
border: `1px solid ${theme('colors-border')}`,
const DangerButton = define('DangerButton', {
base: 'button',
padding: '6px 12px',
fontSize: '13px',
borderRadius: theme('radius-md'),
overflow: 'hidden',
border: 'none',
backgroundColor: theme('colors-error'),
color: 'white',
cursor: 'pointer',
states: {
':hover': {
opacity: 0.9,
},
},
})
const EmptyState = define('EmptyState', {
padding: '30px',
textAlign: 'center',
color: theme('colors-textMuted'),
backgroundColor: theme('colors-bgSubtle'),
borderRadius: theme('radius-md'),
})
const EnvActions = define('EnvActions', {
display: 'flex',
gap: '8px',
flexShrink: 0,
})
const EnvItem = define('EnvItem', {
@ -49,6 +99,15 @@ const EnvKey = define('EnvKey', {
color: theme('colors-text'),
})
const EnvList = define('EnvList', {
listStyle: 'none',
padding: 0,
margin: 0,
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const EnvValue = define('EnvValue', {
fontFamily: theme('fonts-mono'),
fontSize: '14px',
@ -59,42 +118,12 @@ const EnvValue = define('EnvValue', {
whiteSpace: 'nowrap',
})
const EnvActions = define('EnvActions', {
display: 'flex',
gap: '8px',
flexShrink: 0,
})
const Button = define('Button', {
base: 'button',
padding: '6px 12px',
fontSize: '13px',
borderRadius: theme('radius-md'),
border: `1px solid ${theme('colors-border')}`,
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
color: theme('colors-text'),
cursor: 'pointer',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const DangerButton = define('DangerButton', {
base: 'button',
padding: '6px 12px',
fontSize: '13px',
borderRadius: theme('radius-md'),
border: 'none',
backgroundColor: theme('colors-error'),
color: 'white',
cursor: 'pointer',
states: {
':hover': {
opacity: 0.9,
},
},
margin: '20px 0',
})
const Form = define('Form', {
@ -107,6 +136,12 @@ const Form = define('Form', {
borderRadius: theme('radius-md'),
})
const Hint = define('Hint', {
fontSize: '12px',
color: theme('colors-textMuted'),
marginTop: '10px',
})
const Input = define('Input', {
base: 'input',
flex: 1,
@ -125,27 +160,54 @@ const Input = define('Input', {
},
})
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
const Tab = define('Tab', {
base: 'a',
padding: '8px 16px',
fontSize: '13px',
fontFamily: theme('fonts-sans'),
color: theme('colors-textMuted'),
textDecoration: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
states: {
':hover': {
color: theme('colors-text'),
},
},
})
const EmptyState = define('EmptyState', {
padding: '30px',
textAlign: 'center',
color: theme('colors-textMuted'),
backgroundColor: theme('colors-bgSubtle'),
borderRadius: theme('radius-md'),
const TabActive = define('TabActive', {
base: 'a',
padding: '8px 16px',
fontSize: '13px',
fontFamily: theme('fonts-sans'),
color: theme('colors-text'),
textDecoration: 'none',
borderBottom: `2px solid ${theme('colors-primary')}`,
fontWeight: 'bold',
cursor: 'default',
})
const TabBar = define('TabBar', {
display: 'flex',
gap: '4px',
borderBottom: `1px solid ${theme('colors-border')}`,
marginBottom: '15px',
})
interface EnvVar {
key: string
value: string
}
interface LayoutProps {
title: string
children: Child
}
const appEnvPath = (appName: string) =>
join(ENV_DIR, `${appName}.env`)
function Layout({ title, children }: LayoutProps) {
return (
<html>
@ -166,29 +228,6 @@ function Layout({ title, children }: LayoutProps) {
)
}
const clientScript = `
document.querySelectorAll('[data-reveal]').forEach(btn => {
btn.addEventListener('click', () => {
const valueEl = btn.closest('[data-env-item]').querySelector('[data-value]');
const hidden = valueEl.dataset.hidden;
if (hidden) {
valueEl.textContent = hidden;
valueEl.dataset.hidden = '';
btn.textContent = 'Hide';
} else {
valueEl.dataset.hidden = valueEl.textContent;
valueEl.textContent = '••••••••';
btn.textContent = 'Reveal';
}
});
});
`
interface EnvVar {
key: string
value: string
}
function ensureEnvDir() {
if (!existsSync(ENV_DIR)) {
mkdirSync(ENV_DIR, { recursive: true })
@ -229,9 +268,27 @@ function writeEnvFile(path: string, vars: EnvVar[]) {
writeFileSync(path, content)
}
function appEnvPath(appName: string): string {
return join(ENV_DIR, `${appName}.env`)
}
const clientScript = `
document.querySelectorAll('[data-reveal]').forEach(btn => {
btn.addEventListener('click', () => {
const valueEl = btn.closest('[data-env-item]').querySelector('[data-value]');
const hidden = valueEl.dataset.hidden;
if (hidden) {
valueEl.textContent = hidden;
valueEl.dataset.hidden = '';
valueEl.style.whiteSpace = 'pre-wrap';
valueEl.style.wordBreak = 'break-all';
btn.textContent = 'Hide';
} else {
valueEl.dataset.hidden = valueEl.textContent;
valueEl.textContent = '••••••••';
valueEl.style.whiteSpace = 'nowrap';
valueEl.style.wordBreak = '';
btn.textContent = 'Reveal';
}
});
});
`
app.get('/ok', c => c.text('ok'))
@ -243,24 +300,100 @@ app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) {
// Dashboard view: global env vars only
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
return c.html(
<Layout title="Environment Variables">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox>
<Layout title="Global Environment Variables">
{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>
)
}
const tab = c.req.query('tab') === 'global' ? 'global' : 'app'
const appUrl = `/?app=${appName}`
const globalUrl = `/?app=${appName}&tab=global`
if (tab === 'global') {
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
return c.html(
<Layout title={`Env - Global`}>
<TabBar>
<Tab href={appUrl}>App</Tab>
<TabActive href={globalUrl}>Global</TabActive>
</TabBar>
{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?app=${appName}&key=${v.key}`} style="margin:0">
<DangerButton type="submit">Delete</DangerButton>
</form>
</EnvActions>
</EnvItem>
))}
</EnvList>
)}
<Form method="POST" action={`/set-global?app=${appName}`}>
<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>
)
}
const appVars = parseEnvFile(appEnvPath(appName))
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
const globalKeys = new Set(globalVars.map(v => v.key))
return c.html(
<Layout title={`Env - ${appName}`}>
{appVars.length === 0 ? (
<TabBar>
<TabActive href={appUrl}>App</TabActive>
<Tab href={globalUrl}>Global</Tab>
</TabBar>
{appVars.length === 0 && globalKeys.size === 0 ? (
<EmptyState>No environment variables</EmptyState>
) : (
<EnvList>
{appVars.map(v => (
<EnvItem data-env-item>
<EnvKey>{v.key}</EnvKey>
<EnvKey>
{v.key}
{globalKeys.has(v.key) && <Badge>overrides global</Badge>}
</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
@ -270,6 +403,18 @@ app.get('/', async c => {
</EnvActions>
</EnvItem>
))}
{globalVars.filter(v => !appVars.some(a => a.key === v.key)).map(v => (
<EnvItem data-env-item>
<EnvKey>
{v.key}
<Badge>global</Badge>
</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
</EnvActions>
</EnvItem>
))}
</EnvList>
)}
<Form method="POST" action={`/set?app=${appName}`}>
@ -316,4 +461,36 @@ app.post('/delete', async c => {
return c.redirect(`/?app=${appName}`)
})
app.post('/set-global', async c => {
const appName = c.req.query('app')
const body = await c.req.parseBody()
const key = String(body.key).trim().toUpperCase()
const value = String(body.value)
if (!key) return c.text('Missing key', 400)
const vars = parseEnvFile(GLOBAL_ENV_PATH)
const existing = vars.findIndex(v => v.key === key)
if (existing >= 0) {
vars[existing]!.value = value
} else {
vars.push({ key, value })
}
writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
})
app.post('/delete-global', async c => {
const appName = c.req.query('app')
const key = c.req.query('key')
if (!key) return c.text('Missing key', 400)
const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key)
writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
})
export default app.defaults

View File

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

View File

@ -1 +0,0 @@
hi

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=="],
}
}

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

@ -0,0 +1,865 @@
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}` }
}
// Verify package.json with scripts.toes exists
const pkgPath = join(tmpDir, 'package.json')
if (!(await Bun.file(pkgPath).exists())) {
await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'No package.json found in repository' }
}
try {
const pkg = JSON.parse(await Bun.file(pkgPath).text())
if (!pkg.scripts?.toes) {
await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'package.json missing scripts.toes entry' }
}
} catch {
await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'Invalid package.json' }
}
// 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 })
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",
"type": "module",
"private": true,
@ -10,7 +10,9 @@
},
"toes": {
"tool": true,
"icon": "📦"
"dashboard": true,
"share": true,
"icon": "🔀"
},
"devDependencies": {
"@types/bun": "latest"
@ -21,6 +23,6 @@
"dependencies": {
"@because/forge": "^0.0.1",
"@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

@ -1,15 +1,20 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { existsSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
import type { Child } from 'hono/jsx'
// ============================================================================
// Configuration
// ============================================================================
const SAMPLE_INTERVAL_MS = 10_000 // How often to sample process stats
const DATA_HISTORY_MAX_DAYS = 30 // Keep 30 days of data size history
const SAMPLE_INTERVAL_MS = 10_000 // How often to sample process metrics
const HISTORY_MAX_SAMPLES = 60 // Keep 10 minutes of history at 10s intervals
const APPS_DIR = process.env.APPS_DIR!
const TOES_DIR = process.env.TOES_DIR!
const TOES_URL = process.env.TOES_URL!
// ============================================================================
@ -24,6 +29,13 @@ interface App {
tool?: boolean
}
interface AppMetrics extends App {
cpu?: number
dataSize?: number
memory?: number
rss?: number
}
interface HistorySample {
timestamp: number
cpu: number
@ -31,58 +43,42 @@ interface HistorySample {
rss: number
}
interface ProcessStats {
interface DataSample {
date: string
bytes: number
}
interface ProcessMetrics {
pid: number
cpu: number
memory: number
rss: number
}
interface AppStats extends App {
cpu?: number
memory?: number
rss?: number
interface SystemMetrics {
cpu: number
ram: { used: number, total: number, percent: number }
disk: { used: number, total: number, percent: number }
}
// ============================================================================
// Process Stats Collection
// Process Metrics Collection
// ============================================================================
const statsCache = new Map<number, ProcessStats>()
const appHistory = new Map<string, HistorySample[]>() // app name -> history
const dataHistory = new Map<string, DataSample[]>() // app name -> daily data size
const metricsCache = new Map<number, ProcessMetrics>()
async function sampleProcessStats(): Promise<void> {
try {
const proc = Bun.spawn(['ps', '-eo', 'pid,pcpu,pmem,rss'], {
stdout: 'pipe',
stderr: 'ignore',
})
const text = await new Response(proc.stdout).text()
const lines = text.trim().split('\n').slice(1) // Skip header
statsCache.clear()
for (const line of lines) {
const parts = line.trim().split(/\s+/)
if (parts.length >= 4) {
const pid = parseInt(parts[0]!, 10)
const cpu = parseFloat(parts[1]!)
const memory = parseFloat(parts[2]!)
const rss = parseInt(parts[3]!, 10) // KB
if (!isNaN(pid) && pid > 0) {
statsCache.set(pid, { pid, cpu, memory, rss })
}
}
}
} catch (err) {
console.error('Failed to sample process stats:', err)
}
function getDataHistory(appName: string): DataSample[] {
return dataHistory.get(appName) ?? []
}
function getProcessStats(pid: number): ProcessStats | undefined {
return statsCache.get(pid)
function getHistory(appName: string): HistorySample[] {
return appHistory.get(appName) ?? []
}
function getProcessMetrics(pid: number): ProcessMetrics | undefined {
return metricsCache.get(pid)
}
function recordHistory(appName: string, cpu: number, memory: number, rss: number): void {
@ -102,27 +98,79 @@ function recordHistory(appName: string, cpu: number, memory: number, rss: number
appHistory.set(appName, history)
}
function getHistory(appName: string): HistorySample[] {
return appHistory.get(appName) ?? []
async function sampleProcessMetrics(): Promise<void> {
try {
const proc = Bun.spawn(['ps', '-eo', 'pid,pcpu,pmem,rss'], {
stdout: 'pipe',
stderr: 'ignore',
})
const text = await new Response(proc.stdout).text()
const lines = text.trim().split('\n').slice(1) // Skip header
metricsCache.clear()
for (const line of lines) {
const parts = line.trim().split(/\s+/)
if (parts.length >= 4) {
const pid = parseInt(parts[0]!, 10)
const cpu = parseFloat(parts[1]!)
const memory = parseFloat(parts[2]!)
const rss = parseInt(parts[3]!, 10) // KB
if (!isNaN(pid) && pid > 0) {
metricsCache.set(pid, { pid, cpu, memory, rss })
}
}
}
} catch (err) {
console.error('Failed to sample process metrics:', err)
}
}
function recordDataSize(appName: string): void {
const today = new Date().toISOString().slice(0, 10)
const history = dataHistory.get(appName) ?? []
const bytes = getDataSize(appName)
// Update today's entry or add new one
const existing = history.find(s => s.date === today)
if (existing) {
existing.bytes = bytes
} else {
history.push({ date: today, bytes })
}
// Keep only the last N days
while (history.length > DATA_HISTORY_MAX_DAYS) {
history.shift()
}
dataHistory.set(appName, history)
}
async function sampleAndRecordHistory(): Promise<void> {
await sampleProcessStats()
await sampleProcessMetrics()
// Record history for all running apps
try {
const res = await fetch(`${TOES_URL}/api/apps`)
if (!res.ok) return
const apps = await res.json() as App[]
// Record process history for running apps
for (const app of apps) {
if (app.pid && app.state === 'running') {
const stats = getProcessStats(app.pid)
if (stats) {
recordHistory(app.name, stats.cpu, stats.memory, stats.rss)
const metrics = getProcessMetrics(app.pid)
if (metrics) {
recordHistory(app.name, metrics.cpu, metrics.memory, metrics.rss)
}
}
}
// Record data size history for all apps (filesystem-based, not process-based)
for (const app of apps) {
recordDataSize(app.name)
}
} catch {
// Ignore errors
}
@ -132,6 +180,32 @@ async function sampleAndRecordHistory(): Promise<void> {
sampleAndRecordHistory()
setInterval(sampleAndRecordHistory, SAMPLE_INTERVAL_MS)
// ============================================================================
// Data Size
// ============================================================================
function getDataSize(appName: string): number {
let total = 0
const appDir = join(APPS_DIR, appName)
if (existsSync(appDir)) total += dirSize(appDir)
const dataDir = join(TOES_DIR, appName)
if (existsSync(dataDir)) total += dirSize(dataDir)
return total
}
function dirSize(dir: string): number {
let total = 0
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
total += dirSize(path)
} else {
total += statSync(path).size
}
}
return total
}
// ============================================================================
// API Client
// ============================================================================
@ -146,30 +220,32 @@ async function fetchApps(): Promise<App[]> {
}
}
async function getAppStats(): Promise<AppStats[]> {
async function getAppMetrics(): Promise<AppMetrics[]> {
const apps = await fetchApps()
return apps.map(app => {
const stats = app.pid ? getProcessStats(app.pid) : undefined
const metrics = app.pid ? getProcessMetrics(app.pid) : undefined
return {
...app,
cpu: stats?.cpu,
memory: stats?.memory,
rss: stats?.rss,
cpu: metrics?.cpu,
dataSize: getDataSize(app.name),
memory: metrics?.memory,
rss: metrics?.rss,
}
})
}
async function getAppStatsByName(name: string): Promise<AppStats | undefined> {
async function getAppMetricsByName(name: string): Promise<AppMetrics | undefined> {
const apps = await fetchApps()
const app = apps.find(a => a.name === name)
if (!app) return undefined
const stats = app.pid ? getProcessStats(app.pid) : undefined
const metrics = app.pid ? getProcessMetrics(app.pid) : undefined
return {
...app,
cpu: stats?.cpu,
memory: stats?.memory,
rss: stats?.rss,
cpu: metrics?.cpu,
dataSize: getDataSize(app.name),
memory: metrics?.memory,
rss: metrics?.rss,
}
}
@ -269,10 +345,45 @@ const EmptyState = define('EmptyState', {
color: theme('colors-textMuted'),
})
const Tab = define('Tab', {
base: 'a',
padding: '8px 16px',
fontSize: '13px',
fontFamily: theme('fonts-sans'),
color: theme('colors-textMuted'),
textDecoration: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
states: {
':hover': {
color: theme('colors-text'),
},
},
})
const TabActive = define('TabActive', {
base: 'a',
padding: '8px 16px',
fontSize: '13px',
fontFamily: theme('fonts-sans'),
color: theme('colors-text'),
textDecoration: 'none',
borderBottom: `2px solid ${theme('colors-primary')}`,
fontWeight: 'bold',
cursor: 'default',
})
const TabBar = define('TabBar', {
display: 'flex',
gap: '4px',
borderBottom: `1px solid ${theme('colors-border')}`,
marginBottom: '15px',
})
const ChartsContainer = define('ChartsContainer', {
marginTop: '24px',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '20px',
})
@ -297,6 +408,40 @@ const ChartWrapper = define('ChartWrapper', {
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', {
display: 'flex',
alignItems: 'center',
@ -310,6 +455,19 @@ const NoDataMessage = define('NoDataMessage', {
// Helpers
// ============================================================================
function formatBytes(bytes?: number): string {
if (bytes === undefined) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
function formatPercent(value?: number): string {
if (value === undefined) return '-'
return `${value.toFixed(1)}%`
}
function formatRss(kb?: number): string {
if (kb === undefined) return '-'
if (kb < 1024) return `${kb} KB`
@ -317,9 +475,12 @@ function formatRss(kb?: number): string {
return `${(kb / 1024 / 1024).toFixed(2)} GB`
}
function formatPercent(value?: number): string {
if (value === undefined) return '-'
return `${value.toFixed(1)}%`
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 {
@ -364,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
// ============================================================================
@ -377,18 +639,29 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
}))
// API endpoint for CLI
app.get('/api/stats', async c => {
const stats = await getAppStats()
return c.json(stats)
app.get('/api/metrics', async c => {
const metrics = await getAppMetrics()
return c.json(metrics)
})
app.get('/api/stats/:name', async c => {
app.get('/api/metrics/:name', async c => {
const name = c.req.param('name')
const stats = await getAppStatsByName(name)
if (!stats) {
const metrics = await getAppMetricsByName(name)
if (!metrics) {
return c.json({ error: 'App not found' }, 404)
}
return c.json(stats)
return c.json(metrics)
})
app.get('/api/data-history/:name', c => {
const name = c.req.param('name')
const history = getDataHistory(name)
return c.json(history)
})
app.get('/api/system', async c => {
const metrics = await fetchSystemMetrics()
return c.json(metrics)
})
app.get('/api/history/:name', c => {
@ -403,18 +676,98 @@ app.get('/', async c => {
// Single app view
if (appName) {
const stats = await getAppStatsByName(appName)
const tab = c.req.query('tab') === 'global' ? 'global' : 'app'
const appUrl = `/?app=${appName}`
const globalUrl = `/?app=${appName}&tab=global`
if (tab === 'global') {
const metrics = await getAppMetrics()
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">
<TabBar>
<Tab href={appUrl}>App</Tab>
<TabActive href={globalUrl}>Global</TabActive>
</TabBar>
<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)
if (!stats) {
return c.html(
<Layout title="Stats">
<Layout title="Metrics - Global">
<TabBar>
<Tab href={appUrl}>App</Tab>
<TabActive href={globalUrl}>Global</TabActive>
</TabBar>
<Table>
<thead>
<tr>
<Th>Name</Th>
<Th>State</Th>
<ThRight>PID</ThRight>
<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>
)
}
const metrics = await getAppMetricsByName(appName)
if (!metrics) {
return c.html(
<Layout title="Metrics">
<EmptyState>App not found: {appName}</EmptyState>
</Layout>
)
}
return c.html(
<Layout title="Stats">
<Layout title="Metrics">
<TabBar>
<TabActive href={appUrl}>App</TabActive>
<Tab href={globalUrl}>Global</Tab>
</TabBar>
<Table>
<thead>
<tr>
@ -423,19 +776,21 @@ app.get('/', async c => {
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr>
</thead>
<tbody>
<Tr>
<Td>
<StatusBadge style={`color: ${getStatusColor(stats.state)}`}>
{stats.state}
<StatusBadge style={`color: ${getStatusColor(metrics.state)}`}>
{metrics.state}
</StatusBadge>
</Td>
<TdRight>{stats.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(stats.cpu)}</TdRight>
<TdRight>{formatPercent(stats.memory)}</TdRight>
<TdRight>{formatRss(stats.rss)}</TdRight>
<TdRight>{metrics.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(metrics.cpu)}</TdRight>
<TdRight>{formatPercent(metrics.memory)}</TdRight>
<TdRight>{formatRss(metrics.rss)}</TdRight>
<TdRight>{formatBytes(metrics.dataSize)}</TdRight>
</Tr>
</tbody>
</Table>
@ -468,6 +823,15 @@ app.get('/', async c => {
</NoDataMessage>
</ChartWrapper>
</ChartCard>
<ChartCard>
<ChartTitle>Data Size</ChartTitle>
<ChartWrapper>
<canvas id="dataChart"></canvas>
<NoDataMessage id="dataNoData" style="display: none">
Collecting data...
</NoDataMessage>
</ChartWrapper>
</ChartCard>
</ChartsContainer>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
@ -478,6 +842,7 @@ app.get('/', async c => {
const textColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)';
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
const cpuColor = 'rgb(59, 130, 246)';
const dataColor = 'rgb(245, 158, 11)';
const memColor = 'rgb(168, 85, 247)';
const rssColor = 'rgb(34, 197, 94)';
@ -518,18 +883,76 @@ app.get('/', async c => {
}
};
let cpuChart, memChart, rssChart;
let cpuChart, dataChart, memChart, rssChart;
function formatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
function formatDate(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function formatRss(kb) {
if (kb < 1024) return kb + ' KB';
return (kb / 1024).toFixed(1) + ' MB';
}
function updateDataChart(history) {
if (history.length === 0) {
document.getElementById('dataChart').style.display = 'none';
document.getElementById('dataNoData').style.display = 'flex';
return;
}
document.getElementById('dataChart').style.display = 'block';
document.getElementById('dataNoData').style.display = 'none';
const labels = history.map(h => formatDate(h.date));
const data = history.map(h => h.bytes / (1024 * 1024)); // Convert to MB
if (!dataChart) {
dataChart = new Chart(document.getElementById('dataChart'), {
type: 'line',
data: {
labels: labels,
datasets: [{
data: data,
borderColor: dataColor,
backgroundColor: dataColor.replace('rgb', 'rgba').replace(')', ', 0.1)'),
fill: true
}]
},
options: {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
ticks: {
...commonOptions.scales.y.ticks,
callback: v => v.toFixed(1) + ' MB'
}
}
}
}
});
} else {
dataChart.data.labels = labels;
dataChart.data.datasets[0].data = data;
dataChart.update('none');
}
}
function updateCharts(history) {
const labels = history.map(h => formatTime(h.timestamp));
const cpuData = history.map(h => h.cpu);
@ -650,6 +1073,18 @@ app.get('/', async c => {
}
}
async function fetchDataHistory() {
try {
const res = await fetch('/api/data-history/' + encodeURIComponent(appName));
if (res.ok) {
const history = await res.json();
updateDataChart(history);
}
} catch (e) {
console.error('Failed to fetch data history:', e);
}
}
async function fetchHistory() {
try {
const res = await fetch('/api/history/' + encodeURIComponent(appName));
@ -664,73 +1099,28 @@ app.get('/', async c => {
// Initial fetch
fetchHistory();
fetchDataHistory();
// Update every 10 seconds
setInterval(fetchHistory, 10000);
setInterval(fetchDataHistory, 60000); // Data size changes slowly
})();
`}} />
</Layout>
)
}
// All apps view
const stats = await getAppStats()
// Sort: running first, then by name
stats.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 (stats.length === 0) {
return c.html(
<Layout title="Stats">
<EmptyState>No apps found</EmptyState>
</Layout>
)
}
const running = stats.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)
// Dashboard view: system metrics gauges
const sys = await fetchSystemMetrics()
return c.html(
<Layout title="Stats">
<Table>
<thead>
<tr>
<Th>Name</Th>
<Th>State</Th>
<ThRight>PID</ThRight>
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
</tr>
</thead>
<tbody>
{stats.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>
</Tr>
))}
</tbody>
</Table>
<Summary>
{running.length} running &middot; {formatPercent(totalCpu)} CPU &middot; {formatRss(totalRss)} total
</Summary>
<Layout title="Metrics">
<GaugesGrid>
{renderGauge(sys.cpu, 'cpu')}
{renderGauge(sys.ram.percent, 'ram')}
{renderGauge(sys.disk.percent, 'disk')}
</GaugesGrid>
<script dangerouslySetInnerHTML={{ __html: gaugeScript }} />
</Layout>
)
})

View File

@ -1,5 +1,5 @@
{
"name": "stats",
"name": "metrics",
"module": "index.tsx",
"type": "module",
"private": true,
@ -10,7 +10,8 @@
},
"toes": {
"tool": true,
"icon": "📊"
"icon": "📊",
"dashboard": true
},
"devDependencies": {
"@types/bun": "latest"

View File

@ -1,38 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "toes-app",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.2",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
"@types/bun": ["@types/bun@1.3.7", "https://npm.nose.space/@types/bun/-/bun-1.3.7.tgz", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
"@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.7", "https://npm.nose.space/bun-types/-/bun-types-1.3.7.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
"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,8 +0,0 @@
import { Hype } from '@because/hype'
const app = new Hype
app.get('/', c => c.html(<h1>My Profile!!!</h1>))
app.get('/ok', c => c.text('ok'))
export default app.defaults

View File

@ -1,24 +0,0 @@
{
"name": "profile",
"module": "src/index.ts",
"type": "module",
"private": true,
"toes": {
"icon": "👤"
},
"scripts": {
"toes": "bun run --watch index.tsx",
"start": "bun toes",
"dev": "bun run --hot index.tsx"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.2"
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1"
}
}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
registry=https://npm.nose.space

View File

@ -1 +0,0 @@
registry=https://npm.nose.space

View File

@ -1,38 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "toes-app",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.2",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
"@types/bun": ["@types/bun@1.3.7", "https://npm.nose.space/@types/bun/-/bun-1.3.7.tgz", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
"@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.7", "https://npm.nose.space/bun-types/-/bun-types-1.3.7.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
"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 +0,0 @@
registry=https://npm.nose.space

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

@ -1,30 +0,0 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@ -3,11 +3,13 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "toes",
"name": "@because/toes",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"commander": "^14.0.2",
"@because/sneaker": "^0.0.4",
"@because/toes": "^0.0.12",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5",
},
@ -16,7 +18,7 @@
"@types/diff": "^8.0.0",
},
"peerDependencies": {
"typescript": "^5.9.2",
"typescript": "^5.9.3",
},
},
},
@ -25,24 +27,30 @@
"@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=="],
"@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=="],
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@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=="],
"@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=="],
"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=="],
"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.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"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=="],
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"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=="],
"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=="],
}
}

45
color-preview.sh Executable file
View File

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

View File

@ -6,10 +6,8 @@ An app is an HTTP server that runs on its assigned port.
```
apps/<name>/
<timestamp>/ # YYYYMMDD-HHMMSS
package.json
index.tsx
current -> <timestamp> # symlink to active version
package.json
index.tsx
```
**package.json** must have `scripts.toes`:
@ -48,6 +46,7 @@ export default app.defaults
- `PORT` - your assigned port (3001-3100)
- `APPS_DIR` - path to `/apps` directory
- `DATA_DIR` - per-app data directory (`toes/<app-name>/`)
## health checks

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:
```ts
// apps/my-app/current/cron/daily-cleanup.ts
// apps/my-app/cron/daily-cleanup.ts
export const schedule = "day"
export default async function() {
@ -73,7 +73,7 @@ Jobs track:
## discovery
The cron tool:
1. Scans `APPS_DIR/*/current/cron/*.ts`
1. Scans `APPS_DIR/*/cron/*.ts`
2. Imports each file to read `schedule`
3. Validates the schedule
4. Registers with croner

View File

@ -39,3 +39,13 @@ DEBUG=true
```
Keys are uppercased automatically. Quotes around values are stripped.
## Built-in variables
These are set automatically by Toes — you don't need to configure them:
- `PORT` - assigned port (3001-3100)
- `APPS_DIR` - path to the `/apps` directory
- `DATA_DIR` - per-app data directory (`toes/<app-name>/`) for storing persistent data
- `TOES_URL` - base URL of the Toes server
- `TOES_DIR` - path to the toes config directory

813
docs/GUIDE.md Normal file
View File

@ -0,0 +1,813 @@
# Toes User Guide
Toes is a personal web appliance that runs multiple web apps on your home network. Plug it in, turn it on, and forget about the cloud.
## Table of Contents
- [Quick Start](#quick-start)
- [Creating an App](#creating-an-app)
- [App Templates](#app-templates)
- [App Structure](#app-structure)
- [The Bare Minimum](#the-bare-minimum)
- [Using Hype](#using-hype)
- [Using Forge](#using-forge)
- [Creating a Tool](#creating-a-tool)
- [What's a Tool?](#whats-a-tool)
- [Tool Setup](#tool-setup)
- [Theme Tokens](#theme-tokens)
- [Accessing App Data](#accessing-app-data)
- [CLI Reference](#cli-reference)
- [App Management](#app-management)
- [Lifecycle](#lifecycle)
- [Deploying Code](#deploying-code)
- [Environment Variables](#environment-variables)
- [Cron Jobs](#cron-jobs-1)
- [Metrics](#metrics)
- [Sharing](#sharing)
- [Environment Variables](#environment-variables-1)
- [Health Checks](#health-checks)
- [App Lifecycle](#app-lifecycle)
- [Cron Jobs](#cron-jobs)
- [Data Persistence](#data-persistence)
---
## Quick Start
```bash
# Install the CLI
curl -fsSL http://toes.local/install | bash
# Create a new app (scaffolds, inits git, and pushes to server)
toes new my-app
# Enter the directory, install deps, and develop locally
cd my-app
bun install
bun dev
# Deploy changes (standard git)
git add . && git commit -m "my changes"
git push toes main
# Open in browser
toes open
```
Your app is now running at `http://my-app.toes.local`.
`toes new` automatically sets up a `toes` git remote pointing at the server. Pushing to it triggers a deploy.
---
## Creating an App
### App Templates
Toes ships with three templates. Pick one when creating an app:
```bash
toes new my-app # SSR (default)
toes new my-app --bare # Minimal
toes new my-app --spa # Single-page app
```
**SSR** — Server-side rendered with a pages directory. Best for most apps. Uses Hype's built-in layout and page routing.
**Bare** — Just an `index.tsx` with a single route. Good when you want to start from scratch.
**SPA** — Client-side rendering with `hono/jsx/dom`. Hype serves the HTML shell and static files; the browser handles routing and rendering.
### App Structure
A generated SSR app looks like this:
```
my-app/
.gitignore # Files to exclude from sync and deploy
.npmrc # Points to the private registry
package.json # Must have scripts.toes
tsconfig.json # TypeScript config
index.tsx # Entry point (re-exports from src/server)
src/
server/
index.tsx # Hype app with routes
pages/
index.tsx # Page components
```
### The Bare Minimum
Every app needs three things:
1. **`package.json`** with a `scripts.toes` entry
2. **`index.tsx`** that exports `app.defaults`
3. **A `GET /ok` route** that returns 200 (health check)
**package.json:**
```json
{
"name": "my-app",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx"
},
"toes": {
"icon": "🎨"
},
"dependencies": {
"@because/hype": "*",
"@because/forge": "*"
}
}
```
The `scripts.toes` field is how Toes discovers your app. The `toes.icon` field sets the emoji shown in the dashboard.
**.npmrc:**
```
registry=https://npm.nose.space
```
Required for installing `@because/*` packages.
**index.tsx:**
```tsx
import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.html(<h1>Hello World</h1>))
app.get('/ok', c => c.text('ok'))
export default app.defaults
```
That's it. Push to the server and it runs.
### Using Hype
Hype wraps [Hono](https://hono.dev). Everything you know from Hono works here. Hype adds a few extras:
**Basic routing:**
```tsx
import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.html(<h1>Home</h1>))
app.get('/about', c => c.html(<h1>About</h1>))
app.post('/api/items', async c => {
const body = await c.req.json()
return c.json({ ok: true })
})
app.get('/ok', c => c.text('ok'))
export default app.defaults
```
**Sub-routers:**
```tsx
const api = Hype.router()
api.get('/items', c => c.json([]))
api.post('/items', async c => {
const body = await c.req.json()
return c.json({ ok: true })
})
app.route('/api', api) // mounts at /api/items
```
**Server-Sent Events:**
```tsx
app.sse('/stream', (send, c) => {
send({ hello: 'world' })
const interval = setInterval(() => send({ time: Date.now() }), 1000)
return () => clearInterval(interval) // cleanup on disconnect
})
```
**Constructor options:**
```tsx
const app = new Hype({
layout: true, // Wraps pages in an HTML layout (default: true)
prettyHTML: true, // Pretty-print HTML output (default: true)
logging: true, // Log requests to stdout (default: true)
})
```
### Using Forge
Forge is a CSS-in-JS library that creates styled JSX components. Define a component once, use it everywhere.
**Basic usage:**
```tsx
import { define, stylesToCSS } from '@because/forge'
const Box = define('Box', {
padding: 20,
borderRadius: '6px',
backgroundColor: '#f5f5f5',
})
// <Box>content</Box> renders <div class="Box">content</div>
```
Numbers auto-convert to `px` (except `flex`, `opacity`, `zIndex`, `fontWeight`).
**Set the HTML element:**
```tsx
const Button = define('Button', { base: 'button', padding: '8px 16px' })
const Link = define('Link', { base: 'a', textDecoration: 'none' })
const Input = define('Input', { base: 'input', padding: 8, border: '1px solid #ccc' })
```
**Pseudo-classes (`states`):**
```tsx
const Item = define('Item', {
padding: 12,
states: {
':hover': { backgroundColor: '#eee' },
':last-child': { borderBottom: 'none' },
},
})
```
**Nested selectors:**
```tsx
const List = define('List', {
selectors: {
'& > li:last-child': { borderBottom: 'none' },
},
})
```
**Variants:**
```tsx
const Button = define('Button', {
base: 'button',
padding: '8px 16px',
variants: {
variant: {
primary: { backgroundColor: '#2563eb', color: 'white' },
danger: { backgroundColor: '#dc2626', color: 'white' },
},
},
})
// <Button variant="primary">Save</Button>
```
**Serving CSS:**
Forge generates CSS at runtime. Serve it from a route:
```tsx
import { stylesToCSS } from '@because/forge'
app.get('/styles.css', c =>
c.text(stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
)
```
Then link it in your HTML:
```tsx
<link rel="stylesheet" href="/styles.css" />
```
---
## Creating a Tool
### What's a Tool?
A tool is an app that appears as a tab inside the Toes dashboard instead of in the sidebar. Tools render in an iframe and receive the currently selected app as a `?app=` query parameter. Good for things like a code editor, log viewer, env manager, or cron scheduler.
From the server's perspective, a tool is identical to an app — same lifecycle, same health checks, same port allocation. The only differences are in `package.json` and how you render.
### Tool Setup
A tool needs three extra things compared to a regular app:
1. Set `"tool": true` in `package.json`
2. Include `<ToolScript />` in the HTML body
3. Prepend `baseStyles` to CSS output
**package.json:**
```json
{
"name": "my-tool",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx"
},
"toes": {
"icon": "🔧",
"tool": true
},
"dependencies": {
"@because/forge": "*",
"@because/hype": "*",
"@because/toes": "*"
}
}
```
Set `"tool"` to `true` for a tab labeled with the app name, or to a string for a custom label (e.g., `"tool": ".env"`).
**index.tsx:**
```tsx
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import type { Child } from 'hono/jsx'
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'),
})
function Layout({ title, children }: { title: string; children: Child }) {
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' })
)
app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) {
return c.html(<Layout title="My Tool"><p>No app selected</p></Layout>)
}
return c.html(
<Layout title="My Tool">
<h2>{appName}</h2>
<p>Tool content for {appName}</p>
</Layout>
)
})
export default app.defaults
```
Key points:
- `<ToolScript />` handles dark/light mode syncing and iframe height communication with the dashboard.
- `baseStyles` sets the body background to match the dashboard theme.
- `prettyHTML: false` is recommended for tools since their output is inside an iframe.
- The `?app=` query parameter tells you which app the user has selected in the sidebar.
### Theme Tokens
Tools should use theme tokens to match the dashboard's look. Import `theme` from `@because/toes/tools`:
```tsx
import { theme } from '@because/toes/tools'
const Card = define('Card', {
color: theme('colors-text'),
backgroundColor: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
padding: theme('spacing-lg'),
})
```
Available tokens:
| Token | Description |
|-------|-------------|
| `colors-bg` | Page background |
| `colors-bgSubtle` | Subtle background |
| `colors-bgElement` | Element background (cards, inputs) |
| `colors-bgHover` | Hover background |
| `colors-text` | Primary text |
| `colors-textMuted` | Secondary text |
| `colors-textFaint` | Tertiary/disabled text |
| `colors-border` | Borders |
| `colors-link` | Link text |
| `colors-primary` | Primary action color |
| `colors-primaryText` | Text on primary color |
| `colors-error` | Error color |
| `colors-dangerBorder` | Danger state border |
| `colors-dangerText` | Danger text |
| `colors-success` | Success color |
| `colors-successBg` | Success background |
| `colors-statusRunning` | Running indicator |
| `colors-statusStopped` | Stopped indicator |
| `fonts-sans` | Sans-serif font stack |
| `fonts-mono` | Monospace font stack |
| `spacing-xs` | 4px |
| `spacing-sm` | 8px |
| `spacing-md` | 12px |
| `spacing-lg` | 16px |
| `spacing-xl` | 24px |
| `radius-md` | 6px |
### Accessing App Data
**Reading app files:**
```tsx
import { join } from 'path'
const APPS_DIR = process.env.APPS_DIR!
app.get('/', c => {
const appName = c.req.query('app')
if (!appName) return c.html(<p>No app selected</p>)
const appPath = join(APPS_DIR, appName)
// Read files from appPath...
})
```
**Calling the Toes API:**
```tsx
const TOES_URL = process.env.TOES_URL!
// List all apps
const apps = await fetch(`${TOES_URL}/api/apps`).then(r => r.json())
// Get a specific app
const app = await fetch(`${TOES_URL}/api/apps/${name}`).then(r => r.json())
```
**Linking between tools:**
```html
<a href="/tool/code?app=my-app&file=index.tsx">Edit in Code</a>
```
Tool URLs go through `/tool/:name` which redirects to the tool's subdomain with query params preserved.
**Listening to lifecycle events:**
```tsx
import { on } from '@because/toes/tools'
const unsub = on('app:start', event => {
console.log(`${event.app} started at ${event.time}`)
})
// Event types: 'app:start', 'app:stop', 'app:create', 'app:delete', 'app:activate'
```
---
## CLI Reference
The CLI connects to your Toes server over HTTP. By default it connects to `http://toes.local`. Set `TOES_URL` to point elsewhere, or set `DEV=1` to use `http://localhost:3000`.
Most commands accept an optional app name. If omitted, the CLI uses the current directory's `package.json` name.
### App Management
**`toes list`** — List all apps and their status.
```bash
toes list # Show apps and tools
toes list --apps # Apps only (exclude tools)
toes list --tools # Tools only
```
**`toes new [name]`** — Create a new app from a template.
```bash
toes new my-app # SSR template (default)
toes new my-app --bare # Minimal template
toes new my-app --spa # SPA template
```
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 get <name>`** — Clone an app from the server to your local machine.
```bash
toes get my-app # Clones into ./my-app/
cd my-app
bun install
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 rename [name] <new-name>`** — Rename an app. Requires typing a confirmation.
**`toes rm [name]`** — Permanently delete an app from the server. Requires typing a confirmation.
### Lifecycle
**`toes start [name]`** — Start a stopped app.
**`toes stop [name]`** — Stop a running app.
**`toes restart [name]`** — Stop and start an app.
**`toes logs [name]`** — View logs for an app.
```bash
toes logs my-app # Today's logs
toes logs my-app -f # Follow (tail) logs in real-time
toes logs my-app -d 2026-01-15 # Logs from a specific date
toes logs my-app -s 2d # Logs from the last 2 days
toes logs my-app -g error # Filter logs by pattern
toes logs my-app -f -g error # Follow and filter
```
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
### Deploying Code
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.
```bash
# Make changes, commit, and deploy
git add .
git commit -m "update homepage"
git push toes main
```
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
Use standard git commands for history, diffing, and rollback:
```bash
git log # View deploy history
git diff HEAD~1 # See what changed
git revert HEAD # Undo last deploy
git push toes main # Deploy the revert
```
To clone an existing app from the server:
```bash
git clone http://git.toes.local/my-app
cd my-app
bun install
bun dev # Develop locally
```
### Environment Variables
**`toes env [name]`** — List environment variables for an app.
```bash
toes env my-app # List app vars
toes env -g # List global vars
```
**`toes env set [name] <KEY> [value]`** — Set a variable.
```bash
toes env set my-app API_KEY sk-123 # Set for an app
toes env set my-app API_KEY=sk-123 # KEY=value format also works
toes env set -g API_KEY sk-123 # Set globally (shared by all apps)
```
Setting a variable automatically restarts the app.
**`toes env rm [name] <KEY>`** — Remove a variable.
```bash
toes env rm my-app API_KEY # Remove from an app
toes env rm -g API_KEY # Remove global var
```
### Versioning
Every `git push toes main` creates a new deploy. Version history is managed through git.
```bash
git log --oneline # List deploys
git revert HEAD # Undo last change
git push toes main # Deploy the revert
```
### Cron Jobs
Cron commands talk to the cron tool running on your Toes server.
**`toes cron [app]`** — List all cron jobs, or jobs for a specific app.
**`toes cron status <app:name>`** — Show details for a specific job.
```bash
toes cron status my-app:backup
# my-app:backup ok
#
# Schedule: day
# State: idle
# Last run: 2h ago
# Duration: 3s
# Exit code: 0
# Next run: in 22h
```
**`toes cron run <app:name>`** — Trigger a job immediately.
```bash
toes cron run my-app:backup
```
**`toes cron log [target]`** — View cron logs.
```bash
toes cron log # All cron logs
toes cron log my-app # Cron logs for an app
toes cron log my-app:backup # Logs for a specific job
toes cron log -f # Follow logs
```
### Metrics
**`toes metrics [name]`** — Show CPU, memory, and disk usage.
```bash
toes metrics # All apps
toes metrics my-app # Single app
```
### Sharing
**`toes share [name]`** — Create a public tunnel to share an app over the internet.
```bash
toes share my-app
# ↗ Sharing my-app... https://abc123.trycloudflare.com
```
**`toes unshare [name]`** — Stop sharing an app.
---
## Environment Variables
Toes injects these variables into every app process automatically:
| Variable | Description |
|----------|-------------|
| `PORT` | Assigned port (3001-3100). Your app must listen on this port. |
| `APPS_DIR` | Path to the apps directory on the server. |
| `DATA_DIR` | Per-app data directory for persistent storage. |
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local:3000`). |
| `TOES_DIR` | Path to the Toes config directory. |
You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals.
```bash
# Set per-app
toes env set my-app OPENAI_API_KEY sk-123
# Set globally (shared by all apps)
toes env set -g DATABASE_URL postgres://localhost/mydb
```
Access them in your app:
```tsx
const apiKey = process.env.OPENAI_API_KEY
```
---
## Health Checks
Toes checks `GET /ok` on every app every 30 seconds. Your app must return a 2xx response.
Three consecutive failures trigger an automatic restart with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s). After 5 restart failures, the app is marked as errored and restart is disabled.
The simplest health check:
```tsx
app.get('/ok', c => c.text('ok'))
```
---
## App Lifecycle
Apps move through these states:
```
invalid → stopped → starting → running → stopping → stopped
error
```
- **invalid** — Missing `package.json` or `scripts.toes`. Fix the config and start manually.
- **stopped** — Not running. Start with `toes start` or the dashboard.
- **starting** — Process spawned, waiting for `/ok` to return 200. Times out after 30 seconds.
- **running** — Healthy and serving requests.
- **stopping** — SIGTERM sent, waiting for process to exit. Escalates to SIGKILL after 10 seconds.
- **error** — Crashed too many times. Start manually to retry.
On startup, `bun install` runs automatically before the app's `scripts.toes` command.
Apps are accessed via subdomain: `http://my-app.toes.local` or `http://my-app.localhost`. The Toes server proxies requests to the app's assigned port.
---
## Cron Jobs
Place TypeScript files in a `cron/` directory inside your app:
```ts
// cron/daily-cleanup.ts
export const schedule = "day"
export default async function() {
console.log("Running daily cleanup")
// Your job logic here
}
```
The cron tool auto-discovers jobs by scanning `cron/*.ts` in all apps. New jobs are picked up within 60 seconds.
### Schedules
| Value | When |
|-------|------|
| `1 minute` | Every minute |
| `5 minutes` | Every 5 minutes |
| `15 minutes` | Every 15 minutes |
| `30 minutes` | Every 30 minutes |
| `hour` | Top of every hour |
| `noon` | 12:00 daily |
| `midnight` / `day` | 00:00 daily |
| `week` / `sunday` | 00:00 Sunday |
| `monday` - `saturday` | 00:00 on that day |
Jobs inherit the app's working directory and all environment variables.
---
## Data Persistence
Use the filesystem for data storage. The `DATA_DIR` environment variable points to a per-app directory that persists across deployments and restarts:
```tsx
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
const DATA_DIR = process.env.DATA_DIR!
function loadData(): MyData {
const path = join(DATA_DIR, 'data.json')
if (!existsSync(path)) return { items: [] }
return JSON.parse(readFileSync(path, 'utf-8'))
}
function saveData(data: MyData) {
writeFileSync(join(DATA_DIR, 'data.json'), JSON.stringify(data, null, 2))
}
```
`DATA_DIR` is separate from your app's code directory, so pushes and rollbacks won't affect stored data.

149
docs/TAILSCALE.md Normal file
View File

@ -0,0 +1,149 @@
# Tailscale
Connect your Toes appliance to your Tailscale network for secure access from anywhere.
Tailscale is pre-installed on the appliance but not configured. The user authenticates through the dashboard or CLI — no SSH required.
## how it works
1. User clicks "Connect to Tailscale" in the dashboard (or runs `toes tailscale connect`)
2. Toes runs `tailscale login` and captures the auth URL
3. Dashboard shows the URL and a QR code
4. User visits the URL and authenticates with Tailscale
5. Toes detects the connection, runs `tailscale serve --bg 80`
6. Appliance is now accessible at `https://<hostname>.<tailnet>.ts.net`
## dashboard
Settings area shows one of three states:
**Not connected:**
- "Connect to Tailscale" button
**Connecting:**
- Auth URL as a clickable link
- QR code for mobile
- Polls `tailscale status` until authenticated
**Connected:**
- Tailnet URL (clickable)
- Tailnet name
- Device hostname
- `tailscale serve` toggle
- "Disconnect" button
## cli
```bash
toes tailscale # show status
toes tailscale connect # start auth flow, print URL, wait
toes tailscale disconnect # log out of tailnet
toes tailscale serve # toggle tailscale serve on/off
```
### `toes tailscale`
```
Tailscale: connected
Tailnet: user@github
Hostname: toes.tail1234.ts.net
IP: 100.64.0.1
Serve: on (port 80)
```
Or when not connected:
```
Tailscale: not connected
Run `toes tailscale connect` to get started.
```
### `toes tailscale connect`
```
Visit this URL to authenticate:
https://login.tailscale.com/a/abc123
Waiting for authentication... done!
Connected to tailnet user@github
https://toes.tail1234.ts.net
```
## server api
All endpoints shell out to the `tailscale` CLI and parse output.
### `GET /api/tailscale`
Returns current status.
```json
{
"installed": true,
"connected": true,
"hostname": "toes",
"tailnetName": "user@github",
"url": "https://toes.tail1234.ts.net",
"ip": "100.64.0.1",
"serving": true
}
```
When not connected:
```json
{
"installed": true,
"connected": false
}
```
When tailscale isn't installed:
```json
{
"installed": false
}
```
### `POST /api/tailscale/connect`
Runs `tailscale login`. Returns the auth URL.
```json
{
"authUrl": "https://login.tailscale.com/a/abc123"
}
```
### `POST /api/tailscale/disconnect`
Runs `tailscale logout`.
### `POST /api/tailscale/serve`
Toggles `tailscale serve`. Body:
```json
{ "enabled": true }
```
## install
`scripts/install.sh` installs tailscale and enables the daemon, but does not authenticate:
```bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo systemctl enable tailscaled
```
## permissions
The `toes` user needs passwordless sudo for tailscale commands. Add to sudoers during install:
```
toes ALL=(ALL) NOPASSWD: /usr/bin/tailscale
```
This lets the server run `sudo tailscale login`, `sudo tailscale serve`, etc. without a password prompt.

View File

@ -34,23 +34,27 @@ app.get('/', c => {
})
```
## environment
- `PORT` - your assigned port
- `APPS_DIR` - path to `/apps` directory
- `DATA_DIR` - per-app data directory (`toes/<tool-name>/`)
- `TOES_URL` - base URL of the Toes server
- `TOES_DIR` - path to the toes config directory
## accessing app files
Always go through the `current` symlink:
```ts
const APPS_DIR = process.env.APPS_DIR ?? '.'
const appPath = join(APPS_DIR, appName, 'current')
const APPS_DIR = process.env.APPS_DIR!
const appPath = join(APPS_DIR, appName)
```
Not `APPS_DIR/appName` directly.
## linking to tools
Use `/tool/:name` URLs to link directly to tools with params:
```html
<a href="/tool/code?app=my-app&version=20260130-000000">
<a href="/tool/code?app=my-app">
View in Code
</a>
```

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=="],
}
}

137
install/install.sh Normal file
View File

@ -0,0 +1,137 @@
#!/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.
REPO="https://git.nose.space/defunkt/toes"
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"
# ── Clone or pull ────────────────────────────────────────
if [ -d "$DEST/.git" ]; then
info "Pulling latest toes"
git -C "$DEST" fetch origin main
git -C "$DEST" reset --hard origin/main
else
info "Cloning toes"
git clone "$REPO" "$DEST"
fi
# ── Directories ──────────────────────────────────────────
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
# ── Dependencies & build ─────────────────────────────────
cd "$DEST"
info "Installing dependencies"
quiet bun install
info "Building"
rm -rf "$DEST/dist"
quiet bun run build
# ── Bundled apps ─────────────────────────────────────────
REPOS_DIR="$DATA_DIR/repos"
mkdir -p "$REPOS_DIR"
info "Installing bundled apps"
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"
# Seed bare repo for git-based versioning
bare="$REPOS_DIR/$app.git"
quiet git -C "$APPS_DIR/$app" init -b main
quiet git -C "$APPS_DIR/$app" add -A
quiet git -C "$APPS_DIR/$app" -c user.name=toes -c user.email=toes@localhost commit -m "install"
if [ -d "$bare" ]; then
quiet git -C "$APPS_DIR/$app" push --force "$bare" main
else
quiet git clone --bare "$APPS_DIR/$app" "$bare"
quiet git -C "$bare" config http.receivepack true
fi
rm -rf "$APPS_DIR/$app/.git"
done
# ── 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=$(git describe --tags --always 2>/dev/null || echo "unknown")
echo ""
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://$(hostname).local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"
echo ""

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

17
install/server.ts Normal file
View File

@ -0,0 +1,17 @@
import { resolve } from "path"
const script = await Bun.file(resolve(import.meta.dir, "install.sh")).text()
Bun.serve({
port: parseInt(process.env.PORT || "3000"),
fetch(req) {
if (new URL(req.url).pathname === "/install") {
return new Response(script, {
headers: { "content-type": "text/plain" },
})
}
return new Response("404 Not Found", { status: 404 })
},
})
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)

View File

@ -1,30 +1,43 @@
{
"exclude": ["apps", "templates"],
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"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
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": [
"./src/server/*"
],
"@*": [
"./src/shared/*"
],
"%*": [
"./src/lib/*"
]
}
}
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@because/toes",
"version": "0.0.5",
"version": "0.0.12",
"description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts",
"type": "module",
@ -15,7 +15,7 @@
"toes": "src/cli/index.ts"
},
"scripts": {
"check": "bunx tsc --noEmit",
"check": "bun run templates && bunx tsc --noEmit",
"build": "./scripts/build.sh",
"cli:build": "bun run scripts/build.ts",
"cli:build:all": "bun run scripts/build.ts --all",
@ -23,12 +23,17 @@
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes",
"cli:uninstall": "sudo rm /usr/local/bin",
"deploy": "./scripts/deploy.sh",
"dev": "rm pub/client/index.js && bun run --hot src/server/index.tsx",
"debug": "DEBUG=1 bun run dev",
"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:install": "./scripts/remote-install.sh",
"remote:logs": "./scripts/remote-logs.sh",
"remote:restart": "./scripts/remote-restart.sh",
"remote:start": "./scripts/remote-start.sh",
"remote:stop": "./scripts/remote-stop.sh",
"start": "bun run src/server/index.tsx",
"start": "bun run templates && bun run src/server/index.tsx",
"templates": "bun run scripts/embed-templates.ts",
"test": "bun test"
},
"devDependencies": {
@ -36,12 +41,14 @@
"@types/diff": "^8.0.0"
},
"peerDependencies": {
"typescript": "^5.9.2"
"typescript": "^5.9.3"
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"commander": "^14.0.2",
"@because/sneaker": "^0.0.4",
"@because/toes": "^0.0.12",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5"
}

View File

@ -9,6 +9,7 @@ import { join } from 'path'
const DIST_DIR = join(import.meta.dir, '..', 'dist')
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 {
arch: string
@ -23,52 +24,6 @@ const TARGETS: BuildTarget[] = [
{ 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',
'--minify',
'--sourcemap=external',
'--outfile',
output,
], {
stdout: 'inherit',
stderr: 'inherit',
env: {
...process.env,
BUN_TARGET_OS: target.os,
BUN_TARGET_ARCH: target.arch,
},
})
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() {
const platform = process.platform
const arch = process.arch
@ -86,6 +41,7 @@ async function buildCurrent() {
'bun',
'--minify',
'--sourcemap=external',
`--define=__GIT_SHA__="${GIT_SHA}"`,
'--outfile',
output,
], {
@ -105,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
if (buildAll) {
console.log('Building for all targets...\n')

View File

@ -2,7 +2,10 @@
# It isn't enough to modify this yet.
# You also need to manually update the toes.service file.
HOST="${HOST:-toes@toes.local}"
URL="${URL:-http://toes.local}"
DEST="${DEST:-~/toes}"
APPS_DIR="${APPS_DIR:-~/apps}"
TOES_USER="${TOES_USER:-toes}"
HOST="${HOST:-toes.local}"
SSH_HOST="$TOES_USER@$HOST"
URL="${URL:-http://$HOST}"
DEST="${DEST:-$HOME/toes}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"

View File

@ -8,16 +8,63 @@ ROOT_DIR="$SCRIPT_DIR/.."
# Load config
source "$ROOT_DIR/scripts/config.sh"
# Make sure we're up-to-date
if [ -n "$(git status --porcelain)" ]; then
echo "=> You have unsaved (git) changes"
exit 1
fi
git push origin main
# SSH to target and update
ssh "$HOST" "cd $DEST && git pull origin main && bun run build && sudo systemctl restart toes.service"
# SSH to target: pull, build, sync apps, restart
ssh "$SSH_HOST" bash <<'SCRIPT'
set -e
echo "=> Deployed to $HOST"
echo "=> Visit $URL"
DEST="${DEST:-$HOME/toes}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
REPOS_DIR="$DATA_DIR/repos"
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")
[ -f "$app_dir/package.json" ] || continue
target="$APPS_DIR/$app"
mkdir -p "$target"
cp -a "$app_dir"/. "$target"/
echo " $app"
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
done
echo "=> Initializing bare repos..."
mkdir -p "$REPOS_DIR"
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
bare="$REPOS_DIR/$app.git"
if [ ! -d "$bare" ]; then
git init --bare -b main "$bare" > /dev/null
git -C "$bare" config http.receivepack true
fi
tmp=$(mktemp -d)
cp -a "$app_dir"/. "$tmp"/
git -C "$tmp" init -b main > /dev/null 2>&1
git -C "$tmp" add -A > /dev/null
git -C "$tmp" -c user.name=toes -c user.email=toes@localhost commit -m "deploy" > /dev/null 2>&1
git -C "$tmp" push --force "$bare" main > /dev/null 2>&1
rm -rf "$tmp"
echo " $app"
done
sudo systemctl restart toes.service
SCRIPT
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' r=$'\033[0m'
echo ""
echo " ${b}${g}🐾 Deployed${r} to ${b}$SSH_HOST${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}$URL${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL $URL/install | bash${r}"
echo ""

View File

@ -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,123 +1,9 @@
#!/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
quiet() { "$@" > /dev/null 2>&1; }
SYSTEMD_DIR="/etc/systemd/system"
SERVICE_NAME="toes"
SERVICE_FILE="$(dirname "$0")/${SERVICE_NAME}.service"
SYSTEMD_PATH="${SYSTEMD_DIR}/${SERVICE_NAME}.service"
BUN_SYMLINK="/usr/local/bin/bun"
BUN_REAL="$HOME/.bun/bin/bun"
echo ">> Updating system libraries"
quiet sudo apt-get update
quiet sudo apt-get install -y libcap2-bin
quiet sudo apt-get install -y avahi-utils
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 apps directory"
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..."
# Copy app to ~/apps
cp -r "apps/$app" ~/apps/
# Find the version directory and create current symlink
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
# Install dependencies
(cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1
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..."
quiet systemctl status "$SERVICE_NAME" --no-pager -l || true
sleep 5
quiet sudo nohup reboot >/dev/null 2>&1 &
exit 0
exec "$(dirname "$0")/../install/install.sh"

View File

@ -9,4 +9,13 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
# Run remote install on the target
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
ssh "$SSH_HOST" bash <<'SCRIPT'
set -e
DEST="${DEST:-$HOME/toes}"
if [ -d "$DEST/.git" ]; then
cd "$DEST" && git pull
else
git clone https://git.nose.space/defunkt/toes "$DEST" && cd "$DEST"
fi
./scripts/install.sh
SCRIPT

9
scripts/remote-logs.sh Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
ssh "$SSH_HOST" "journalctl -u toes -n 100"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
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"
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"
ssh "$HOST" "sudo systemctl stop toes.service"
ssh "$SSH_HOST" "sudo systemctl stop toes.service"

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

@ -0,0 +1,65 @@
#!/bin/bash
#
# setup-ssh.sh - Configure SSH for the toes CLI user
#
# This script:
# 1. Creates a `cli` system user with /usr/local/bin/toes as shell
# 2. Sets an empty password on `cli` for passwordless SSH
# 3. Adds a Match block in sshd_config to allow empty passwords for `cli`
# 4. Adds /usr/local/bin/toes to /etc/shells
# 5. Restarts sshd
#
# Run as root on the toes machine.
# Usage: ssh cli@toes.local
set -euo pipefail
TOES_SHELL="/usr/local/bin/toes"
SSHD_CONFIG="/etc/ssh/sshd_config"
echo "==> Setting up SSH CLI user for toes"
# 1. Create cli system user
if ! id cli &>/dev/null; then
useradd --system --home-dir /home/cli --shell "$TOES_SHELL" --create-home cli
echo " Created cli user"
else
echo " cli user already exists"
fi
# 2. Set empty password
passwd -d cli
echo " Set empty password on cli"
# 3. Add Match block for cli user in sshd_config
if ! grep -q 'Match User cli' "$SSHD_CONFIG"; then
cat >> "$SSHD_CONFIG" <<EOF
# toes CLI: allow passwordless SSH for the cli user
Match User cli
PermitEmptyPasswords yes
EOF
echo " Added Match User cli block to sshd_config"
else
echo " sshd_config already has Match User cli block"
fi
# 4. Ensure /usr/local/bin/toes is in /etc/shells
if ! grep -q "^${TOES_SHELL}$" /etc/shells; then
echo "$TOES_SHELL" >> /etc/shells
echo " Added $TOES_SHELL to /etc/shells"
else
echo " $TOES_SHELL already in /etc/shells"
fi
# Warn if toes binary doesn't exist yet
if [ ! -f "$TOES_SHELL" ]; then
echo " WARNING: $TOES_SHELL does not exist yet"
echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL"
fi
# 5. Restart sshd
echo " Restarting sshd..."
systemctl restart sshd || service ssh restart || true
echo "==> Done. Connect with: ssh cli@toes.local"

View File

@ -8,6 +8,7 @@ User=toes
WorkingDirectory=/home/toes/toes/
Environment=PORT=80
Environment=NODE_ENV=production
Environment=DATA_DIR=/home/toes/data
Environment=APPS_DIR=/home/toes/apps/
ExecStart=/home/toes/.bun/bin/bun start
Restart=always

227
src/cli/commands/cron.ts Normal file
View File

@ -0,0 +1,227 @@
import type { LogLine } from '@types'
import color from 'kleur'
import { get, getSignal, handleError, makeUrl, post } from '../http'
import { resolveAppName } from '../name'
interface CronJobSummary {
app: string
name: string
schedule: string
state: string
status: string
lastRun?: number
lastDuration?: number
lastExitCode?: number
nextRun?: number
}
interface CronJobDetail extends CronJobSummary {
lastError?: string
lastOutput?: string
}
function formatRelative(ts?: number): string {
if (!ts) return '-'
const diff = Date.now() - ts
if (diff < 0) {
const mins = Math.round(-diff / 60000)
if (mins < 60) return `in ${mins}m`
const hours = Math.round(mins / 60)
if (hours < 24) return `in ${hours}h`
return `in ${Math.round(hours / 24)}d`
}
const mins = Math.round(diff / 60000)
if (mins < 60) return `${mins}m ago`
const hours = Math.round(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.round(hours / 24)}d ago`
}
function formatDuration(ms?: number): string {
if (!ms) return '-'
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${Math.round(ms / 1000)}s`
return `${Math.round(ms / 60000)}m`
}
function pad(str: string, len: number, right = false): string {
if (right) return str.padStart(len)
return str.padEnd(len)
}
function statusColor(status: string): (s: string) => string {
if (status === 'running') return color.green
if (status === 'ok') return color.green
if (status === 'idle') return color.gray
return color.red
}
function parseJobArg(arg: string): { app: string; name: string } | undefined {
const parts = arg.split(':')
if (parts.length !== 2 || !parts[0] || !parts[1]) {
console.error(`Invalid job format: ${arg}`)
console.error('Use app:name format (e.g., myapp:backup)')
return undefined
}
return { app: parts[0]!, name: parts[1]! }
}
export async function cronList(app?: string) {
const appName = app ? resolveAppName(app) : undefined
if (app && !appName) return
const url = appName
? `/api/tools/cron/api/jobs?app=${appName}`
: '/api/tools/cron/api/jobs'
const jobs = await get<CronJobSummary[]>(url)
if (!jobs || jobs.length === 0) {
console.log('No cron jobs found')
return
}
const jobWidth = Math.max(3, ...jobs.map(j => `${j.app}:${j.name}`.length))
const schedWidth = Math.max(8, ...jobs.map(j => String(j.schedule).length))
const statusWidth = Math.max(6, ...jobs.map(j => j.status.length))
console.log(
color.gray(
`${pad('JOB', jobWidth)} ${pad('SCHEDULE', schedWidth)} ${pad('STATUS', statusWidth)} ${pad('LAST RUN', 10)} ${pad('NEXT RUN', 10)}`
)
)
for (const j of jobs) {
const id = `${j.app}:${j.name}`
const colorFn = statusColor(j.status)
console.log(
`${pad(id, jobWidth)} ${pad(String(j.schedule), schedWidth)} ${colorFn(pad(j.status, statusWidth))} ${pad(formatRelative(j.lastRun), 10)} ${pad(formatRelative(j.nextRun), 10)}`
)
}
}
export async function cronStatus(arg: string) {
const parsed = parseJobArg(arg)
if (!parsed) return
const job = await get<CronJobDetail>(`/api/tools/cron/api/jobs/${parsed.app}/${parsed.name}`)
if (!job) return
const colorFn = statusColor(job.status)
console.log(`${color.bold(`${job.app}:${job.name}`)} ${colorFn(job.status)}`)
console.log()
console.log(` Schedule: ${job.schedule}`)
console.log(` State: ${job.state}`)
console.log(` Last run: ${formatRelative(job.lastRun)}`)
console.log(` Duration: ${formatDuration(job.lastDuration)}`)
if (job.lastExitCode !== undefined) {
console.log(` Exit code: ${job.lastExitCode === 0 ? color.green('0') : color.red(String(job.lastExitCode))}`)
}
console.log(` Next run: ${formatRelative(job.nextRun)}`)
if (job.lastError) {
console.log()
console.log(color.red('Error:'))
console.log(job.lastError)
}
if (job.lastOutput) {
console.log()
console.log(color.gray('Output:'))
console.log(job.lastOutput)
}
}
export async function cronLog(arg?: string, options?: { follow?: boolean }) {
// No arg: show the cron tool's own logs
// "myapp": show myapp's logs filtered to [cron entries
// "myapp:backup": show myapp's logs filtered to [cron:backup]
const follow = options?.follow ?? false
if (!arg) {
// Show cron tool's own logs
if (follow) {
await tailCronLogs('cron')
return
}
const logs = await get<LogLine[]>('/api/apps/cron/logs')
if (!logs || logs.length === 0) {
console.log('No cron logs yet')
return
}
for (const line of logs) printCronLog(line)
return
}
// Parse arg — could be "myapp" or "myapp:backup"
const colon = arg.indexOf(':')
const appName = colon >= 0 ? arg.slice(0, colon) : arg
const jobName = colon >= 0 ? arg.slice(colon + 1) : undefined
const grepPrefix = jobName ? `[cron:${jobName}]` : '[cron'
const resolved = resolveAppName(appName)
if (!resolved) return
if (follow) {
await tailCronLogs(resolved, grepPrefix)
return
}
const logs = await get<LogLine[]>(`/api/apps/${resolved}/logs`)
if (!logs || logs.length === 0) {
console.log('No cron logs yet')
return
}
for (const line of logs) {
if (line.text.includes(grepPrefix)) printCronLog(line)
}
}
export async function cronRun(arg: string) {
const parsed = parseJobArg(arg)
if (!parsed) return
const result = await post<{ ok: boolean; message: string; error?: string }>(
`/api/tools/cron/api/jobs/${parsed.app}/${parsed.name}/run`
)
if (!result) return
console.log(color.green(result.message))
}
const printCronLog = (line: LogLine) =>
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
async function tailCronLogs(app: string, grep?: string) {
try {
const url = makeUrl(`/api/apps/${app}/logs/stream`)
const res = await fetch(url, { signal: getSignal() })
if (!res.ok) {
console.error(`App not found: ${app}`)
return
}
if (!res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6)) as LogLine
if (!grep || data.text.includes(grep)) printCronLog(data)
}
}
}
} catch (error) {
handleError(error)
}
}

View File

@ -7,11 +7,62 @@ interface EnvVar {
value: string
}
export async function envList(name: string | undefined) {
function parseKeyValue(keyOrKeyValue: string, valueArg?: string): { key: string, value: string } | null {
if (valueArg !== undefined) {
const key = keyOrKeyValue.trim()
if (!key) { console.error('Key cannot be empty'); return null }
return { key, value: valueArg }
}
const eqIndex = keyOrKeyValue.indexOf('=')
if (eqIndex === -1) {
console.error('Invalid format. Use: KEY value or KEY=value')
return null
}
const key = keyOrKeyValue.slice(0, eqIndex).trim()
if (!key) { console.error('Key cannot be empty'); return null }
return { key, value: keyOrKeyValue.slice(eqIndex + 1) }
}
async function globalEnvSet(keyOrKeyValue: string, valueArg?: string) {
const parsed = parseKeyValue(keyOrKeyValue, valueArg)
if (!parsed) return
const { key, value } = parsed
try {
const result = await post<{ ok: boolean, error?: string }>('/api/env', { key, value })
if (result?.ok) {
console.log(color.green(`Set global ${color.bold(key.toUpperCase())}`))
} else {
console.error(result?.error ?? 'Failed to set variable')
}
} catch (error) {
handleError(error)
}
}
export async function envList(name: string | undefined, opts: { global?: boolean }) {
if (opts.global) {
const vars = await get<EnvVar[]>('/api/env')
console.log(color.bold().cyan('Global Environment Variables'))
console.log()
if (!vars || vars.length === 0) {
console.log(color.gray(' No global environment variables set'))
return
}
for (const v of vars) {
console.log(` ${color.bold(v.key)}=${color.gray(v.value)}`)
}
return
}
const appName = resolveAppName(name)
if (!appName) return
const vars = await get<EnvVar[]>(`/api/apps/${appName}/env`)
const [vars, globalVars] = await Promise.all([
get<EnvVar[]>(`/api/apps/${appName}/env`),
get<EnvVar[]>('/api/env'),
])
if (!vars) {
console.error(`App not found: ${appName}`)
return
@ -20,7 +71,9 @@ export async function envList(name: string | undefined) {
console.log(color.bold().cyan(`Environment Variables for ${appName}`))
console.log()
if (vars.length === 0) {
const appKeys = new Set(vars.map(v => v.key))
if (vars.length === 0 && (!globalVars || globalVars.length === 0)) {
console.log(color.gray(' No environment variables set'))
return
}
@ -28,31 +81,30 @@ export async function envList(name: string | undefined) {
for (const v of vars) {
console.log(` ${color.bold(v.key)}=${color.gray(v.value)}`)
}
if (globalVars && globalVars.length > 0) {
const inherited = globalVars.filter(v => !appKeys.has(v.key))
if (inherited.length > 0) {
if (vars.length > 0) console.log()
console.log(color.gray(' Inherited from global:'))
for (const v of inherited) {
console.log(` ${color.bold(v.key)}=${color.gray(v.value)}`)
}
}
}
}
export async function envSet(name: string | undefined, keyOrKeyValue: string, valueArg?: string) {
let key: string
let value: string
if (valueArg !== undefined) {
// KEY value format
key = keyOrKeyValue.trim()
value = valueArg
} else {
// KEY=value format
const eqIndex = keyOrKeyValue.indexOf('=')
if (eqIndex === -1) {
console.error('Invalid format. Use: KEY value or KEY=value')
return
}
key = keyOrKeyValue.slice(0, eqIndex).trim()
value = keyOrKeyValue.slice(eqIndex + 1)
export async function envSet(name: string | undefined, keyOrKeyValue: string, valueArg: string | undefined, opts: { global?: boolean }) {
// With --global, args shift: name becomes key, key becomes value
if (opts.global) {
const actualKey = name ?? keyOrKeyValue
const actualValue = name ? keyOrKeyValue : valueArg
return globalEnvSet(actualKey, actualValue)
}
if (!key) {
console.error('Key cannot be empty')
return
}
const parsed = parseKeyValue(keyOrKeyValue, valueArg)
if (!parsed) return
const { key, value } = parsed
const appName = resolveAppName(name)
if (!appName) return
@ -70,7 +122,21 @@ export async function envSet(name: string | undefined, keyOrKeyValue: string, va
}
}
export async function envRm(name: string | undefined, key: string) {
export async function envRm(name: string | undefined, key: string, opts: { global?: boolean }) {
// With --global, args shift: name becomes key
if (opts.global) {
const actualKey = name ?? key
if (!actualKey) {
console.error('Key is required')
return
}
const ok = await del(`/api/env/${actualKey.toUpperCase()}`)
if (ok) {
console.log(color.green(`Removed global ${color.bold(actualKey.toUpperCase())}`))
}
return
}
if (!key) {
console.error('Key is required')
return

View File

@ -1,7 +1,8 @@
export { cronList, cronLog, cronRun, cronStatus } from './cron'
export { envList, envRm, envSet } from './env'
export { logApp } from './logs'
export {
configShow,
getApp,
infoApp,
listApps,
newApp,
@ -9,8 +10,9 @@ export {
renameApp,
restartApp,
rmApp,
shareApp,
startApp,
stopApp,
unshareApp,
} from './manage'
export { statsApp } from './stats'
export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
export { metricsApp } from './metrics'

View File

@ -1,5 +1,5 @@
import type { LogLine } from '@types'
import { get, handleError, makeUrl } from '../http'
import { get, getSignal, handleError, makeUrl } from '../http'
import { resolveAppName } from '../name'
interface LogOptions {
@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) {
export async function tailLogs(name: string, grep?: string) {
try {
const url = makeUrl(`/api/apps/${name}/logs/stream`)
const res = await fetch(url)
const res = await fetch(url, { signal: getSignal() })
if (!res.ok) {
console.error(`App not found: ${name}`)
return

View File

@ -1,32 +1,37 @@
import type { App } from '@types'
import { generateTemplates, type TemplateType } from '%templates'
import color from 'kleur'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { basename, join } from 'path'
import { del, get, getManifest, HOST, makeAppUrl, post } from '../http'
import { buildAppUrl } from '@urls'
import { del, get, getManifest, gitUrl, HOST, post } from '../http'
import { confirm, prompt } from '../prompts'
import { resolveAppName } from '../name'
import { pushApp } from './sync'
export const STATE_ICONS: Record<string, string> = {
error: color.red('●'),
running: color.green('●'),
starting: color.yellow('◎'),
stopped: color.gray('◯'),
invalid: color.red('◌'),
}
export async function configShow() {
console.log(`Host: ${color.bold(HOST)}`)
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const source = process.env.TOES_URL ? 'TOES_URL' : process.env.DEV ? 'DEV' : '(default)'
console.log(`Source: ${color.gray(source)}`)
if (process.env.TOES_URL) {
console.log(` TOES_URL=${process.env.TOES_URL}`)
}
if (process.env.DEV) {
console.log(` DEV=${process.env.DEV}`)
async function waitForState(name: string, target: string, timeout: number): Promise<string | undefined> {
const start = Date.now()
while (Date.now() - start < timeout) {
await sleep(500)
const app: App | undefined = await get(`/api/apps/${name}`)
if (!app) return undefined
if (app.state === target) return target
// Terminal failure states — stop polling
if (target === 'running' && (app.state === 'stopped' || app.state === 'invalid' || app.state === 'error')) return app.state
if (target === 'stopped' && (app.state === 'invalid' || app.state === 'error')) return app.state
}
// Timed out — return last known state
const app: App | undefined = await get(`/api/apps/${name}`)
return app?.state
}
export async function infoApp(arg?: string) {
@ -42,9 +47,14 @@ export async function infoApp(arg?: string) {
const icon = STATE_ICONS[app.state] ?? '◯'
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
console.log(` State: ${app.state}`)
if (app.state === 'running') {
console.log(` URL: ${buildAppUrl(app.name, HOST)}`)
}
if (app.port) {
console.log(` Port: ${app.port}`)
console.log(` URL: ${makeAppUrl(app.port)}`)
}
if (app.tunnelUrl) {
console.log(` Tunnel: ${app.tunnelUrl}`)
}
if (app.pid) {
console.log(` PID: ${app.pid}`)
@ -121,12 +131,26 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
if (options.bare) template = 'bare'
else if (options.spa) template = 'spa'
const pkgPath = join(appPath, 'package.json')
// If package.json exists, ensure it has scripts.toes and bail
if (existsSync(pkgPath)) {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
if (!pkg.scripts?.toes) {
pkg.scripts = pkg.scripts ?? {}
pkg.scripts.toes = 'bun start'
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
console.log(color.green('✓ Added scripts.toes to package.json'))
}
return
}
if (name && existsSync(appPath)) {
console.error(`Directory already exists: ${name}`)
return
}
const filesToCheck = ['index.tsx', 'package.json', 'tsconfig.json']
const filesToCheck = ['index.tsx', 'tsconfig.json']
const existing = filesToCheck.filter((f) => existsSync(join(appPath, f)))
if (existing.length > 0) {
console.error(`Files already exist: ${existing.join(', ')}`)
@ -149,17 +173,42 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
writeFileSync(join(appPath, filename), content)
}
process.chdir(appPath)
await pushApp()
// Initialize git repo and push to server (git push creates the app via the git tool)
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()
console.log('Next steps:')
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) {
@ -175,11 +224,34 @@ export async function openApp(arg?: string) {
console.error(`App is not running: ${name}`)
return
}
const url = makeAppUrl(app.port!)
const url = buildAppUrl(app.name, HOST)
console.log(`Opening ${url}`)
Bun.spawn(['open', url])
}
export async function shareApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
const result = await post<{ ok: boolean, error?: string }>(`/api/apps/${name}/tunnel`)
if (!result) return
if (!result.ok) {
console.error(color.red(result.error ?? 'Failed to share'))
return
}
process.stdout.write(`${color.cyan('↗')} Sharing ${color.bold(name)}...`)
// Poll until tunnelUrl appears
const start = Date.now()
while (Date.now() - start < 15000) {
await sleep(500)
const app: App | undefined = await get(`/api/apps/${name}`)
if (app?.tunnelUrl) {
console.log(` ${color.cyan(app.tunnelUrl)}`)
return
}
}
console.log(` ${color.yellow('enabled (URL pending)')}`)
}
export async function renameApp(arg: string | undefined, newName: string) {
const name = resolveAppName(arg)
if (!name) return
@ -214,7 +286,15 @@ export async function renameApp(arg: string | undefined, newName: string) {
export async function restartApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/restart`)
const result = await post(`/api/apps/${name}/restart`)
if (!result) return
process.stdout.write(`${color.yellow('↻')} Restarting ${color.bold(name)}...`)
const state = await waitForState(name, 'running', 15000)
if (state === 'running') {
console.log(` ${color.green('running')}`)
} else {
console.log(` ${color.red(state ?? 'unknown')}`)
}
}
export async function rmApp(arg?: string) {
@ -246,11 +326,36 @@ export async function rmApp(arg?: string) {
export async function startApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/start`)
const result = await post(`/api/apps/${name}/start`)
if (!result) return
process.stdout.write(`${color.green('▶')} Starting ${color.bold(name)}...`)
const state = await waitForState(name, 'running', 15000)
if (state === 'running') {
console.log(` ${color.green('running')}`)
} else {
console.log(` ${color.red(state ?? 'unknown')}`)
}
}
export async function unshareApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
const result = await del(`/api/apps/${name}/tunnel`)
if (!result) return
console.log(`${color.gray('↗')} Unshared ${color.bold(name)}`)
}
export async function stopApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/stop`)
const result = await post(`/api/apps/${name}/stop`)
if (!result) return
process.stdout.write(`${color.red('■')} Stopping ${color.bold(name)}...`)
const state = await waitForState(name, 'stopped', 10000)
if (state === 'stopped') {
console.log(` ${color.gray('stopped')}`)
} else {
console.log(` ${color.yellow(state ?? 'unknown')}`)
}
}

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