Compare commits

...

210 Commits
cron ... main

Author SHA1 Message Date
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
3ef7eba0d9 missed a health check 2026-02-09 09:59:30 -08:00
Chris Wanstrath
4b3ab78ae4 use pkg version 2026-02-09 09:49:03 -08:00
Chris Wanstrath
31d9ad4520 rpi moved chromium 2026-02-09 09:44:57 -08:00
eaae9ae993 fix cli host 2026-02-09 09:39:05 -08:00
Chris Wanstrath
8fa7dd9993 try rpi fix 2026-02-09 09:25:59 -08:00
96289f7e30 whoops 2026-02-08 14:19:00 -08:00
b43c1b4660 responsive for mobile 2026-02-08 13:56:09 -08:00
Chris Wanstrath
396f214eae more rpi fixes 2026-02-05 09:34:00 -08:00
b90a90ae92 install fish, comments 2026-02-05 07:41:45 -08:00
Chris Wanstrath
e6046dafee more rpi updates 2026-02-04 18:47:11 -08:00
Chris Wanstrath
7a98850a57 wrong dir 2026-02-04 18:40:40 -08:00
Chris Wanstrath
002282ec72 sanity check 2026-02-04 17:02:48 -08:00
Chris Wanstrath
ae16734708 bun install 2026-02-04 17:00:35 -08:00
Chris Wanstrath
92cf18f0b5 tweak install scripts 2026-02-04 16:59:34 -08:00
Chris Wanstrath
c224dd25a9 ignore health checks in logs 2026-02-04 16:46:53 -08:00
Chris Wanstrath
e7f5e8a636 no todo yet 2026-02-04 16:41:35 -08:00
8f91b676e9 stats 2026-02-04 13:50:26 -08:00
a396f740a5 health checks 2026-02-04 13:35:38 -08:00
02fca1313c show diffs 2026-02-04 13:29:54 -08:00
9bf3973020 show emoji in tabs 2026-02-04 10:31:20 -08:00
0d81406190 fix linter errors 2026-02-04 10:04:35 -08:00
b1fc698b9a PID ideas 2026-02-04 09:52:19 -08:00
a3f36a0c98 tsconfig.json 2026-02-04 09:51:58 -08:00
b2d7c72fee bun check 2026-02-04 09:51:35 -08:00
2ef00c9d53 /ok 2026-02-04 09:51:29 -08:00
067fd12e94 toolscript 2026-02-04 09:50:32 -08:00
0d572b4b5d share pids, proxy api 2026-02-04 08:54:35 -08:00
913f8f34d7 0.0.5 2026-02-04 08:51:30 -08:00
11caa8fe19 tools guide 2026-02-04 08:39:00 -08:00
303d2dfc72 simpler init script for tools 2026-02-04 08:35:42 -08:00
d8769b2d9d fix app tabs 2026-02-04 08:35:38 -08:00
Chris Wanstrath
ed200431f2 skip node_modules when copying 2026-02-02 16:09:55 -08:00
Chris Wanstrath
52bfa783e1 install current symlink automatically from git 2026-02-02 16:03:32 -08:00
Chris Wanstrath
6f03954850 don't hardcode localhost 2026-02-02 15:50:40 -08:00
Chris Wanstrath
d99f80cd0e toes list shows everything by default 2026-02-02 14:59:39 -08:00
Chris Wanstrath
cca93189e0 clear index.js in dev 2026-02-02 13:54:24 -08:00
dfb70c84f5 Merge pull request 'edit code' (#3) from edit-code into main
Reviewed-on: #3
2026-02-02 20:21:33 +00:00
3cf26c7154 Merge pull request 'dotenv support' (#2) from dotenv into main
Reviewed-on: #2
2026-02-02 19:56:22 +00:00
81d0e5d2fd Merge pull request 'cron' (#1) from cron into main
Reviewed-on: #1
2026-02-02 19:53:07 +00:00
f14a731cae edit code 2026-02-01 23:32:10 -08:00
a58c42e0d4 dotenv support 2026-02-01 23:27:22 -08:00
156 changed files with 9852 additions and 1411 deletions

6
.gitignore vendored
View File

@ -1,6 +1,9 @@
.sandlot/
# dependencies (bun install) # dependencies (bun install)
node_modules node_modules
pub/client/index.js pub/client/index.js
toes/
# output # output
out out
@ -33,3 +36,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
# app symlinks (created on boot)
apps/*/current

237
CLAUDE.md
View File

@ -1,6 +1,4 @@
# Toes - Claude Code Guide # Toes
## What It Is
Personal web appliance that auto-discovers and runs multiple web apps on your home network. Personal web appliance that auto-discovers and runs multiple web apps on your home network.
@ -8,139 +6,142 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
## How It Works ## How It Works
1. Host server scans `/apps` directory for valid apps 1. Server scans `APPS_DIR` for directories with a `package.json` containing a `scripts.toes` entry
2. Valid app = has `package.json` with `scripts.toes` entry 2. Each app is spawned as a child process with a unique port (3001-3100)
3. Each app spawned as child process with unique port (3001+) 3. Dashboard UI shows all apps with status, logs, and links via SSE
4. Dashboard UI shows all apps with current status, logs, and links 4. CLI communicates with the server over HTTP
## 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 regular apps only (excludes tools)
toes list --tools # Lists tools only
toes list --all # Lists all apps including 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 |
## Tech Stack ## Tech Stack
- **Bun** runtime (not Node) - **Bun** runtime (not Node)
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype - **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge - **Forge** (typed CSS-in-JS) from `@because/forge`
- **Commander** + **kleur** for CLI - **Commander** + **kleur** for CLI
- TypeScript + Hono JSX - TypeScript + Hono JSX
- Client renders with `hono/jsx/dom` (no build step, served directly)
## Running ## Running
```bash ```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 ## Project Structure
```tsx ```
// apps/example/index.tsx src/
import { Hype } from "@because/hype" server/ # HTTP server and process management ($)
const app = new Hype() client/ # Browser-side dashboard
app.get("/", (c) => c.html(<h1>Content</h1>)) shared/ # Types shared between server and client (@)
export default app.defaults lib/ # Code shared between CLI and server (%)
cli/ # CLI tool
tools/ # @because/toes package exports
pages/ # Hype page routes
``` ```
## Conventions Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
- Apps get `PORT` env var from host ### Server (`src/server/`)
- 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
## Current State - `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create), `DELETE /:name`, `PUT /:name/rename`, `PUT /:name/icon`.
- `api/sync.ts` -- File sync protocol: manifest comparison, push/pull with hash-based diffing.
- `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).
### Infrastructure (Complete) ### Client (`src/client/`)
- 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
### CLI Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx` files directly.
- Full management: `toes list|start|stop|restart|info|new|rename|delete|open`
- File sync: `toes push|pull|sync`
- Logs: `toes logs [-f] <app>`
Check `TODO.txt` for planned features - `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`
- **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash`
- **Config**: `config`, `env`, `versions`, `history`, `rollback`
### 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` -- `.toesignore` pattern matching
### 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
### 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 live at `APPS_DIR/<name>/` with timestamped version directories and a `current` symlink. Push creates a new version; rollback moves the symlink.
### 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 ## Coding Guidelines
@ -211,3 +212,7 @@ function start(app: App): void {
console.log(`Starting ${app.config.name}`) console.log(`Starting ${app.config.name}`)
} }
``` ```
## Writing Apps and Tools
See `docs/CLAUDE.md` for the guide to writing toes apps and tools.

View File

@ -4,11 +4,28 @@ Toes is a personal web server you run in your home.
Plug it in, turn it on, and forget about the cloud. Plug it in, turn it on, and forget about the cloud.
## quickstart ## setup
1. Plug in and turn on your Toes computer. Toes runs on a Raspberry Pi. You'll need:
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
3. Visit https://toes.local to get started! - A Raspberry Pi running Raspberry Pi OS
- A `toes` user with passwordless sudo
SSH into your Pi as the `toes` user and run:
```bash
curl -fsSL https://toes.dev/install | bash
```
This will:
1. Install system dependencies (git, fish shell, networking tools)
2. Install Bun and grant it network binding capabilities
3. Clone and build the toes server
4. Set up bundled apps (clock, code, cron, env, stats, versions)
5. Install and enable a systemd service for auto-start
Once complete, visit `http://<hostname>.local` on your local network.
## features ## features
- Hosts bun/hono/hype webapps - both SSR and SPA. - Hosts bun/hono/hype webapps - both SSR and SPA.
@ -22,10 +39,9 @@ Plug it in, turn it on, and forget about the cloud.
by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production. by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
```bash ```bash
toes config # show current host toes config # show current host
TOES_URL=http://192.168.1.50:3000 toes list # full URL TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
TOES_HOST=mypi.local toes list # hostname (port 80) TOES_URL=http://mypi.local toes list # connect to hostname
TOES_HOST=mypi.local PORT=3000 toes list # hostname + port
``` ```
set `NODE_ENV=production` to default to `toes.local:80`. set `NODE_ENV=production` to default to `toes.local:80`.
@ -34,8 +50,7 @@ set `NODE_ENV=production` to default to `toes.local:80`.
- textOS (TODO, more?) - textOS (TODO, more?)
- Claude that knows about all your toes APIS and your projects. - Claude that knows about all your toes APIS and your projects.
- HTTPS Tunnel for sharing your apps with the world. - non-webapps
- Charts and graphs in the webUI.
## february goal ## 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,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,10 +0,0 @@
import { Hype } from '@because/hype'
const app = new Hype
app.get('/', c => c.html(<h1>Hi there!</h1>))
const apps = () => {
}
export default app.defaults

View File

@ -1 +0,0 @@
20260130-000000

View File

@ -45,6 +45,8 @@ app.get('/styles.css', c => c.text(stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8', 'Content-Type': 'text/css; charset=utf-8',
})) }))
app.get('/ok', c => c.text('ok'))
app.get('/', c => c.html( app.get('/', c => c.html(
<html> <html>
<head> <head>

View File

@ -1 +0,0 @@
20260130-000000

View File

@ -5,16 +5,16 @@
"": { "": {
"name": "code", "name": "code",
"dependencies": { "dependencies": {
"@because/forge": "*", "@because/forge": "^0.0.1",
"@because/howl": "*", "@because/howl": "^0.0.2",
"@because/hype": "*", "@because/hype": "^0.0.2",
"@because/toes": "*", "@because/toes": "^0.0.5",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.9.2", "typescript": "^5.9.3",
}, },
}, },
}, },
@ -23,9 +23,9 @@
"@because/howl": ["@because/howl@0.0.2", "https://npm.nose.space/@because/howl/-/howl-0.0.2.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-Z4okzEa282LKkBk9DQwEUU6FT+PeThfQ6iQAY41LIEjs8B2kfXRZnbWLs7tgpwCfYORxb0RO4Hr7KiyEqnfTvQ=="], "@because/howl": ["@because/howl@0.0.2", "https://npm.nose.space/@because/howl/-/howl-0.0.2.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-Z4okzEa282LKkBk9DQwEUU6FT+PeThfQ6iQAY41LIEjs8B2kfXRZnbWLs7tgpwCfYORxb0RO4Hr7KiyEqnfTvQ=="],
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="], "@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.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="], "@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/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=="],

View File

@ -16,12 +16,12 @@
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.9.2" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@because/forge": "*", "@because/forge": "^0.0.1",
"@because/howl": "*", "@because/howl": "^0.0.2",
"@because/hype": "*", "@because/hype": "^0.0.2",
"@because/toes": "*" "@because/toes": "^0.0.5"
} }
} }

View File

@ -1,6 +1,6 @@
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge' import { define, stylesToCSS } from '@because/forge'
import { baseStyles, initScript, theme } from '@because/toes/tools' import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, stat } from 'fs/promises' import { readdir, stat } from 'fs/promises'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { join, extname, basename } from 'path' import { join, extname, basename } from 'path'
@ -88,6 +88,9 @@ const CodeHeader = define('CodeHeader', {
borderBottom: `1px solid ${theme('colors-border')}`, borderBottom: `1px solid ${theme('colors-border')}`,
fontWeight: 'bold', fontWeight: 'bold',
fontSize: '14px', fontSize: '14px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}) })
const ErrorBox = define('ErrorBox', { const ErrorBox = define('ErrorBox', {
@ -190,6 +193,46 @@ const DownloadButton = define('DownloadButton', {
}, },
}) })
const EditButton = define('EditButton', {
base: 'button',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
backgroundColor: theme('colors-bgElement'),
color: theme('colors-text'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
fontSize: '13px',
cursor: 'pointer',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const EditLink = define('EditLink', {
base: 'a',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
backgroundColor: theme('colors-bgElement'),
color: theme('colors-text'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
fontSize: '13px',
cursor: 'pointer',
textDecoration: 'none',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const FolderIcon = () => ( const FolderIcon = () => (
<FileIcon viewBox="0 0 24 24"> <FileIcon viewBox="0 0 24 24">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" /> <path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
@ -206,6 +249,7 @@ interface LayoutProps {
title: string title: string
children: Child children: Child
highlight?: boolean highlight?: boolean
editable?: boolean
} }
const fileMemoryScript = ` const fileMemoryScript = `
@ -233,7 +277,7 @@ const fileMemoryScript = `
})(); })();
` `
function Layout({ title, children, highlight }: LayoutProps) { function Layout({ title, children, highlight, editable }: LayoutProps) {
return ( return (
<html> <html>
<head> <head>
@ -241,26 +285,42 @@ function Layout({ title, children, highlight }: LayoutProps) {
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title> <title>{title}</title>
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
{highlight && ( {highlight && !editable && (
<> <>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
</> </>
)} )}
{editable && (
<>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" media="(prefers-color-scheme: light)" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" media="(prefers-color-scheme: dark)" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-tsx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
</>
)}
</head> </head>
<body> <body>
<script dangerouslySetInnerHTML={{ __html: fileMemoryScript }} /> <script dangerouslySetInnerHTML={{ __html: fileMemoryScript }} />
<script dangerouslySetInnerHTML={{ __html: initScript }} /> <ToolScript />
<Container> <Container>
{children} {children}
</Container> </Container>
{highlight && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />} {highlight && !editable && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
</body> </body>
</html> </html>
) )
} }
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, { app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8', 'Content-Type': 'text/css; charset=utf-8',
})) }))
@ -284,6 +344,26 @@ app.get('/raw', async c => {
return new Response(file) return new Response(file)
}) })
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 content = await c.req.text()
try {
await Bun.write(fullPath, content)
return c.text('OK')
} catch (err) {
return c.text(`Failed to save: ${err}`, 500)
}
})
async function listFiles(appPath: string, subPath: string = '') { async function listFiles(appPath: string, subPath: string = '') {
const fullPath = join(appPath, subPath) const fullPath = join(appPath, subPath)
const entries = await readdir(fullPath, { withFileTypes: true }) const entries = await readdir(fullPath, { withFileTypes: true })
@ -373,6 +453,29 @@ function getLanguage(filename: string): string {
return langMap[ext] || 'plaintext' return langMap[ext] || 'plaintext'
} }
function getPrismLanguage(filename: string): string {
const ext = extname(filename).toLowerCase()
const langMap: Record<string, string> = {
'.js': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.json': 'json',
'.css': 'css',
'.html': 'html',
'.md': 'markdown',
'.sh': 'bash',
'.yml': 'yaml',
'.yaml': 'yaml',
'.py': 'python',
'.rb': 'ruby',
'.go': 'go',
'.rs': 'rust',
'.sql': 'sql',
}
return langMap[ext] || 'plaintext'
}
app.get('/', async c => { app.get('/', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
@ -478,12 +581,91 @@ app.get('/', async c => {
// Text file - show with syntax highlighting // Text file - show with syntax highlighting
const content = readFileSync(fullPath, 'utf-8') const content = readFileSync(fullPath, 'utf-8')
const language = getLanguage(filename) const language = getLanguage(filename)
const prismLang = getPrismLanguage(filename)
const edit = c.req.query('edit') === '1'
if (edit) {
const editorScript = `
import { CodeJar } from 'https://cdn.jsdelivr.net/npm/codejar@4.2.0/dist/codejar.js';
const editor = document.getElementById('editor');
const saveBtn = document.getElementById('save-btn');
const status = document.getElementById('save-status');
let dirty = false;
const highlight = (el) => {
Prism.highlightElement(el);
};
const jar = CodeJar(editor, highlight, { tab: ' ', addClosing: false });
// Initial highlight
highlight(editor);
jar.onUpdate(() => {
if (!dirty) {
dirty = true;
saveBtn.textContent = 'Save *';
}
});
saveBtn.onclick = async () => {
if (!dirty) return;
saveBtn.disabled = true;
status.textContent = 'Saving...';
try {
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: jar.toString()
});
if (!res.ok) throw new Error('Save failed');
dirty = false;
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
status.textContent = 'Saved!';
setTimeout(() => { status.textContent = ''; }, 2000);
} catch (err) {
saveBtn.disabled = false;
status.textContent = 'Error: ' + err.message;
}
};
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveBtn.click();
}
});
`
return c.html(
<Layout title={`${appName}/${filePath}`} editable>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<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>
</div>
</CodeHeader>
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
</CodeBlock>
<script type="module" dangerouslySetInnerHTML={{ __html: editorScript }} />
</Layout>
)
}
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`} highlight> <Layout title={`${appName}/${filePath}`} highlight>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<CodeBlock> <CodeBlock>
<CodeHeader>{filename}</CodeHeader> <CodeHeader>
<span>{filename}</span>
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
</CodeHeader>
<pre><code class={`language-${language}`}>{content}</code></pre> <pre><code class={`language-${language}`}>{content}</code></pre>
</CodeBlock> </CodeBlock>
</Layout> </Layout>

View File

@ -1 +0,0 @@
20260130-000000

View File

@ -5,10 +5,10 @@
"": { "": {
"name": "cron", "name": "cron",
"dependencies": { "dependencies": {
"@because/forge": "*", "@because/forge": "^0.0.1",
"@because/hype": "*", "@because/hype": "^0.0.2",
"@because/toes": "*", "@because/toes": "^0.0.8",
"croner": "^9.0.0", "croner": "^9.1.0",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@ -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/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.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="], "@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/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=="], "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,7 +36,7 @@
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "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=="], "kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
@ -42,6 +44,6 @@
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@because/toes/@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=="], "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 { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge' import { define, stylesToCSS } from '@because/forge'
import { baseStyles, initScript, theme } from '@because/toes/tools' import { baseStyles, on, ToolScript, theme } from '@because/toes/tools'
import { discoverCronJobs } from './lib/discovery' import { discoverCronJobs } from './lib/discovery'
import { scheduleJob, stopJob } from './lib/scheduler' import { scheduleJob, stopJob } from './lib/scheduler'
import { executeJob } from './lib/executor' import { executeJob } from './lib/executor'
import { setJobs, getJob, getAllJobs, broadcast } from './lib/state' import { setJobs, setInvalidJobs, getJob, getAllJobs, getInvalidJobs, broadcast } from './lib/state'
import { SCHEDULES, type CronJob } from './lib/schedules' import { SCHEDULES, type CronJob, type InvalidJob } from './lib/schedules'
import type { Child } from 'hono/jsx' import type { Child } from 'hono/jsx'
import { join } from 'path' import { join } from 'path'
import { mkdir, writeFile } from 'fs/promises' import { mkdir, writeFile } from 'fs/promises'
@ -74,6 +74,7 @@ const Time = define('Time', {
const RunButton = define('RunButton', { const RunButton = define('RunButton', {
base: 'button', base: 'button',
padding: '4px 10px', padding: '4px 10px',
marginTop: '10px',
fontSize: '12px', fontSize: '12px',
backgroundColor: theme('colors-primary'), backgroundColor: theme('colors-primary'),
color: 'white', color: 'white',
@ -92,6 +93,24 @@ const EmptyState = define('EmptyState', {
color: theme('colors-textMuted'), 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', { const ActionRow = define('ActionRow', {
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
@ -172,18 +191,116 @@ 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 // Layout
function Layout({ title, children }: { title: string; children: Child }) { function Layout({ title, children, refresh }: { title: string; children: Child; refresh?: boolean }) {
return ( return (
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
{refresh && <meta http-equiv="refresh" content="2" />}
<title>{title}</title> <title>{title}</title>
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
<body> <body>
<script dangerouslySetInnerHTML={{ __html: initScript }} /> <ToolScript />
<Container> <Container>
{children} {children}
</Container> </Container>
@ -217,26 +334,81 @@ function statusColor(job: CronJob): string {
} }
// Routes // Routes
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, { app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8', '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 => { app.get('/', async c => {
const appFilter = c.req.query('app') const appFilter = c.req.query('app')
let jobs = getAllJobs() let jobs = getAllJobs()
let invalid = getInvalidJobs()
if (appFilter) { if (appFilter) {
jobs = jobs.filter(j => j.app === appFilter) jobs = jobs.filter(j => j.app === appFilter)
invalid = invalid.filter(j => j.app === appFilter)
} }
jobs.sort((a, b) => a.id.localeCompare(b.id)) 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( return c.html(
<Layout title="Cron Jobs"> <Layout title="Cron Jobs" refresh={anyRunning}>
<ActionRow> <ActionRow>
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton> <NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
</ActionRow> </ActionRow>
{jobs.length === 0 ? ( {!hasAny ? (
<EmptyState> <EmptyState>
No cron jobs found. No cron jobs found.
<br /> <br />
@ -247,7 +419,11 @@ app.get('/', async c => {
{jobs.map(job => ( {jobs.map(job => (
<JobItem> <JobItem>
<StatusDot style={{ backgroundColor: statusColor(job) }} /> <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> <Schedule>{job.schedule}</Schedule>
<Time title="Last run">{formatRelative(job.lastRun)}</Time> <Time title="Last run">{formatRelative(job.lastRun)}</Time>
<Time title="Next run">{formatRelative(job.nextRun)}</Time> <Time title="Next run">{formatRelative(job.nextRun)}</Time>
@ -258,12 +434,99 @@ app.get('/', async c => {
</form> </form>
</JobItem> </JobItem>
))} ))}
{invalid.map(job => (
<InvalidItem>
<StatusDot style={{ backgroundColor: theme('colors-error') }} />
<JobName>{job.app}/{job.name}</JobName>
<ErrorText>{job.error}</ErrorText>
</InvalidItem>
))}
</JobList> </JobList>
)} )}
</Layout> </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 => { app.get('/new', async c => {
const appName = c.req.query('app') || '' const appName = c.req.query('app') || ''
@ -334,8 +597,9 @@ export default async function() {
console.log(`[cron] Created ${appName}:${name}`) console.log(`[cron] Created ${appName}:${name}`)
// Trigger rediscovery // Trigger rediscovery
const jobs = await discoverCronJobs() const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs) setJobs(jobs)
setInvalidJobs(invalid)
for (const job of jobs) { for (const job of jobs) {
if (job.id === `${appName}:${name}`) { if (job.id === `${appName}:${name}`) {
scheduleJob(job, broadcast) scheduleJob(job, broadcast)
@ -354,29 +618,39 @@ app.post('/run/:app/:name', async c => {
return c.redirect('/?error=not-found') 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') 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}` : '/') return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
}) })
// Initialize // Initialize
async function init() { async function init() {
const jobs = await discoverCronJobs() const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs) 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) { for (const job of jobs) {
scheduleJob(job, broadcast) scheduleJob(job, broadcast)
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`) 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 // Watch for cron file changes
let debounceTimer: Timer | null = null let debounceTimer: Timer | null = null
async function rediscover() { async function rediscover() {
const jobs = await discoverCronJobs() const { jobs, invalid } = await discoverCronJobs()
const existing = getAllJobs() const existing = getAllJobs()
// Stop removed jobs // Stop removed jobs
@ -400,11 +674,13 @@ async function rediscover() {
job.lastDuration = old.lastDuration job.lastDuration = old.lastDuration
job.lastExitCode = old.lastExitCode job.lastExitCode = old.lastExitCode
job.lastError = old.lastError job.lastError = old.lastError
job.lastOutput = old.lastOutput
job.nextRun = old.nextRun job.nextRun = old.nextRun
} }
} }
setJobs(jobs) setJobs(jobs)
setInvalidJobs(invalid)
} }
watch(APPS_DIR, { recursive: true }, (_event, filename) => { watch(APPS_DIR, { recursive: true }, (_event, filename) => {
@ -415,6 +691,11 @@ watch(APPS_DIR, { recursive: true }, (_event, filename) => {
debounceTimer = setTimeout(rediscover, 100) debounceTimer = setTimeout(rediscover, 100)
}) })
on(['app:activate', 'app:delete'], (event) => {
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
rediscover()
})
init() init()
export default app.defaults export default app.defaults

View File

@ -1,10 +1,12 @@
import { readdir } from 'fs/promises' import { readdir, readFile } from 'fs/promises'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { isValidSchedule, toCronExpr, type CronJob, type Schedule } from './schedules' import { isValidSchedule, toCronExpr, type CronJob, type InvalidJob, type Schedule } from './schedules'
const APPS_DIR = process.env.APPS_DIR! const APPS_DIR = process.env.APPS_DIR!
const SCHEDULE_RE = /export\s+const\s+schedule\s*=\s*['"]([^'"]+)['"]/
export async function getApps(): Promise<string[]> { export async function getApps(): Promise<string[]> {
const entries = await readdir(APPS_DIR, { withFileTypes: true }) const entries = await readdir(APPS_DIR, { withFileTypes: true })
const apps: string[] = [] const apps: string[] = []
@ -20,8 +22,14 @@ export async function getApps(): Promise<string[]> {
return apps.sort() return apps.sort()
} }
export async function discoverCronJobs(): Promise<CronJob[]> { export type DiscoveryResult = {
jobs: CronJob[]
invalid: InvalidJob[]
}
export async function discoverCronJobs(): Promise<DiscoveryResult> {
const jobs: CronJob[] = [] const jobs: CronJob[] = []
const invalid: InvalidJob[] = []
const apps = await readdir(APPS_DIR, { withFileTypes: true }) const apps = await readdir(APPS_DIR, { withFileTypes: true })
for (const app of apps) { for (const app of apps) {
@ -36,18 +44,26 @@ export async function discoverCronJobs(): Promise<CronJob[]> {
const filePath = join(cronDir, file) const filePath = join(cronDir, file)
const name = file.replace(/\.ts$/, '') const name = file.replace(/\.ts$/, '')
const id = `${app.name}:${name}`
try { try {
const mod = await import(filePath) const source = await readFile(filePath, 'utf-8')
const schedule = mod.schedule as Schedule 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)) { if (!isValidSchedule(schedule)) {
console.error(`Invalid schedule in ${filePath}: ${schedule}`) invalid.push({ id, app: app.name, name, file: filePath, error: `Invalid schedule: "${match[1]}"` })
continue continue
} }
jobs.push({ jobs.push({
id: `${app.name}:${name}`, id,
app: app.name, app: app.name,
name, name,
file: filePath, file: filePath,
@ -56,10 +72,11 @@ export async function discoverCronJobs(): Promise<CronJob[]> {
state: 'idle', state: 'idle',
}) })
} catch (e) { } catch (e) {
console.error(`Failed to load cron file ${filePath}:`, e) const msg = e instanceof Error ? e.message : String(e)
invalid.push({ id, app: app.name, name, file: filePath, error: msg })
} }
} }
} }
return jobs return { jobs, invalid }
} }

View File

@ -1,49 +1,95 @@
import { join } from 'path' import { join } from 'path'
import { loadAppEnv } from '@because/toes/tools'
import type { CronJob } from './schedules' import type { CronJob } from './schedules'
import { getNextRun } from './scheduler' import { getNextRun } from './scheduler'
const APPS_DIR = process.env.APPS_DIR! 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> { export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
if (job.state === 'disabled') return if (job.state === 'disabled') return
job.state = 'running' job.state = 'running'
job.lastRun = Date.now() job.lastRun = Date.now()
job.lastOutput = undefined
job.lastError = undefined
job.lastExitCode = undefined
job.lastDuration = undefined
onUpdate() onUpdate()
const cwd = join(APPS_DIR, job.app, 'current') const cwd = join(APPS_DIR, job.app, 'current')
forwardLog(job.app, `[cron] Running ${job.name}`)
try { try {
const proc = Bun.spawn(['bun', 'run', job.file], { const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
cwd, cwd,
env: { ...process.env }, env: { ...process.env, ...loadAppEnv(job.app), DATA_DIR: join(TOES_DIR, job.app) },
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
}) })
const [stdout, stderr] = await Promise.all([ // Stream output incrementally into job fields
new Response(proc.stdout).text(), await Promise.all([
new Response(proc.stderr).text(), 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 const code = await proc.exited
job.lastDuration = Date.now() - job.lastRun job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = code job.lastExitCode = code
job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined 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.state = 'idle'
job.nextRun = getNextRun(job.id) job.nextRun = getNextRun(job.id)
// Log result // Log result
console.log(`[cron] ${job.id} finished: code=${code} duration=${job.lastDuration}ms`) const status = code === 0 ? 'ok' : `failed (code=${code})`
if (stdout) console.log(stdout) const summary = `[cron] ${job.name} finished: ${status} duration=${job.lastDuration}ms`
if (stderr) console.error(stderr) 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) { } catch (e) {
job.lastDuration = Date.now() - job.lastRun job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = 1 job.lastExitCode = 1
job.lastError = e instanceof Error ? e.message : String(e) job.lastError = e instanceof Error ? e.message : String(e)
job.state = 'idle' job.state = 'idle'
console.error(`[cron] ${job.id} failed:`, e) console.error(`[cron] ${job.id} failed:`, e)
forwardLog(job.app, `[cron] ${job.name} failed: ${job.lastError}`, 'stderr')
} }
onUpdate() onUpdate()

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" | "30 minutes" | "15 minutes" | "5 minutes" | "1 minute"
| "30minutes" | "15minutes" | "5minutes" | "1minute" | "30minutes" | "15minutes" | "5minutes" | "1minute"
| 30 | 15 | 5 | 1 | 30 | 15 | 5 | 1
| (string & {}) // time strings like "7am", "7:30pm", "14:00"
export type CronJob = { export type CronJob = {
id: string // "appname:filename" id: string // "appname:filename"
@ -17,9 +18,18 @@ export type CronJob = {
lastDuration?: number lastDuration?: number
lastExitCode?: number lastExitCode?: number
lastError?: string lastError?: string
lastOutput?: string
nextRun?: number nextRun?: number
} }
export type InvalidJob = {
id: string
app: string
name: string
file: string
error: string
}
export const SCHEDULES = [ export const SCHEDULES = [
'1 minute', '1 minute',
'5 minutes', '5 minutes',
@ -62,19 +72,48 @@ const SCHEDULE_MAP: Record<string, string> = {
'1minute': '* * * * *', '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 { export function isValidSchedule(value: unknown): value is Schedule {
if (typeof value === 'number') { if (typeof value === 'number') {
return [1, 5, 15, 30].includes(value) return [1, 5, 15, 30].includes(value)
} }
if (typeof value === 'string') { if (typeof value === 'string') {
return value in SCHEDULE_MAP return value in SCHEDULE_MAP || parseTime(value) !== null
} }
return false 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 jobs = new Map<string, CronJob>()
const listeners = new Set<() => void>() const listeners = new Set<() => void>()
let invalidJobs: InvalidJob[] = []
export function setJobs(newJobs: CronJob[]) { export function setJobs(newJobs: CronJob[]) {
jobs.clear() jobs.clear()
for (const job of newJobs) { for (const job of newJobs) {
@ -11,6 +13,10 @@ export function setJobs(newJobs: CronJob[]) {
broadcast() broadcast()
} }
export function setInvalidJobs(newInvalid: InvalidJob[]) {
invalidJobs = newInvalid
}
export function getJob(id: string): CronJob | undefined { export function getJob(id: string): CronJob | undefined {
return jobs.get(id) return jobs.get(id)
} }
@ -19,6 +25,10 @@ export function getAllJobs(): CronJob[] {
return Array.from(jobs.values()) return Array.from(jobs.values())
} }
export function getInvalidJobs(): InvalidJob[] {
return invalidJobs
}
export function broadcast() { export function broadcast() {
listeners.forEach(cb => cb()) listeners.forEach(cb => cb())
} }

View File

@ -11,10 +11,10 @@
"icon": "⏰" "icon": "⏰"
}, },
"dependencies": { "dependencies": {
"@because/forge": "*", "@because/forge": "^0.0.1",
"@because/hype": "*", "@because/hype": "^0.0.2",
"@because/toes": "*", "@because/toes": "^0.0.8",
"croner": "^9.0.0" "croner": "^9.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

View File

@ -1 +0,0 @@
20260201-000000

View File

@ -3,7 +3,7 @@
"configVersion": 1, "configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "todo", "name": "env",
"dependencies": { "dependencies": {
"@because/forge": "*", "@because/forge": "*",
"@because/hype": "*", "@because/hype": "*",
@ -20,13 +20,13 @@
"packages": { "packages": {
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="], "@because/forge": ["@because/forge@0.0.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=="], "@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.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="], "@because/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
"@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/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=="], "@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=="],
"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.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
@ -41,5 +41,7 @@
"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=="], "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=="], "undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@because/toes/@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=="],
} }
} }

471
apps/env/20260130-000000/index.tsx vendored Normal file
View File

@ -0,0 +1,471 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, initScript, theme } from '@because/toes/tools'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
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',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
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,
},
},
})
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', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '10px',
states: {
':last-child': {
borderBottom: 'none',
},
},
})
const EnvKey = define('EnvKey', {
fontFamily: theme('fonts-mono'),
fontSize: '14px',
fontWeight: 'bold',
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',
color: theme('colors-textMuted'),
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
})
const Form = define('Form', {
base: 'form',
display: 'flex',
gap: '10px',
marginTop: '15px',
padding: '15px',
backgroundColor: theme('colors-bgSubtle'),
borderRadius: theme('radius-md'),
})
const Hint = define('Hint', {
fontSize: '12px',
color: theme('colors-textMuted'),
marginTop: '10px',
})
const Input = define('Input', {
base: 'input',
flex: 1,
padding: '8px 12px',
fontSize: '14px',
fontFamily: theme('fonts-mono'),
borderRadius: theme('radius-md'),
border: `1px solid ${theme('colors-border')}`,
backgroundColor: theme('colors-bg'),
color: theme('colors-text'),
states: {
':focus': {
outline: 'none',
borderColor: theme('colors-primary'),
},
},
})
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',
})
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>
<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>
<script dangerouslySetInnerHTML={{ __html: initScript }} />
<Container>
{children}
</Container>
<script dangerouslySetInnerHTML={{ __html: clientScript }} />
</body>
</html>
)
}
function ensureEnvDir() {
if (!existsSync(ENV_DIR)) {
mkdirSync(ENV_DIR, { recursive: true })
}
}
function parseEnvFile(path: string): EnvVar[] {
if (!existsSync(path)) return []
const content = readFileSync(path, 'utf-8')
const vars: EnvVar[] = []
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex === -1) continue
const key = trimmed.slice(0, eqIndex).trim()
let value = trimmed.slice(eqIndex + 1).trim()
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
if (key) vars.push({ key, value })
}
return vars
}
function writeEnvFile(path: string, vars: EnvVar[]) {
ensureEnvDir()
const content = vars.map(v => `${v.key}=${v.value}`).join('\n') + (vars.length ? '\n' : '')
writeFileSync(path, content)
}
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'))
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="Environment Variables">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox>
</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}`}>
<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}
{globalKeys.has(v.key) && <Badge>overrides global</Badge>}
</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
<form method="post" action={`/delete?app=${appName}&key=${v.key}`} style="margin:0">
<DangerButton type="submit">Delete</DangerButton>
</form>
</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}`}>
<Input type="text" name="key" placeholder="KEY" required />
<Input type="text" name="value" placeholder="value" required />
<Button type="submit">Add</Button>
</Form>
</Layout>
)
})
app.post('/set', async c => {
const appName = c.req.query('app')
if (!appName) return c.text('Missing app', 400)
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 path = appEnvPath(appName)
const vars = parseEnvFile(path)
const existing = vars.findIndex(v => v.key === key)
if (existing >= 0) {
vars[existing]!.value = value
} else {
vars.push({ key, value })
}
writeEnvFile(path, vars)
return c.redirect(`/?app=${appName}`)
})
app.post('/delete', async c => {
const appName = c.req.query('app')
const key = c.req.query('key')
if (!appName || !key) return c.text('Missing app or key', 400)
const path = appEnvPath(appName)
const vars = parseEnvFile(path).filter(v => v.key !== key)
writeEnvFile(path, vars)
return c.redirect(`/?app=${appName}`)
})
app.post('/set-global', async c => {
const appName = c.req.query('app')
if (!appName) return c.text('Missing app', 400)
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(`/?app=${appName}&tab=global`)
})
app.post('/delete-global', async c => {
const appName = c.req.query('app')
const key = c.req.query('key')
if (!appName || !key) return c.text('Missing app or key', 400)
const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key)
writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(`/?app=${appName}&tab=global`)
})
export default app.defaults

View File

@ -1,5 +1,5 @@
{ {
"name": "todo", "name": "env",
"module": "index.tsx", "module": "index.tsx",
"type": "module", "type": "module",
"private": true, "private": true,
@ -9,8 +9,8 @@
"dev": "bun run --hot index.tsx" "dev": "bun run --hot index.tsx"
}, },
"toes": { "toes": {
"tool": "TODO", "tool": ".env",
"icon": "" "icon": "🔑"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

View File

@ -1 +0,0 @@
hi

View File

@ -0,0 +1,45 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "git",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
"@because/toes": ["@because/toes@0.0.5", "https://npm.nose.space/@because/toes/-/toes-0.0.5.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-YM1VuR1sym7m7pFcaiqnjg6eJUyhJYUH2ROBb+xi+HEXajq46ZL8KDyyCtz7WiHTfrbxcEWGjqyj20a7UppcJg=="],
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.3", "https://npm.nose.space/@types/node/-/node-25.3.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"hono": ["hono@4.12.3", "https://npm.nose.space/hono/-/hono-4.12.3.tgz", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@ -0,0 +1,565 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { mkdirSync } from 'fs'
import { mkdir, readdir, readlink, rm, stat } from 'fs/promises'
import { join, resolve } from 'path'
import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR!
const DATA_DIR = process.env.DATA_DIR!
const TOES_URL = process.env.TOES_URL!
const MAX_VERSIONS = 5
const REPOS_DIR = join(DATA_DIR, 'repos')
const VALID_NAME = /^[a-zA-Z0-9_-]+$/
const app = new Hype({ prettyHTML: false, layout: false })
const deployLocks = new Map<string, Promise<void>>()
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const Badge = define('Badge', {
fontSize: '12px',
padding: '2px 8px',
borderRadius: theme('radius-md'),
backgroundColor: theme('colors-bgElement'),
color: theme('colors-textMuted'),
})
const CodeBlock = define('CodeBlock', {
base: 'pre',
backgroundColor: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
padding: theme('spacing-lg'),
fontFamily: theme('fonts-mono'),
fontSize: '13px',
overflowX: 'auto',
color: theme('colors-text'),
lineHeight: '1.5',
})
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
const Heading = define('Heading', {
base: 'h3',
margin: '24px 0 8px',
color: theme('colors-text'),
})
const HelpText = define('HelpText', {
color: theme('colors-textMuted'),
fontSize: '14px',
lineHeight: '1.6',
margin: '12px 0',
})
const RepoItem = define('RepoItem', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
states: {
':last-child': { borderBottom: 'none' },
':hover': { backgroundColor: theme('colors-bgHover') },
},
})
const RepoList = define('RepoList', {
listStyle: 'none',
padding: 0,
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const RepoName = define('RepoName', {
fontFamily: theme('fonts-mono'),
fontSize: '15px',
fontWeight: 'bold',
color: theme('colors-text'),
})
// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------
interface LayoutProps {
title: string
children: Child
}
// ---------------------------------------------------------------------------
// Functions
// ---------------------------------------------------------------------------
const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)
const timestamp = () => {
const [date, time] = new Date().toISOString().slice(0, 19).split('T')
return `${date.replaceAll('-', '')}-${time.replaceAll(':', '')}`
}
// resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal
const validRepoName = (name: string) =>
VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)
async function activateApp(name: string, version: string): Promise<string | null> {
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/activate?version=${version}`, {
method: 'POST',
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const msg = (body as Record<string, string>).error ?? `activate returned ${res.status}`
console.error(`Activate failed for ${name}@${version}:`, msg)
return msg
}
return null
}
async function cleanOldVersions(appDir: string): Promise<void> {
if (!(await dirExists(appDir))) return
// Read the current symlink target so we never delete the active version
let current: string | null = null
try {
const target = await readlink(join(appDir, 'current'))
current = target.split('/').pop() ?? null
} catch {}
const entries = await readdir(appDir, { withFileTypes: true })
const versions = entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => e.name)
.sort()
if (versions.length <= MAX_VERSIONS) return
const toRemove = versions
.slice(0, versions.length - MAX_VERSIONS)
.filter(v => v !== current)
for (const dir of toRemove) {
await rm(join(appDir, dir), { recursive: true, force: true })
}
}
async function deploy(repoName: string): Promise<{ ok: boolean; error?: string; version?: string }> {
const bare = repoPath(repoName)
if (!(await hasCommits(bare))) {
return { ok: false, error: 'No commits in repository' }
}
const ts = timestamp()
const appDir = join(APPS_DIR, repoName)
const versionDir = join(appDir, ts)
await mkdir(versionDir, { recursive: true })
// Extract HEAD into the version directory — no shell, pipe git archive into tar
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
stdout: 'pipe',
stderr: 'pipe',
})
const tar = Bun.spawn(['tar', '-x', '-C', versionDir], {
stdin: archive.stdout,
stdout: 'ignore',
stderr: 'pipe',
})
// Consume stderr concurrently to prevent pipe buffer from filling and blocking the process
const [archiveExit, tarExit, archiveErr, tarErr] = await Promise.all([
archive.exited,
tar.exited,
new Response(archive.stderr).text(),
new Response(tar.stderr).text(),
])
if (archiveExit !== 0 || tarExit !== 0) {
await rm(versionDir, { recursive: true, force: true })
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
}
// Verify package.json with scripts.toes exists
const pkgPath = join(versionDir, 'package.json')
if (!(await Bun.file(pkgPath).exists())) {
await rm(versionDir, { recursive: true, force: true })
return { ok: false, error: 'No package.json found in repository' }
}
try {
const pkg = JSON.parse(await Bun.file(pkgPath).text())
if (!pkg.scripts?.toes) {
await rm(versionDir, { recursive: true, force: true })
return { ok: false, error: 'package.json missing scripts.toes entry' }
}
} catch {
await rm(versionDir, { recursive: true, force: true })
return { ok: false, error: 'Invalid package.json' }
}
// Clean up old versions beyond MAX_VERSIONS
await cleanOldVersions(appDir)
return { ok: true, version: ts }
}
// Bun.file().exists() is for files only — it returns false for directories.
// Use stat() to check directory existence instead.
async function dirExists(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory()
} catch {
return false
}
}
async function ensureBareRepo(name: string): Promise<string> {
const bare = repoPath(name)
if (!(await dirExists(bare))) {
await mkdir(bare, { recursive: true })
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: bare }).exited
await run(['git', 'init', '--bare'])
await run(['git', 'symbolic-ref', 'HEAD', 'refs/heads/main'])
await run(['git', 'config', 'http.receivepack', 'true'])
}
return bare
}
function findLastFlush(data: Uint8Array): number {
for (let i = data.length - 4; i >= 0; i--) {
if (data[i] === 0x30 && data[i + 1] === 0x30 &&
data[i + 2] === 0x30 && data[i + 3] === 0x30) {
return i
}
}
return -1
}
async function getDefaultBranch(bare: string): Promise<string> {
const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
cwd: bare,
stdout: 'pipe',
// Ignore stderr to avoid filling the pipe buffer and blocking the process
stderr: 'ignore',
})
if ((await proc.exited) === 0) {
const ref = await new Response(proc.stdout).text()
return ref.trim().replace('refs/heads/', '')
}
return 'main'
}
async function gitRpc(
repo: string,
service: string,
body: ReadableStream<Uint8Array> | null,
): Promise<Response> {
const bare = repoPath(repo)
const proc = Bun.spawn([service, '--stateless-rpc', bare], {
stdin: body ?? 'ignore',
stdout: 'pipe',
// Ignore stderr to avoid filling the pipe buffer and blocking the process
stderr: 'ignore',
})
return new Response(proc.stdout, {
headers: {
'Content-Type': `application/x-${service}-result`,
'Cache-Control': 'no-cache',
},
})
}
async function gitService(repo: string, service: string): Promise<Response | null> {
const bare = repoPath(repo)
if (!(await dirExists(bare))) return null
const proc = Bun.spawn([service, '--stateless-rpc', '--advertise-refs', bare], {
stdout: 'pipe',
// Ignore stderr to avoid filling the pipe buffer and blocking the process
stderr: 'ignore',
})
const stdout = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
await proc.exited
const header = serviceHeader(service)
const body = new Uint8Array(header.length + stdout.byteLength)
body.set(header, 0)
body.set(stdout, header.length)
return new Response(body, {
headers: {
'Content-Type': `application/x-${service}-advertisement`,
'Cache-Control': 'no-cache',
},
})
}
function gitSidebandMessage(text: string): Uint8Array {
const encoder = new TextEncoder()
const lines = text.split('\n').filter(Boolean)
const parts: Uint8Array[] = []
for (const line of lines) {
const msg = `\x02remote: ${line}\n`
const hex = (4 + msg.length).toString(16).padStart(4, '0')
parts.push(encoder.encode(hex + msg))
}
const total = parts.reduce((sum, p) => sum + p.length, 0)
const out = new Uint8Array(total)
let offset = 0
for (const part of parts) {
out.set(part, offset)
offset += part.length
}
return out
}
async function hasCommits(bare: string): Promise<boolean> {
const proc = Bun.spawn(['git', 'rev-parse', 'HEAD'], {
cwd: bare,
// Only checking exit code; ignore stdout/stderr to avoid filling the pipe buffer
stdout: 'ignore',
stderr: 'ignore',
})
return (await proc.exited) === 0
}
function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array {
const pos = findLastFlush(gitBody)
if (pos === -1) {
const out = new Uint8Array(gitBody.length + msg.length)
out.set(gitBody, 0)
out.set(msg, gitBody.length)
return out
}
const out = new Uint8Array(gitBody.length + msg.length)
out.set(gitBody.subarray(0, pos), 0)
out.set(msg, pos)
out.set(gitBody.subarray(pos), pos + msg.length)
return out
}
function Layout({ title, children }: LayoutProps) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>{children}</Container>
</body>
</html>
)
}
async function listRepos(): Promise<string[]> {
if (!(await dirExists(REPOS_DIR))) return []
const entries = await readdir(REPOS_DIR, { withFileTypes: true })
return entries
.filter(e => e.isDirectory() && e.name.endsWith('.git'))
.map(e => e.name.replace(/\.git$/, ''))
.sort()
}
function serviceHeader(service: string): Uint8Array {
const line = `# service=${service}\n`
const hex = (4 + line.length).toString(16).padStart(4, '0')
const header = `${hex}${line}0000`
return new TextEncoder().encode(header)
}
async function withDeployLock<T>(repo: string, fn: () => Promise<T>): Promise<T> {
const prev = deployLocks.get(repo) ?? Promise.resolve()
const { promise: lock, resolve: release } = Promise.withResolvers<void>()
deployLocks.set(repo, lock)
await prev
try {
return await fn()
} finally {
release()
if (deployLocks.get(repo) === lock) deployLocks.delete(repo)
}
}
// ---------------------------------------------------------------------------
// Module init
// ---------------------------------------------------------------------------
mkdirSync(REPOS_DIR, { recursive: true })
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c =>
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
)
// GET /:repo.git/info/refs?service=git-upload-pack|git-receive-pack
app.get('/:repo{.+\\.git}/info/refs', async c => {
const repoParam = c.req.param('repo').replace(/\.git$/, '')
const service = c.req.query('service')
if (!validRepoName(repoParam)) {
return c.text('Invalid repository name', 400)
}
if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
return c.text('Invalid service', 400)
}
if (service === 'git-receive-pack') {
await ensureBareRepo(repoParam)
}
const bare = repoPath(repoParam)
if (!(await dirExists(bare))) {
return c.text('Repository not found', 404)
}
const res = await gitService(repoParam, service)
return res ?? c.text('Repository not found', 404)
})
// POST /:repo.git/git-upload-pack
app.post('/:repo{.+\\.git}/git-upload-pack', async c => {
const repoParam = c.req.param('repo').replace(/\.git$/, '')
if (!validRepoName(repoParam)) {
return c.text('Invalid repository name', 400)
}
const bare = repoPath(repoParam)
if (!(await dirExists(bare))) {
return c.text('Repository not found', 404)
}
return gitRpc(repoParam, 'git-upload-pack', c.req.raw.body)
})
// POST /:repo.git/git-receive-pack
app.post('/:repo{.+\\.git}/git-receive-pack', async c => {
const repoParam = c.req.param('repo').replace(/\.git$/, '')
if (!validRepoName(repoParam)) {
return c.text('Invalid repository name', 400)
}
await ensureBareRepo(repoParam)
const response = await gitRpc(repoParam, 'git-receive-pack', c.req.raw.body)
// Buffer the full response so we can inject sideband error messages before the
// final flush-pkt on deploy failure. The receive-pack response is just ref status
// lines (not pack data), so the buffer is small regardless of push size.
const gitBody = new Uint8Array(await response.arrayBuffer())
const deployError = await withDeployLock(repoParam, async () => {
try {
const result = await deploy(repoParam)
if (result.ok && result.version) {
const err = await activateApp(repoParam, result.version)
if (err) {
console.error(`Activate failed for ${repoParam}: ${err}`)
return `Deploy succeeded but activation failed: ${err}`
}
console.log(`Deployed ${repoParam}@${result.version}`)
return null
}
console.error(`Deploy failed for ${repoParam}: ${result.error}`)
return `Deploy failed: ${result.error}`
} catch (e) {
console.error(`Deploy error for ${repoParam}:`, e)
return `Deploy failed: ${e instanceof Error ? e.message : String(e)}`
}
})
const headers = {
'Content-Type': response.headers.get('Content-Type') ?? 'application/x-git-receive-pack-result',
'Cache-Control': 'no-cache',
}
if (deployError) {
return new Response(insertBeforeFlush(gitBody, gitSidebandMessage(deployError)), { headers })
}
return new Response(gitBody, { headers })
})
app.get('/', async c => {
const repos = await listRepos()
const host = c.req.header('host') ?? 'git.toes.local'
const baseUrl = `http://${host}`
const repoData = await Promise.all(repos.map(async name => {
const bare = repoPath(name)
const [commits, branch] = await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
return { name, commits, branch }
}))
return c.html(
<Layout title="Git">
<Heading>Push to Deploy</Heading>
<HelpText>
Push a git repository to deploy it as a toes app.
The repo must contain a <code>package.json</code> with a <code>scripts.toes</code> entry.
</HelpText>
<CodeBlock>{[
'# Add this server as a remote and push',
`git remote add toes ${baseUrl}/<app-name>.git`,
'git push toes main',
'',
'# Or push an existing repo',
`git push ${baseUrl}/<app-name>.git main`,
].join('\n')}</CodeBlock>
{repoData.length > 0 && (
<>
<Heading>Repositories</Heading>
<RepoList>
{repoData.map(({ name, commits, branch }) => (
<RepoItem>
<div>
<RepoName>{name}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{name}.git
</HelpText>
</div>
<div style="display: flex; gap: 8px; align-items: center">
<Badge>{branch}</Badge>
{commits
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
: <Badge>empty</Badge>}
</div>
</RepoItem>
))}
</RepoList>
</>
)}
{repoData.length === 0 && (
<HelpText>No repositories yet. Push one to get started.</HelpText>
)}
</Layout>,
)
})
export default app.defaults

View File

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

View File

@ -0,0 +1,45 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "stats",
"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.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
"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=="],
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "basic", "name": "metrics",
"module": "src/index.ts", "module": "index.tsx",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
@ -8,14 +8,19 @@
"start": "bun toes", "start": "bun toes",
"dev": "bun run --hot index.tsx" "dev": "bun run --hot index.tsx"
}, },
"toes": {
"tool": true,
"icon": "📊"
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.9.2" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.1",
"@because/hype": "^0.0.1" "@because/hype": "^0.0.2",
"@because/toes": "^0.0.5"
} }
} }

View File

@ -18,12 +18,6 @@
"noImplicitOverride": true, "noImplicitOverride": true,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false
"baseUrl": ".",
"paths": {
"$*": ["src/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
} }
} }

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,7 +0,0 @@
import { Hype } from '@because/hype'
const app = new Hype
app.get('/', c => c.html(<h1>My Profile!!!</h1>))
export default app.defaults

View File

@ -1 +0,0 @@
20260130-000000

View File

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

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
20260130-000000

View File

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

View File

@ -1 +0,0 @@
export { default } from './src/server'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -1,36 +0,0 @@
import { render, useState } from 'hono/jsx/dom'
import { define } from '@because/forge'
const Wrapper = define({
margin: '0 auto',
marginTop: 50,
width: '50vw',
border: '1px solid black',
padding: 24,
textAlign: 'center'
})
export default function App() {
const [count, setCount] = useState(0)
try {
return (
<Wrapper>
<h1>It works!</h1>
<h2>Count: {count}</h2>
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
&nbsp;
<button onClick={() => setCount(c => c && c - 1)}>-</button>
</div>
</Wrapper>
)
} catch (error) {
console.error('Render error:', error)
return <><h1>Error</h1><pre>{error instanceof Error ? error : new Error(String(error))}</pre></>
}
}
const root = document.getElementById('root')!
render(<App />, root)

View File

@ -1,40 +0,0 @@
section {
max-width: 500px;
margin: 0 auto;
text-align: center;
font-size: 200%;
}
h1 {
margin-top: 0;
}
.hype {
display: inline-block;
padding: 0.3rem 0.8rem;
background: linear-gradient(45deg,
#ff00ff 0%,
#00ffff 33%,
#ffff00 66%,
#ff00ff 100%);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
color: black;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-weight: 700;
border-radius: 4px;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
ul {
list-style-type: none;
}

View File

@ -1,31 +0,0 @@
import { $ } from 'bun'
const GIT_HASH = process.env.RENDER_GIT_COMMIT?.slice(0, 7)
|| await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'unknown')
export default () => <>
<html lang="en">
<head>
<title>hype</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
<script dangerouslySetInnerHTML={{
__html: `
window.GIT_HASH = '${GIT_HASH}';
${(process.env.NODE_ENV !== 'production' || process.env.IS_PULL_REQUEST === 'true') ? 'window.DEBUG = true;' : ''}
`
}} />
</head>
<body>
<div id="viewport">
<main>
<div id="root" />
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
</main>
</div>
</body>
</html>
</>

View File

@ -1,363 +0,0 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, initScript, theme } from '@because/toes/tools'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { join } from 'path'
const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false })
const Container = define('TodoContainer', {
fontFamily: theme('fonts-sans'),
padding: '20px',
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
const Header = define('Header', {
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})
const Title = define('Title', {
margin: 0,
fontSize: '24px',
fontWeight: 'bold',
})
const AppName = define('AppName', {
color: theme('colors-textMuted'),
fontSize: '14px',
})
const TodoList = define('TodoList', {
listStyle: 'none',
padding: 0,
margin: 0,
})
const TodoSection = define('TodoSection', {
marginBottom: '24px',
})
const SectionTitle = define('SectionTitle', {
fontSize: '16px',
fontWeight: 600,
color: theme('colors-textMuted'),
marginBottom: '12px',
paddingBottom: '8px',
borderBottom: `1px solid ${theme('colors-border')}`,
})
const TodoItemStyle = define('TodoItem', {
display: 'flex',
alignItems: 'flex-start',
padding: '8px 0',
gap: '10px',
selectors: {
'& input[type="checkbox"]': {
marginTop: '3px',
width: '18px',
height: '18px',
cursor: 'pointer',
},
'& label': {
flex: 1,
cursor: 'pointer',
lineHeight: '1.5',
},
},
})
const doneClass = 'todo-done'
const Error = define('Error', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
})
const successClass = 'msg-success'
const errorClass = 'msg-error'
const SaveButton = define('SaveButton', {
base: 'button',
backgroundColor: theme('colors-primary'),
color: theme('colors-primaryText'),
border: 'none',
padding: '8px 16px',
borderRadius: theme('radius-md'),
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
states: {
':hover': {
opacity: 0.9,
},
':disabled': {
opacity: 0.5,
cursor: 'not-allowed',
},
},
})
const AddForm = define('AddForm', {
display: 'flex',
gap: '10px',
marginBottom: '20px',
})
const AddInput = define('AddInput', {
base: 'input',
flex: 1,
padding: '8px 12px',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
fontSize: '14px',
backgroundColor: theme('colors-bg'),
color: theme('colors-text'),
states: {
':focus': {
outline: 'none',
borderColor: theme('colors-primary'),
},
},
})
const todoStyles = `
.${doneClass} {
color: ${theme('colors-done')};
text-decoration: line-through;
}
.${successClass} {
padding: 12px 16px;
border-radius: ${theme('radius-md')};
margin-bottom: 16px;
background-color: ${theme('colors-successBg')};
color: ${theme('colors-success')};
}
.${errorClass} {
padding: 12px 16px;
border-radius: ${theme('radius-md')};
margin-bottom: 16px;
background-color: ${theme('colors-bgElement')};
color: ${theme('colors-error')};
}
`
interface TodoEntry {
text: string
done: boolean
}
interface ParsedTodo {
title: string
items: TodoEntry[]
}
function parseTodoFile(content: string): ParsedTodo {
const lines = content.split('\n')
let title = 'TODO'
const items: TodoEntry[] = []
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
if (trimmed.startsWith('# ')) {
title = trimmed.slice(2)
} else if (trimmed.startsWith('[x] ') || trimmed.startsWith('[X] ')) {
items.push({ text: trimmed.slice(4), done: true })
} else if (trimmed.startsWith('[ ] ')) {
items.push({ text: trimmed.slice(4), done: false })
}
}
return { title, items }
}
function serializeTodo(todo: ParsedTodo): string {
const lines = [`# ${todo.title}`]
for (const item of todo.items) {
lines.push(item.done ? `[x] ${item.text}` : `[ ] ${item.text}`)
}
return lines.join('\n') + '\n'
}
app.get('/styles.css', c => c.text(baseStyles + todoStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
app.get('/', async c => {
const appName = c.req.query('app')
const message = c.req.query('message')
const messageType = c.req.query('type') as 'success' | 'error' | undefined
if (!appName) {
return c.html(
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TODO</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<script dangerouslySetInnerHTML={{ __html: initScript }} />
<Container>
<Header>
<Title>TODO</Title>
</Header>
<Error>Select an app to view its TODO list</Error>
</Container>
</body>
</html>
)
}
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
let todo: ParsedTodo
if (existsSync(todoPath)) {
const content = readFileSync(todoPath, 'utf-8')
todo = parseTodoFile(content)
} else {
todo = { title: `${appName} TODO`, items: [] }
}
const pendingItems = todo.items.filter(i => !i.done)
return c.html(
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{todo.title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<script dangerouslySetInnerHTML={{ __html: initScript }} />
<Container>
<Header>
<div>
<Title>{todo.title}</Title>
<AppName>{appName}/TODO.txt</AppName>
</div>
</Header>
{message && (
<div class={messageType === 'success' ? successClass : errorClass}>
{message}
</div>
)}
<form action="/add" method="post">
<input type="hidden" name="app" value={appName} />
<AddForm>
<AddInput type="text" name="text" placeholder="Add a new todo..." required />
<SaveButton type="submit">Add</SaveButton>
</AddForm>
</form>
{todo.items.length > 0 && (
<TodoSection>
<SectionTitle>
{pendingItems.length === 0 ? 'All done!' : `Pending (${pendingItems.length})`}
</SectionTitle>
<TodoList>
{todo.items.map((item, i) => (
<TodoItemStyle key={i}>
<form action="/toggle" method="post" style={{ display: 'contents' }}>
<input type="hidden" name="app" value={appName} />
<input type="hidden" name="index" value={i.toString()} />
<input
type="checkbox"
id={`item-${i}`}
checked={item.done}
onchange="this.form.submit()"
/>
<label for={`item-${i}`} class={item.done ? doneClass : ''}>
{item.text}
</label>
</form>
</TodoItemStyle>
))}
</TodoList>
</TodoSection>
)}
{todo.items.length === 0 && (
<TodoSection>
<SectionTitle>No todos yet</SectionTitle>
<p style={{ color: theme('colors-textMuted') }}>Add your first todo above!</p>
</TodoSection>
)}
</Container>
</body>
</html>
)
})
app.post('/toggle', async c => {
const form = await c.req.formData()
const appName = form.get('app') as string
const index = parseInt(form.get('index') as string, 10)
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
let todo: ParsedTodo
if (existsSync(todoPath)) {
const content = readFileSync(todoPath, 'utf-8')
todo = parseTodoFile(content)
} else {
return c.redirect(`/?app=${appName}`)
}
if (index >= 0 && index < todo.items.length) {
todo.items[index].done = !todo.items[index].done
}
try {
writeFileSync(todoPath, serializeTodo(todo))
return c.redirect(`/?app=${appName}`)
} catch {
return c.redirect(`/?app=${appName}&message=${encodeURIComponent('Failed to save')}&type=error`)
}
})
app.post('/add', async c => {
const form = await c.req.formData()
const appName = form.get('app') as string
const text = (form.get('text') as string).trim()
if (!text) {
return c.redirect(`/?app=${appName}`)
}
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
let todo: ParsedTodo
if (existsSync(todoPath)) {
const content = readFileSync(todoPath, 'utf-8')
todo = parseTodoFile(content)
} else {
todo = { title: `${appName} TODO`, items: [] }
}
todo.items.push({ text, done: false })
try {
writeFileSync(todoPath, serializeTodo(todo))
return c.redirect(`/?app=${appName}`)
} catch {
return c.redirect(`/?app=${appName}&message=${encodeURIComponent('Failed to add')}&type=error`)
}
})
export default app.defaults

View File

@ -1 +0,0 @@
20260130-181927

View File

@ -46,6 +46,8 @@ app.get("/", (c) => {
app.get("/txt", c => c.text(truism())) app.get("/txt", c => c.text(truism()))
app.get("/ok", c => c.text("ok"))
export default { export default {
port: process.env.PORT || 3000, port: process.env.PORT || 3000,
fetch: app.fetch, fetch: app.fetch,

View File

@ -1 +0,0 @@
20260130-000000

View File

@ -5,24 +5,24 @@
"": { "": {
"name": "versions", "name": "versions",
"dependencies": { "dependencies": {
"@because/forge": "*", "@because/forge": "^0.0.1",
"@because/hype": "*", "@because/hype": "^0.0.2",
"@because/toes": "*", "@because/toes": "^0.0.5",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.9.2", "typescript": "^5.9.3",
}, },
}, },
}, },
"packages": { "packages": {
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="], "@because/forge": ["@because/forge@0.0.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=="], "@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.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="], "@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/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=="],

View File

@ -1,6 +1,6 @@
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge' import { define, stylesToCSS } from '@because/forge'
import { baseStyles, initScript, theme } from '@because/toes/tools' import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, readlink, stat } from 'fs/promises' import { readdir, readlink, stat } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import type { Child } from 'hono/jsx' import type { Child } from 'hono/jsx'
@ -19,24 +19,6 @@ const Container = define('Container', {
color: theme('colors-text'), color: theme('colors-text'),
}) })
const Header = define('Header', {
marginBottom: '20px',
paddingBottom: '10px',
borderBottom: `2px solid ${theme('colors-border')}`,
})
const Title = define('Title', {
margin: 0,
fontSize: '24px',
fontWeight: 'bold',
})
const Subtitle = define('Subtitle', {
color: theme('colors-textMuted'),
fontSize: '18px',
marginTop: '5px',
})
const VersionList = define('VersionList', { const VersionList = define('VersionList', {
listStyle: 'none', listStyle: 'none',
padding: 0, padding: 0,
@ -95,11 +77,10 @@ const ErrorBox = define('ErrorBox', {
interface LayoutProps { interface LayoutProps {
title: string title: string
subtitle?: string
children: Child children: Child
} }
function Layout({ title, subtitle, children }: LayoutProps) { function Layout({ title, children }: LayoutProps) {
return ( return (
<html> <html>
<head> <head>
@ -109,11 +90,8 @@ function Layout({ title, subtitle, children }: LayoutProps) {
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
<body> <body>
<script dangerouslySetInnerHTML={{ __html: initScript }} /> <ToolScript />
<Container> <Container>
<Header>
<Title>Versions</Title>
</Header>
{children} {children}
</Container> </Container>
</body> </body>
@ -121,6 +99,8 @@ function Layout({ title, subtitle, children }: LayoutProps) {
) )
} }
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, { app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8', 'Content-Type': 'text/css; charset=utf-8',
})) }))
@ -170,14 +150,14 @@ app.get('/', async c => {
if (versions.length === 0) { if (versions.length === 0) {
return c.html( return c.html(
<Layout title="Versions" subtitle={appName}> <Layout title="Versions">
<ErrorBox>No versions found</ErrorBox> <ErrorBox>No versions found</ErrorBox>
</Layout> </Layout>
) )
} }
return c.html( return c.html(
<Layout title="Versions" subtitle={appName}> <Layout title="Versions">
<VersionList> <VersionList>
{versions.map(v => ( {versions.map(v => (
<VersionItem> <VersionItem>

View File

@ -16,11 +16,11 @@
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.9.2" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@because/forge": "*", "@because/forge": "^0.0.1",
"@because/hype": "*", "@because/hype": "^0.0.2",
"@because/toes": "*" "@because/toes": "^0.0.5"
} }
} }

View File

@ -1 +0,0 @@
20260130-000000

View File

@ -3,11 +3,12 @@
"configVersion": 1, "configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "toes", "name": "@because/toes",
"dependencies": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.1",
"@because/hype": "^0.0.2", "@because/hype": "^0.0.2",
"commander": "^14.0.2", "@because/sneaker": "^0.0.3",
"commander": "^14.0.3",
"diff": "^8.0.3", "diff": "^8.0.3",
"kleur": "^4.1.5", "kleur": "^4.1.5",
}, },
@ -16,7 +17,7 @@
"@types/diff": "^8.0.0", "@types/diff": "^8.0.0",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.9.2", "typescript": "^5.9.3",
}, },
}, },
}, },
@ -25,24 +26,28 @@
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="], "@because/hype": ["@because/hype@0.0.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.3", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.3.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-4cG8w/tYPGbDtLw89k1PiASJKfWUdd1NXv+GKad2d7Ckw3FpZ+dnN2+gR2ihs81dqAkNaZomo+9RznBju2WaOw=="],
"@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/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], "@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/node": ["@types/node@25.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.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"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=="], "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=="], "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=="], "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=="], "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

@ -48,6 +48,7 @@ export default app.defaults
- `PORT` - your assigned port (3001-3100) - `PORT` - your assigned port (3001-3100)
- `APPS_DIR` - path to `/apps` directory - `APPS_DIR` - path to `/apps` directory
- `DATA_DIR` - per-app data directory (`toes/<app-name>/`)
## health checks ## health checks

51
docs/ENV.md Normal file
View File

@ -0,0 +1,51 @@
# Environment Variables
Store API keys and secrets outside your app code.
## Using env vars
Access them via `process.env`:
```tsx
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('Missing OPENAI_API_KEY')
```
Env vars are injected when your app starts. Changing them restarts the app automatically.
## Managing env vars
### CLI
```bash
toes env my-app # list env vars
toes env my-app set KEY value # set a var
toes env my-app set KEY=value # also works
toes env my-app rm KEY # remove a var
```
### Dashboard
The `.env` tool in the tab bar lets you view and edit vars for the selected app. Values are masked until you click Reveal.
## Format
Standard `.env` syntax:
```
OPENAI_API_KEY=sk-...
DATABASE_URL=postgres://localhost/mydb
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

848
docs/GUIDE.md Normal file
View File

@ -0,0 +1,848 @@
# 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)
- [Syncing Code](#syncing-code)
- [Environment Variables](#environment-variables)
- [Versioning](#versioning)
- [Cron Jobs](#cron-jobs-1)
- [Metrics](#metrics)
- [Sharing](#sharing)
- [Configuration](#configuration)
- [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
toes new my-app
# Enter the directory, install deps, and develop locally
cd my-app
bun install
bun dev
# Push to the server
toes push
# Open in browser
toes open
```
Your app is now running at `http://my-app.toes.local`.
> **Tip:** Add `.toes` to your `.gitignore`. This file tracks local sync state and shouldn't be committed.
---
## 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/
.npmrc # Points to the private registry
.toesignore # Files to exclude from sync (like .gitignore)
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, 'current')
// Read files from appPath...
})
```
Always go through the `current` symlink — never access version directories directly.
**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
```
Creates the app locally, then pushes it to the server. 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>`** — Download an app from the server to your local machine.
```bash
toes get my-app # Creates ./my-app/ with all files
cd my-app
bun install
bun dev # Develop locally
```
**`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).
### Syncing Code
Toes uses a manifest-based sync protocol. Each file is tracked by SHA-256 hash. The server stores versioned snapshots with timestamps.
**`toes push`** — Push local changes to the server.
```bash
toes push # Push changes (fails if server changed)
toes push --force # Overwrite server changes
```
Creates a new version on the server, uploads changed files, deletes removed files, then activates the new version. The app auto-restarts.
**`toes pull`** — Pull changes from the server.
```bash
toes pull # Pull changes (fails if you have local changes)
toes pull --force # Overwrite local changes
```
**`toes status`** — Show what would be pushed or pulled.
```bash
toes status
# Changes to push:
# * index.tsx
# + new-file.ts
# - removed-file.ts
```
**`toes diff`** — Show a line-by-line diff of changed files.
**`toes sync`** — Watch for changes and sync bidirectionally in real-time. Useful during development when editing on the server.
**`toes clean`** — Remove local files that don't exist on the server.
```bash
toes clean # Interactive confirmation
toes clean --force # No confirmation
toes clean --dry-run # Show what would be removed
```
**`toes stash`** — Stash local changes (like `git stash`).
```bash
toes stash # Save local changes
toes stash pop # Restore stashed changes
toes stash list # List all stashes
```
### Environment Variables
**`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 push creates a timestamped version. The server keeps the last 5 versions.
**`toes versions [name]`** — List deployed versions.
```bash
toes versions my-app
# Versions for my-app:
#
# → 20260219-143022 2/19/2026, 2:30:22 PM (current)
# 20260218-091500 2/18/2026, 9:15:00 AM
# 20260217-160845 2/17/2026, 4:08:45 PM
```
**`toes history [name]`** — Show file changes between versions.
**`toes rollback [name]`** — Rollback to a previous version.
```bash
toes rollback my-app # Interactive version picker
toes rollback my-app -v 20260218-091500 # Rollback to specific version
```
### Cron Jobs
Cron 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.
### Configuration
**`toes config`** — Show the current server URL and sync state.
---
## 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,12 +34,20 @@ 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 ## accessing app files
Always go through the `current` symlink: Always go through the `current` symlink:
```ts ```ts
const APPS_DIR = process.env.APPS_DIR ?? '.' const APPS_DIR = process.env.APPS_DIR!
const appPath = join(APPS_DIR, appName, 'current') const appPath = join(APPS_DIR, appName, 'current')
``` ```

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

123
install/install.sh Normal file
View File

@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
##
# toes installer
# Usage: curl -sSL https://toes.dev/install | bash
# Must be run as the 'toes' user.
DEST=~/toes
REPO="https://git.nose.space/defunkt/toes"
quiet() { "$@" > /dev/null 2>&1; }
echo ""
echo " ╔══════════════════════════════════╗"
echo " ║ 🐾 toes - personal web appliance ║"
echo " ╚══════════════════════════════════╝"
echo ""
# Must be running as toes
if [ "$(whoami)" != "toes" ]; then
echo "ERROR: This script must be run as the 'toes' user."
echo "Create the user during Raspberry Pi OS setup."
exit 1
fi
# Must have passwordless sudo (can't prompt when piped from curl)
if ! sudo -n true 2>/dev/null; then
echo "ERROR: This script requires passwordless sudo."
echo "On Raspberry Pi OS, the default user has this already."
exit 1
fi
# -- System packages --
echo ">> Updating system packages"
quiet sudo apt-get update
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
echo ">> Setting fish as default shell"
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
quiet sudo chsh -s /usr/bin/fish toes
fi
# -- Bun --
BUN_REAL="$HOME/.bun/bin/bun"
BUN_SYMLINK="/usr/local/bin/bun"
if [ ! -x "$BUN_REAL" ]; then
echo ">> Installing bun"
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
if [ ! -x "$BUN_REAL" ]; then
echo "ERROR: bun installation failed"
exit 1
fi
fi
if [ ! -x "$BUN_SYMLINK" ]; then
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
fi
echo ">> Setting CAP_NET_BIND_SERVICE on bun"
sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
# -- Clone --
if [ ! -d "$DEST" ]; then
echo ">> Cloning toes"
git clone "$REPO" "$DEST"
else
echo ">> Updating toes"
cd "$DEST" && git pull origin main
fi
# -- Directories --
mkdir -p ~/data ~/apps
# -- Dependencies & build --
echo ">> Installing dependencies"
cd "$DEST" && bun install
echo ">> Building client"
cd "$DEST" && bun run build
# -- Bundled apps --
echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do
if [ -d "$DEST/apps/$app" ]; then
if [ -d ~/apps/"$app" ]; then
echo " $app (exists, skipping)"
continue
fi
echo " $app"
cp -r "$DEST/apps/$app" ~/apps/
version_dir=$(ls -1 ~/apps/"$app" | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
if [ -n "$version_dir" ]; then
ln -sfn "$version_dir" ~/apps/"$app"/current
(cd ~/apps/"$app"/current && bun install --frozen-lockfile) > /dev/null 2>&1 || true
fi
fi
done
# -- Systemd --
echo ">> Installing toes service"
sudo install -m 644 -o root -g root "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
sudo systemctl daemon-reload
sudo systemctl enable toes
echo ">> Starting toes"
sudo systemctl restart toes
# -- Done --
echo ""
echo " toes is installed and running!"
echo " Visit: http://$(hostname).local"
echo ""

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

43
install/tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"exclude": ["apps", "templates"],
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": [
"./src/server/*"
],
"@*": [
"./src/shared/*"
],
"%*": [
"./src/lib/*"
]
}
}
}

View File

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

View File

@ -1,4 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds the client JS bundle
set -euo pipefail set -euo pipefail
echo ">> Building client bundle" echo ">> Building client bundle"

View File

@ -1,4 +1,5 @@
#!/usr/bin/env bun #!/usr/bin/env bun
// Builds the self-contained CLI executable
// Usage: bun scripts/build.ts [--all | --target=<name>] // Usage: bun scripts/build.ts [--all | --target=<name>]
// No flags: builds for current platform (dist/toes) // No flags: builds for current platform (dist/toes)
// --all: builds for all targets (macos-arm64, macos-x64, linux-arm64, linux-x64) // --all: builds for all targets (macos-arm64, macos-x64, linux-arm64, linux-x64)
@ -43,7 +44,7 @@ async function buildTarget(target: BuildTarget) {
ENTRY_POINT, ENTRY_POINT,
'--compile', '--compile',
'--target', '--target',
'bun', `bun-${target.os}-${target.arch}`,
'--minify', '--minify',
'--sourcemap=external', '--sourcemap=external',
'--outfile', '--outfile',
@ -51,11 +52,6 @@ async function buildTarget(target: BuildTarget) {
], { ], {
stdout: 'inherit', stdout: 'inherit',
stderr: 'inherit', stderr: 'inherit',
env: {
...process.env,
BUN_TARGET_OS: target.os,
BUN_TARGET_ARCH: target.arch,
},
}) })
const exitCode = await proc.exited const exitCode = await proc.exited

View File

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

View File

@ -8,16 +8,35 @@ ROOT_DIR="$SCRIPT_DIR/.."
# Load config # Load config
source "$ROOT_DIR/scripts/config.sh" 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 git push origin main
# SSH to target and update # SSH to target: pull, build, sync apps, restart
ssh "$HOST" "cd $DEST && git pull origin main && bun run build && sudo systemctl restart toes.service" ssh "$SSH_HOST" bash <<'SCRIPT'
set -e
echo "=> Deployed to $HOST" DEST="${DEST:-$HOME/toes}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
echo "=> Syncing default apps..."
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
for version_dir in "$app_dir"*/; do
[ -d "$version_dir" ] || continue
version=$(basename "$version_dir")
[ -f "$version_dir/package.json" ] || continue
target="$APPS_DIR/$app/$version"
mkdir -p "$target"
cp -a "$version_dir"/. "$target"/
rm -f "$APPS_DIR/$app/current"
echo " $app/$version"
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
done
done
sudo systemctl restart toes.service
SCRIPT
echo "=> Deployed to $SSH_HOST"
echo "=> Visit $URL" echo "=> Visit $URL"

View File

@ -19,6 +19,15 @@ echo ">> Updating system libraries"
quiet sudo apt-get update quiet sudo apt-get update
quiet sudo apt-get install -y libcap2-bin quiet sudo apt-get install -y libcap2-bin
quiet sudo apt-get install -y avahi-utils 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" echo ">> Ensuring bun is available in /usr/local/bin"
if [ ! -x "$BUN_SYMLINK" ]; then if [ ! -x "$BUN_SYMLINK" ]; then
@ -28,7 +37,11 @@ if [ ! -x "$BUN_SYMLINK" ]; then
else else
echo ">> Installing bun at $BUN_REAL" echo ">> Installing bun at $BUN_REAL"
quiet sudo apt install unzip quiet sudo apt install unzip
quiet curl -fsSL https://bun.sh/install | bash 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" quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK" echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
fi fi
@ -40,9 +53,30 @@ echo ">> Setting CAP_NET_BIND_SERVICE on $BUN_REAL"
quiet sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL" quiet sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
quiet /usr/sbin/getcap "$BUN_REAL" || true quiet /usr/sbin/getcap "$BUN_REAL" || true
echo ">> Creating apps directory" echo ">> Creating data and apps directories"
mkdir -p ~/data
mkdir -p ~/apps 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" echo ">> Building client bundle"
bun run build bun run build
@ -59,13 +93,32 @@ echo ">> Starting (or restarting) $SERVICE_NAME"
quiet sudo systemctl restart "$SERVICE_NAME" quiet sudo systemctl restart "$SERVICE_NAME"
echo ">> Enabling kiosk mode" echo ">> Enabling kiosk mode"
quiet mkdir -p ~/.config/labwc sudo raspi-config nonint do_boot_behaviour B4
# labwc (older RPi OS / manual installs)
mkdir -p ~/.config/labwc
cat > ~/.config/labwc/autostart <<'EOF' cat > ~/.config/labwc/autostart <<'EOF'
chromium-browser --noerrdialogs --disable-infobars --kiosk http://localhost chromium --noerrdialogs --disable-infobars --kiosk http://localhost
EOF 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..." echo ">> Done! Rebooting in 5 seconds..."
quiet systemctl status "$SERVICE_NAME" --no-pager -l quiet systemctl status "$SERVICE_NAME" --no-pager -l || true
sleep 5 sleep 5
quiet sudo nohup reboot >/dev/null 2>&1 & quiet sudo nohup reboot >/dev/null 2>&1 &
exit 0 exit 0

View File

@ -9,4 +9,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
# Run remote install on the target # Run remote install on the target
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh" ssh "$SSH_HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"

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" source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "sudo systemctl restart toes.service" ssh "$SSH_HOST" "sudo systemctl restart toes.service"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "sudo systemctl start toes.service" ssh "$SSH_HOST" "sudo systemctl start toes.service"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "sudo systemctl stop toes.service" ssh "$SSH_HOST" "sudo systemctl stop toes.service"

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

@ -5,9 +5,10 @@ Wants=network-online.target
[Service] [Service]
User=toes User=toes
WorkingDirectory=/home/toes/.toes/ WorkingDirectory=/home/toes/toes/
Environment=PORT=80 Environment=PORT=80
Environment=NODE_ENV=production Environment=NODE_ENV=production
Environment=DATA_DIR=/home/toes/data
Environment=APPS_DIR=/home/toes/apps/ Environment=APPS_DIR=/home/toes/apps/
ExecStart=/home/toes/.bun/bin/bun start ExecStart=/home/toes/.bun/bin/bun start
Restart=always 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)
}
}

153
src/cli/commands/env.ts Normal file
View File

@ -0,0 +1,153 @@
import color from 'kleur'
import { del, get, handleError, post } from '../http'
import { resolveAppName } from '../name'
interface EnvVar {
key: string
value: string
}
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, globalVars] = await Promise.all([
get<EnvVar[]>(`/api/apps/${appName}/env`),
get<EnvVar[]>('/api/env'),
])
if (!vars) {
console.error(`App not found: ${appName}`)
return
}
console.log(color.bold().cyan(`Environment Variables for ${appName}`))
console.log()
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
}
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 | 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)
}
const parsed = parseKeyValue(keyOrKeyValue, valueArg)
if (!parsed) return
const { key, value } = parsed
const appName = resolveAppName(name)
if (!appName) return
try {
const result = await post<{ ok: boolean, error?: string }>(`/api/apps/${appName}/env`, { key, value })
if (result?.ok) {
console.log(color.green(`Set ${color.bold(key.toUpperCase())} for ${appName}`))
console.log(color.gray('App restarted to apply changes'))
} else {
console.error(result?.error ?? 'Failed to set variable')
}
} catch (error) {
handleError(error)
}
}
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
}
const appName = resolveAppName(name)
if (!appName) return
const ok = await del(`/api/apps/${appName}/env/${key.toUpperCase()}`)
if (ok) {
console.log(color.green(`Removed ${color.bold(key.toUpperCase())} from ${appName}`))
console.log(color.gray('App restarted to apply changes'))
}
}

View File

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

View File

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

View File

@ -1,44 +1,47 @@
import type { App } from '@types' import type { App } from '@types'
import { generateTemplates, type TemplateType } from '%templates' import { generateTemplates, type TemplateType } from '%templates'
import { readSyncState } from '%sync'
import color from 'kleur' import color from 'kleur'
import { existsSync, mkdirSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { basename, join } from 'path' import { basename, join } from 'path'
import { buildAppUrl } from '@urls'
import { del, get, getManifest, HOST, post } from '../http' import { del, get, getManifest, HOST, post } from '../http'
import { confirm, prompt } from '../prompts' import { confirm, prompt } from '../prompts'
import { resolveAppName } from '../name' import { resolveAppName } from '../name'
import { pushApp } from './sync' import { pushApp } from './sync'
export const STATE_ICONS: Record<string, string> = { export const STATE_ICONS: Record<string, string> = {
error: color.red('●'),
running: color.green('●'), running: color.green('●'),
starting: color.yellow('◎'), starting: color.yellow('◎'),
stopped: color.gray('◯'), stopped: color.gray('◯'),
invalid: color.red('◌'), invalid: color.red('◌'),
} }
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
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 configShow() { export async function configShow() {
console.log(`Host: ${color.bold(HOST)}`) console.log(`Host: ${color.bold(HOST)}`)
const source = process.env.TOES_URL const syncState = readSyncState(process.cwd())
? 'TOES_URL' if (syncState) {
: process.env.TOES_HOST console.log(`Version: ${color.bold(syncState.version)}`)
? 'TOES_HOST' + (process.env.PORT ? ' + PORT' : '')
: process.env.NODE_ENV === 'production'
? 'default (production)'
: 'default (development)'
console.log(`Source: ${color.gray(source)}`)
if (process.env.TOES_URL) {
console.log(` TOES_URL=${process.env.TOES_URL}`)
}
if (process.env.TOES_HOST) {
console.log(` TOES_HOST=${process.env.TOES_HOST}`)
}
if (process.env.PORT) {
console.log(` PORT=${process.env.PORT}`)
}
if (process.env.NODE_ENV) {
console.log(` NODE_ENV=${process.env.NODE_ENV}`)
} }
} }
@ -55,9 +58,17 @@ export async function infoApp(arg?: string) {
const icon = STATE_ICONS[app.state] ?? '◯' const icon = STATE_ICONS[app.state] ?? '◯'
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`) console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
console.log(` State: ${app.state}`) console.log(` State: ${app.state}`)
if (app.state === 'running') {
console.log(` URL: ${buildAppUrl(app.name, HOST)}`)
}
if (app.port) { if (app.port) {
console.log(` Port: ${app.port}`) console.log(` Port: ${app.port}`)
console.log(` URL: http://localhost:${app.port}`) }
if (app.tunnelUrl) {
console.log(` Tunnel: ${app.tunnelUrl}`)
}
if (app.pid) {
console.log(` PID: ${app.pid}`)
} }
if (app.started) { if (app.started) {
const uptime = Date.now() - app.started const uptime = Date.now() - app.started
@ -74,15 +85,24 @@ export async function infoApp(arg?: string) {
} }
interface ListAppsOptions { interface ListAppsOptions {
apps?: boolean
tools?: boolean tools?: boolean
all?: boolean
} }
export async function listApps(options: ListAppsOptions) { export async function listApps(options: ListAppsOptions) {
const allApps: App[] | undefined = await get('/api/apps') const allApps: App[] | undefined = await get('/api/apps')
if (!allApps) return if (!allApps) return
if (options.all) { if (options.apps || options.tools) {
const filtered = allApps.filter((app) => {
if (options.tools) return app.tool
return !app.tool
})
for (const app of filtered) {
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
}
} else {
const apps = allApps.filter((app) => !app.tool) const apps = allApps.filter((app) => !app.tool)
const tools = allApps.filter((app) => app.tool) const tools = allApps.filter((app) => app.tool)
@ -104,15 +124,6 @@ export async function listApps(options: ListAppsOptions) {
console.log(` ${STATE_ICONS[tool.state] ?? '◯'} ${tool.name}`) console.log(` ${STATE_ICONS[tool.state] ?? '◯'} ${tool.name}`)
} }
} }
} else {
const filtered = allApps.filter((app) => {
if (options.tools) return app.tool
return !app.tool
})
for (const app of filtered) {
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
}
} }
} }
@ -131,12 +142,26 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
if (options.bare) template = 'bare' if (options.bare) template = 'bare'
else if (options.spa) template = 'spa' 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)) { if (name && existsSync(appPath)) {
console.error(`Directory already exists: ${name}`) console.error(`Directory already exists: ${name}`)
return return
} }
const filesToCheck = ['index.tsx', 'package.json', 'tsconfig.json'] const filesToCheck = ['index.tsx', 'tsconfig.json']
const existing = filesToCheck.filter((f) => existsSync(join(appPath, f))) const existing = filesToCheck.filter((f) => existsSync(join(appPath, f)))
if (existing.length > 0) { if (existing.length > 0) {
console.error(`Files already exist: ${existing.join(', ')}`) console.error(`Files already exist: ${existing.join(', ')}`)
@ -185,11 +210,34 @@ export async function openApp(arg?: string) {
console.error(`App is not running: ${name}`) console.error(`App is not running: ${name}`)
return return
} }
const url = `http://localhost:${app.port}` const url = buildAppUrl(app.name, HOST)
console.log(`Opening ${url}`) console.log(`Opening ${url}`)
Bun.spawn(['open', 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) { export async function renameApp(arg: string | undefined, newName: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return if (!name) return
@ -224,7 +272,15 @@ export async function renameApp(arg: string | undefined, newName: string) {
export async function restartApp(arg?: string) { export async function restartApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return 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) { export async function rmApp(arg?: string) {
@ -256,11 +312,36 @@ export async function rmApp(arg?: string) {
export async function startApp(arg?: string) { export async function startApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return 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) { export async function stopApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return 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')}`)
}
} }

115
src/cli/commands/metrics.ts Normal file
View File

@ -0,0 +1,115 @@
import color from 'kleur'
import { get } from '../http'
import { resolveAppName } from '../name'
interface AppMetrics {
name: string
state: string
port?: number
pid?: number
cpu?: number
memory?: number
rss?: number
dataSize?: number
tool?: boolean
}
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 formatRss(kb?: number): string {
if (kb === undefined) return '-'
if (kb < 1024) return `${kb} KB`
if (kb < 1024 * 1024) return `${(kb / 1024).toFixed(1)} MB`
return `${(kb / 1024 / 1024).toFixed(2)} GB`
}
function formatPercent(value?: number): string {
if (value === undefined) return '-'
return `${value.toFixed(1)}%`
}
function pad(str: string, len: number, right = false): string {
if (right) return str.padStart(len)
return str.padEnd(len)
}
export async function metricsApp(arg?: string) {
// If arg is provided, show metrics for that app only
if (arg) {
const name = resolveAppName(arg)
if (!name) return
const metrics: AppMetrics | undefined = await get(`/api/tools/metrics/api/metrics/${name}`)
if (!metrics) {
console.error(`App not found: ${name}`)
return
}
console.log(`${color.bold(metrics.name)} ${metrics.tool ? color.gray('[tool]') : ''}`)
console.log(` State: ${metrics.state}`)
if (metrics.pid) console.log(` PID: ${metrics.pid}`)
if (metrics.port) console.log(` Port: ${metrics.port}`)
if (metrics.cpu !== undefined) console.log(` CPU: ${formatPercent(metrics.cpu)}`)
if (metrics.memory !== undefined) console.log(` Memory: ${formatPercent(metrics.memory)}`)
if (metrics.rss !== undefined) console.log(` RSS: ${formatRss(metrics.rss)}`)
console.log(` Data: ${formatBytes(metrics.dataSize)}`)
return
}
// Show metrics for all apps
const metrics: AppMetrics[] | undefined = await get('/api/tools/metrics/api/metrics')
if (!metrics || metrics.length === 0) {
console.log('No apps found')
return
}
// Sort: running first, then by name
metrics.sort((a, b) => {
if (a.state === 'running' && b.state !== 'running') return -1
if (a.state !== 'running' && b.state === 'running') return 1
return a.name.localeCompare(b.name)
})
// Calculate column widths
const nameWidth = Math.max(4, ...metrics.map(s => s.name.length + (s.tool ? 7 : 0)))
const stateWidth = Math.max(5, ...metrics.map(s => s.state.length))
// Header
console.log(
color.gray(
`${pad('NAME', nameWidth)} ${pad('STATE', stateWidth)} ${pad('PID', 7, true)} ${pad('CPU', 7, true)} ${pad('MEM', 7, true)} ${pad('RSS', 10, true)} ${pad('DATA', 10, true)}`
)
)
// Rows
for (const s of metrics) {
const name = s.tool ? `${s.name} ${color.gray('[tool]')}` : s.name
const stateColor = s.state === 'running' ? color.green : s.state === 'invalid' ? color.red : color.gray
const state = stateColor(s.state)
const pid = s.pid ? String(s.pid) : '-'
const cpu = formatPercent(s.cpu)
const mem = formatPercent(s.memory)
const rss = formatRss(s.rss)
const data = formatBytes(s.dataSize)
console.log(
`${pad(name, nameWidth)} ${pad(state, stateWidth)} ${pad(pid, 7, true)} ${pad(cpu, 7, true)} ${pad(mem, 7, true)} ${pad(rss, 10, true)} ${pad(data, 10, true)}`
)
}
// Summary
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)
console.log()
console.log(color.gray(`${running.length} running, ${formatPercent(totalCpu)} CPU, ${formatRss(totalRss)} RSS, ${formatBytes(totalData)} data`))
}

View File

@ -1,27 +1,98 @@
import type { Manifest } from '@types' import type { Manifest } from '@types'
import { loadGitignore } from '@gitignore' import { loadGitignore } from '@gitignore'
import { computeHash, generateManifest } from '%sync' import { generateManifest, readSyncState, writeSyncState } from '%sync'
import color from 'kleur' import color from 'kleur'
import { diffLines } from 'diff' import { diffLines } from 'diff'
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from 'fs' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http' import { del, download, get, getManifest, getSignal, handleError, makeUrl, post, put } from '../http'
import { confirm, prompt } from '../prompts' import { confirm, prompt } from '../prompts'
import { getAppName, isApp, resolveAppName } from '../name' import { getAppName, getAppPackage, isApp, resolveAppName } from '../name'
const s = (n: number) => n === 1 ? '' : 's'
function notAppError(): string {
const pkg = getAppPackage()
if (!pkg) return 'No package.json found. Use `toes get <app>` to grab one.'
if (!pkg.scripts?.toes) return 'Missing scripts.toes in package.json. Use `toes new` to add it.'
return 'Not a toes app'
}
interface Rename {
from: string
to: string
}
interface ManifestDiff { interface ManifestDiff {
changed: string[] changed: string[]
localOnly: string[] localOnly: string[]
remoteOnly: string[] remoteOnly: string[]
renamed: Rename[]
localManifest: Manifest localManifest: Manifest
remoteManifest: Manifest | null remoteManifest: Manifest | null
remoteVersion: string | null
serverChanged: boolean
}
export async function historyApp(name?: string) {
const appName = resolveAppName(name)
if (!appName) return
type HistoryEntry = {
version: string
current: boolean
added: string[]
modified: string[]
deleted: string[]
renamed: string[]
}
type HistoryResponse = { history: HistoryEntry[] }
const result = await get<HistoryResponse>(`/api/sync/apps/${appName}/history`)
if (!result) return
if (result.history.length === 0) {
console.log(`No versions found for ${color.bold(appName)}`)
return
}
console.log(`History for ${color.bold(appName)}:\n`)
for (const entry of result.history) {
const date = formatVersion(entry.version)
const label = entry.current ? ` ${color.green('→')} ${color.bold(entry.version)}` : ` ${entry.version}`
const suffix = entry.current ? ` ${color.green('(current)')}` : ''
console.log(`${label} ${color.gray(date)}${suffix}`)
const renamed = entry.renamed ?? []
const hasChanges = entry.added.length > 0 || entry.modified.length > 0 || entry.deleted.length > 0 || renamed.length > 0
if (!hasChanges) {
console.log(color.gray(' No changes'))
}
for (const rename of renamed) {
console.log(` ${color.cyan('→')} ${rename}`)
}
for (const file of entry.added) {
console.log(` ${color.green('+')} ${file}`)
}
for (const file of entry.modified) {
console.log(` ${color.magenta('*')} ${file}`)
}
for (const file of entry.deleted) {
console.log(` ${color.red('-')} ${file}`)
}
console.log()
}
} }
export async function getApp(name: string) { export async function getApp(name: string) {
console.log(`Fetching ${color.bold(name)} from server...`) console.log(`Fetching ${color.bold(name)} from server...`)
const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`) const result = await getManifest(name)
if (!manifest) { if (!result || !result.exists || !result.manifest) {
console.error(`App not found: ${name}`) console.error(`App not found: ${name}`)
return return
} }
@ -34,8 +105,8 @@ export async function getApp(name: string) {
mkdirSync(appPath, { recursive: true }) mkdirSync(appPath, { recursive: true })
const files = Object.keys(manifest.files) const files = Object.keys(result.manifest.files)
console.log(`Downloading ${files.length} files...`) console.log(`Downloading ${files.length} file${s(files.length)}...`)
for (const file of files) { for (const file of files) {
const content = await download(`/api/sync/apps/${name}/files/${file}`) const content = await download(`/api/sync/apps/${name}/files/${file}`)
@ -54,47 +125,70 @@ export async function getApp(name: string) {
writeFileSync(fullPath, content) writeFileSync(fullPath, content)
} }
if (result.version) {
writeSyncState(appPath, { version: result.version })
}
console.log(color.green(`✓ Downloaded ${name}`)) console.log(color.green(`✓ Downloaded ${name}`))
} }
export async function pushApp() { export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}) {
if (!isApp()) { if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.') console.error(notAppError())
return return
} }
const appName = getAppName() const appName = getAppName()
const diff = await getManifestDiff(appName)
const localManifest = generateManifest(process.cwd(), appName) if (diff === null) {
const result = await getManifest(appName)
if (result === null) {
// Connection error - already printed
return return
} }
if (!result.exists) { const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff
if (!remoteManifest) {
const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`) const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`)
if (!ok) return if (!ok) return
} }
const localFiles = new Set(Object.keys(localManifest.files)) // If server changed, abort unless --force (skip for new apps)
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {})) if (remoteManifest && serverChanged && !options.force) {
console.error('Cannot push: server has changed since last sync')
console.error('\nRun `toes pull` first, or `toes push --force` to overwrite')
return
}
// Files to upload (new or changed) // Files to upload: changed + localOnly
const toUpload: string[] = [] const toUpload = [...changed, ...localOnly]
for (const file of localFiles) { // Files to delete on server: remoteOnly (local deletions when version matches, or forced)
const local = localManifest.files[file]! const toDelete = !serverChanged || options.force ? [...remoteOnly] : []
const remote = result.manifest?.files[file]
if (!remote || local.hash !== remote.hash) { // Detect renames among upload/delete pairs (same hash, different path)
toUpload.push(file) const renames: Rename[] = [...renamed]
const remoteByHash = new Map<string, string>()
if (remoteManifest) {
for (const file of toDelete) {
const info = remoteManifest.files[file]
if (info) remoteByHash.set(info.hash, file)
} }
} }
// Note: We don't delete files in versioned deployments - new version is separate directory const renamedUploads = new Set<string>()
const renamedDeletes = new Set<string>()
for (const file of toUpload) {
const hash = localManifest.files[file]?.hash
if (!hash) continue
const remoteFile = remoteByHash.get(hash)
if (remoteFile && !renamedDeletes.has(remoteFile)) {
renames.push({ from: remoteFile, to: file })
renamedUploads.add(file)
renamedDeletes.add(remoteFile)
}
}
if (toUpload.length === 0) { if (toUpload.length === 0 && toDelete.length === 0) {
console.log('Already up to date') if (!options.quiet) console.log('Already up to date')
return return
} }
@ -112,11 +206,28 @@ export async function pushApp() {
console.log(`Deploying version ${color.bold(version)}...`) console.log(`Deploying version ${color.bold(version)}...`)
// 2. Upload changed files to new version // 2. Upload changed files to new version
if (toUpload.length > 0) { const actualUploads = toUpload.filter(f => !renamedUploads.has(f))
console.log(`Uploading ${toUpload.length} files...`) const actualDeletes = toDelete.filter(f => !renamedDeletes.has(f))
if (renames.length > 0) {
console.log(`Renaming ${renames.length} file${s(renames.length)}...`)
for (const { from, to } of renames) {
const content = readFileSync(join(process.cwd(), to))
const uploadOk = await put(`/api/sync/apps/${appName}/files/${to}?version=${version}`, content)
const deleteOk = await del(`/api/sync/apps/${appName}/files/${from}?version=${version}`)
if (uploadOk && deleteOk) {
console.log(` ${color.cyan('→')} ${from}${to}`)
} else {
console.log(` ${color.red('✗')} ${from}${to} (failed)`)
}
}
}
if (actualUploads.length > 0) {
console.log(`Uploading ${actualUploads.length} file${s(actualUploads.length)}...`)
let failedUploads = 0 let failedUploads = 0
for (const file of toUpload) { for (const file of actualUploads) {
const content = readFileSync(join(process.cwd(), file)) const content = readFileSync(join(process.cwd(), file))
const success = await put(`/api/sync/apps/${appName}/files/${file}?version=${version}`, content) const success = await put(`/api/sync/apps/${appName}/files/${file}?version=${version}`, content)
if (success) { if (success) {
@ -128,13 +239,26 @@ export async function pushApp() {
} }
if (failedUploads > 0) { if (failedUploads > 0) {
console.error(`Failed to upload ${failedUploads} file(s). Deployment aborted.`) console.error(`Failed to upload ${failedUploads} file${s(failedUploads)}. Deployment aborted.`)
console.error(`Incomplete version ${version} left on server (not activated).`) console.error(`Incomplete version ${version} left on server (not activated).`)
return return
} }
} }
// 3. Activate new version (updates symlink and restarts app) // 3. Delete files that no longer exist locally
if (actualDeletes.length > 0) {
console.log(`Deleting ${actualDeletes.length} file${s(actualDeletes.length)}...`)
for (const file of actualDeletes) {
const success = await del(`/api/sync/apps/${appName}/files/${file}?version=${version}`)
if (success) {
console.log(` ${color.red('-')} ${file}`)
} else {
console.log(` ${color.red('✗')} ${file} (failed)`)
}
}
}
// 4. Activate new version (updates symlink and restarts app)
type ActivateResponse = { ok: boolean } type ActivateResponse = { ok: boolean }
const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${version}`) const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${version}`)
if (!activateRes?.ok) { if (!activateRes?.ok) {
@ -142,12 +266,15 @@ export async function pushApp() {
return return
} }
// 5. Write sync version after successful push
writeSyncState(process.cwd(), { version })
console.log(color.green(`✓ Deployed and activated version ${version}`)) console.log(color.green(`✓ Deployed and activated version ${version}`))
} }
export async function pullApp(options: { force?: boolean } = {}) { export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}) {
if (!isApp()) { if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.') console.error(notAppError())
return return
} }
@ -158,37 +285,57 @@ export async function pullApp(options: { force?: boolean } = {}) {
return return
} }
const { changed, localOnly, remoteOnly, remoteManifest } = diff const { changed, localOnly, remoteOnly, remoteManifest, remoteVersion, serverChanged } = diff
if (!remoteManifest) { if (!remoteManifest) {
console.error('App not found on server') console.error('App not found on server')
return return
} }
// Check for local changes that would be overwritten if (!serverChanged) {
const wouldOverwrite = changed.length > 0 || localOnly.length > 0 // Server hasn't changed — all diffs are local, nothing to pull
if (wouldOverwrite && !options.force) { if (!options.quiet) {
if (changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0) {
console.log('Server is up to date. You have local changes — use `toes push`.')
} else {
console.log('Already up to date')
}
}
return
}
// Server changed — download diffs from remote
const hasDiffs = changed.length > 0 || localOnly.length > 0
if (hasDiffs && !options.force) {
console.error('Cannot pull: you have local changes that would be overwritten') console.error('Cannot pull: you have local changes that would be overwritten')
console.error(' Use `toes status` and `toes diff` to see differences') for (const file of changed) {
console.error(' Use `toes pull --force` to overwrite local changes') console.error(` ${color.magenta('*')} ${file}`)
}
for (const file of localOnly) {
console.error(` ${color.green('+')} ${file} (local only)`)
}
console.error('\nUse `toes pull --force` to overwrite local changes')
return return
} }
// Files to download: changed + remoteOnly // Files to download: changed + remoteOnly
const toDownload = [...changed, ...remoteOnly] const toDownload = [...changed, ...remoteOnly]
// Files to delete locally: only when forcing
// Files to delete: localOnly const toDelete = options.force ? localOnly : []
const toDelete = localOnly
if (toDownload.length === 0 && toDelete.length === 0) { if (toDownload.length === 0 && toDelete.length === 0) {
console.log('Already up to date') // Server version changed but files are identical — just update stored version
if (remoteVersion) {
writeSyncState(process.cwd(), { version: remoteVersion })
}
if (!options.quiet) console.log('Already up to date')
return return
} }
console.log(`Pulling ${color.bold(appName)} from server...`) console.log(`Pulling ${color.bold(appName)} from server...`)
if (toDownload.length > 0) { if (toDownload.length > 0) {
console.log(`Downloading ${toDownload.length} files...`) console.log(`Downloading ${toDownload.length} file${s(toDownload.length)}...`)
for (const file of toDownload) { for (const file of toDownload) {
const content = await download(`/api/sync/apps/${appName}/files/${file}`) const content = await download(`/api/sync/apps/${appName}/files/${file}`)
if (!content) { if (!content) {
@ -209,20 +356,26 @@ export async function pullApp(options: { force?: boolean } = {}) {
} }
if (toDelete.length > 0) { if (toDelete.length > 0) {
console.log(`Deleting ${toDelete.length} local files...`) console.log(`Deleting ${toDelete.length} local file${s(toDelete.length)}...`)
for (const file of toDelete) { for (const file of toDelete) {
const fullPath = join(process.cwd(), file) const fullPath = join(process.cwd(), file)
unlinkSync(fullPath) if (existsSync(fullPath)) {
console.log(` ${color.red('✗')} ${file}`) unlinkSync(fullPath)
console.log(` ${color.red('-')} ${file}`)
}
} }
} }
if (remoteVersion) {
writeSyncState(process.cwd(), { version: remoteVersion })
}
console.log(color.green('✓ Pull complete')) console.log(color.green('✓ Pull complete'))
} }
export async function diffApp() { export async function diffApp() {
if (!isApp()) { if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.') console.error(notAppError())
return return
} }
@ -233,21 +386,36 @@ export async function diffApp() {
return return
} }
const { changed, localOnly, remoteOnly } = diff const { changed, localOnly, remoteOnly, renamed } = diff
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) { if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0 && renamed.length === 0) {
// console.log(color.green('✓ No differences'))
return return
} }
// Fetch all changed files in parallel // Show renames
for (const { from, to } of renamed) {
console.log(color.cyan('\nRenamed'))
console.log(color.bold(`${from}${to}`))
console.log(color.gray('─'.repeat(60)))
}
// Fetch all changed files in parallel (skip binary files)
const remoteContents = await Promise.all( const remoteContents = await Promise.all(
changed.map(file => download(`/api/sync/apps/${appName}/files/${file}`)) changed.map(file => isBinary(file) ? null : download(`/api/sync/apps/${appName}/files/${file}`))
) )
// Show diffs for changed files // Show diffs for changed files
for (let i = 0; i < changed.length; i++) { for (let i = 0; i < changed.length; i++) {
const file = changed[i]! const file = changed[i]!
console.log(color.bold(`\n${file}`))
console.log(color.gray('─'.repeat(60)))
if (isBinary(file)) {
console.log(color.gray('Binary file changed'))
continue
}
const remoteContent = remoteContents[i] const remoteContent = remoteContents[i]
const localContent = readFileSync(join(process.cwd(), file), 'utf-8') const localContent = readFileSync(join(process.cwd(), file), 'utf-8')
@ -257,9 +425,6 @@ export async function diffApp() {
} }
const remoteText = new TextDecoder().decode(remoteContent) const remoteText = new TextDecoder().decode(remoteContent)
console.log(color.bold(`\n${file}`))
console.log(color.gray('─'.repeat(60)))
showDiff(remoteText, localContent) showDiff(remoteText, localContent)
} }
@ -268,6 +433,12 @@ export async function diffApp() {
console.log(color.green('\nNew file (local only)')) console.log(color.green('\nNew file (local only)'))
console.log(color.bold(`${file}`)) console.log(color.bold(`${file}`))
console.log(color.gray('─'.repeat(60))) console.log(color.gray('─'.repeat(60)))
if (isBinary(file)) {
console.log(color.gray('Binary file'))
continue
}
const content = readFileSync(join(process.cwd(), file), 'utf-8') const content = readFileSync(join(process.cwd(), file), 'utf-8')
const lines = content.split('\n') const lines = content.split('\n')
for (let i = 0; i < Math.min(lines.length, 10); i++) { for (let i = 0; i < Math.min(lines.length, 10); i++) {
@ -278,9 +449,9 @@ export async function diffApp() {
} }
} }
// Fetch all remote-only files in parallel // Fetch all remote-only files in parallel (skip binary files)
const remoteOnlyContents = await Promise.all( const remoteOnlyContents = await Promise.all(
remoteOnly.map(file => download(`/api/sync/apps/${appName}/files/${file}`)) remoteOnly.map(file => isBinary(file) ? null : download(`/api/sync/apps/${appName}/files/${file}`))
) )
// Show remote-only files // Show remote-only files
@ -290,7 +461,13 @@ export async function diffApp() {
console.log(color.bold(`\n${file}`)) console.log(color.bold(`\n${file}`))
console.log(color.gray('─'.repeat(60))) console.log(color.gray('─'.repeat(60)))
console.log(color.red('Remote only (would be deleted on push)')) console.log(color.red('Remote only'))
if (isBinary(file)) {
console.log(color.gray('Binary file'))
continue
}
if (content) { if (content) {
const text = new TextDecoder().decode(content) const text = new TextDecoder().decode(content)
const lines = text.split('\n') const lines = text.split('\n')
@ -309,7 +486,7 @@ export async function diffApp() {
export async function statusApp() { export async function statusApp() {
if (!isApp()) { if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.') console.error(notAppError())
return return
} }
@ -320,95 +497,102 @@ export async function statusApp() {
return return
} }
const { changed, localOnly, remoteOnly, localManifest, remoteManifest } = diff const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff
// toPush = changed + localOnly (new or modified locally)
const toPush = [...changed, ...localOnly]
// Local changes block pull
const hasLocalChanges = toPush.length > 0
// Display status
// console.log(`Status for ${color.bold(appName)}:\n`)
if (!remoteManifest) { if (!remoteManifest) {
console.log(color.yellow('App does not exist on server')) console.log(color.yellow('App does not exist on server'))
const localFileCount = Object.keys(localManifest.files).length const localFileCount = Object.keys(localManifest.files).length
console.log(`\nWould create new app with ${localFileCount} files on push\n`) console.log(`\nWould create new app with ${localFileCount} file${s(localFileCount)} on push\n`)
return return
} }
// Push status const hasDiffs = changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0 || renamed.length > 0
if (toPush.length > 0 || remoteOnly.length > 0) {
console.log(color.bold('Changes to push:'))
for (const file of toPush) {
console.log(` ${color.green('↑')} ${file}`)
}
for (const file of remoteOnly) {
console.log(` ${color.red('✗')} ${file}`)
}
console.log()
}
// Pull status (only show if no local changes blocking) if (!hasDiffs) {
if (!hasLocalChanges && remoteOnly.length > 0) { if (serverChanged) {
console.log(color.bold('Changes to pull:')) // Files identical but version changed — update stored version silently
for (const file of remoteOnly) { const { remoteVersion } = diff
console.log(` ${color.green('↓')} ${file}`) if (remoteVersion) {
writeSyncState(process.cwd(), { version: remoteVersion })
}
} }
console.log()
}
// Summary
if (toPush.length === 0 && remoteOnly.length === 0) {
console.log(color.green('✓ In sync with server')) console.log(color.green('✓ In sync with server'))
return
}
if (!serverChanged) {
// Server hasn't moved — all diffs are local changes to push
console.log(color.bold('Changes to push:'))
for (const { from, to } of renamed) {
console.log(` ${color.cyan('→')} ${from}${to}`)
}
for (const file of changed) {
console.log(` ${color.magenta('*')} ${file}`)
}
for (const file of localOnly) {
console.log(` ${color.green('+')} ${file}`)
}
for (const file of remoteOnly) {
console.log(` ${color.red('-')} ${file}`)
}
console.log()
} else {
// Server changed — show diffs neutrally
console.log(color.yellow('Server has changed since last sync\n'))
console.log(color.bold('Differences:'))
for (const { from, to } of renamed) {
console.log(` ${color.cyan('→')} ${from}${to}`)
}
for (const file of changed) {
console.log(` ${color.magenta('*')} ${file}`)
}
for (const file of localOnly) {
console.log(` ${color.green('+')} ${file} (local only)`)
}
for (const file of remoteOnly) {
console.log(` ${color.green('+')} ${file} (remote only)`)
}
console.log(`\nRun ${color.bold('toes pull')} to update, or ${color.bold('toes push --force')} to overwrite server`)
console.log()
} }
} }
export async function syncApp() { export async function syncApp() {
if (!isApp()) { if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.') console.error(notAppError())
return return
} }
const appName = getAppName() const appName = getAppName()
const gitignore = loadGitignore(process.cwd())
const localHashes = new Map<string, string>()
// Initialize local hashes // Verify app exists on server
const manifest = generateManifest(process.cwd(), appName) const result = await getManifest(appName)
for (const [path, info] of Object.entries(manifest.files)) { if (result === null) return
localHashes.set(path, info.hash) if (!result.exists) {
console.error(`App ${color.bold(appName)} doesn't exist on server. Run ${color.bold('toes push')} first.`)
return
} }
console.log(`Syncing ${color.bold(appName)}...`) console.log(`Syncing ${color.bold(appName)}...`)
// Watch local files // Initial sync: pull remote changes, then push local
const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => { await mergeSync(appName)
const gitignore = loadGitignore(process.cwd())
// Watch local files with debounce → push
let pushTimer: Timer | null = null
const watcher = watch(process.cwd(), { recursive: true }, (_event, filename) => {
if (!filename || gitignore.shouldExclude(filename)) return if (!filename || gitignore.shouldExclude(filename)) return
if (pushTimer) clearTimeout(pushTimer)
const fullPath = join(process.cwd(), filename) pushTimer = setTimeout(() => pushApp({ quiet: true }), 500)
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
const content = readFileSync(fullPath)
const hash = computeHash(content)
if (localHashes.get(filename) !== hash) {
localHashes.set(filename, hash)
await put(`/api/sync/apps/${appName}/files/${filename}`, content)
console.log(` ${color.green('↑')} ${filename}`)
}
} else if (!existsSync(fullPath)) {
localHashes.delete(filename)
await del(`/api/sync/apps/${appName}/files/${filename}`)
console.log(` ${color.red('✗')} ${filename}`)
}
}) })
// Connect to SSE for remote changes // Connect to SSE for remote changes → pull
const url = makeUrl(`/api/sync/apps/${appName}/watch`) const url = makeUrl(`/api/sync/apps/${appName}/watch`)
let res: Response let res: Response
try { try {
res = await fetch(url) res = await fetch(url, { signal: getSignal() })
if (!res.ok) { if (!res.ok) {
console.error(`Failed to connect to server: ${res.status} ${res.statusText}`) console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
watcher.close() watcher.close()
@ -426,11 +610,12 @@ export async function syncApp() {
return return
} }
console.log(` Connected to server, watching for changes...`) console.log(` Connected, watching for changes...`)
const reader = res.body.getReader() const reader = res.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
let pullTimer: Timer | null = null
try { try {
while (true) { while (true) {
@ -443,30 +628,13 @@ export async function syncApp() {
for (const line of lines) { for (const line of lines) {
if (!line.startsWith('data: ')) continue if (!line.startsWith('data: ')) continue
const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete', path: string, hash?: string } if (pullTimer) clearTimeout(pullTimer)
pullTimer = setTimeout(() => mergeSync(appName), 500)
if (event.type === 'change') {
// Skip if we already have this version (handles echo from our own changes)
if (localHashes.get(event.path) === event.hash) continue
const content = await download(`/api/sync/apps/${appName}/files/${event.path}`)
if (content) {
const fullPath = join(process.cwd(), event.path)
mkdirSync(dirname(fullPath), { recursive: true })
writeFileSync(fullPath, content)
localHashes.set(event.path, event.hash!)
console.log(` ${color.green('↓')} ${event.path}`)
}
} else if (event.type === 'delete') {
const fullPath = join(process.cwd(), event.path)
if (existsSync(fullPath)) {
unlinkSync(fullPath)
localHashes.delete(event.path)
console.log(` ${color.red('✗')} ${event.path} (remote)`)
}
}
} }
} }
} finally { } finally {
if (pushTimer) clearTimeout(pushTimer)
if (pullTimer) clearTimeout(pullTimer)
watcher.close() watcher.close()
} }
} }
@ -581,7 +749,7 @@ const STASH_BASE = '/tmp/toes-stash'
export async function stashApp() { export async function stashApp() {
if (!isApp()) { if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.') console.error(notAppError())
return return
} }
@ -632,12 +800,12 @@ export async function stashApp() {
const content = readFileSync(srcPath) const content = readFileSync(srcPath)
writeFileSync(destPath, content) writeFileSync(destPath, content)
console.log(` ${color.yellow('→')} ${file}`) console.log(` ${color.magenta('*')} ${file}`)
} }
// Restore changed files from server // Restore changed files from server
if (changed.length > 0) { if (changed.length > 0) {
console.log(`\nRestoring ${changed.length} changed files from server...`) console.log(`\nRestoring ${changed.length} changed file${s(changed.length)} from server...`)
for (const file of changed) { for (const file of changed) {
const content = await download(`/api/sync/apps/${appName}/files/${file}`) const content = await download(`/api/sync/apps/${appName}/files/${file}`)
if (content) { if (content) {
@ -648,13 +816,13 @@ export async function stashApp() {
// Delete local-only files // Delete local-only files
if (localOnly.length > 0) { if (localOnly.length > 0) {
console.log(`Removing ${localOnly.length} local-only files...`) console.log(`Removing ${localOnly.length} local-only file${s(localOnly.length)}...`)
for (const file of localOnly) { for (const file of localOnly) {
unlinkSync(join(process.cwd(), file)) unlinkSync(join(process.cwd(), file))
} }
} }
console.log(color.green(`\n✓ Stashed ${toStash.length} file(s)`)) console.log(color.green(`\n✓ Stashed ${toStash.length} file${s(toStash.length)}`))
} }
export async function stashListApp() { export async function stashListApp() {
@ -680,7 +848,7 @@ export async function stashListApp() {
files: string[] files: string[]
} }
const date = new Date(metadata.timestamp).toLocaleString() const date = new Date(metadata.timestamp).toLocaleString()
console.log(` ${color.bold(stash.name)} ${color.gray(date)} ${color.gray(`(${metadata.files.length} files)`)}`) console.log(` ${color.bold(stash.name)} ${color.gray(date)} ${color.gray(`(${metadata.files.length} file${s(metadata.files.length)})`)}`)
} else { } else {
console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`) console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`)
} }
@ -690,7 +858,7 @@ export async function stashListApp() {
export async function stashPopApp() { export async function stashPopApp() {
if (!isApp()) { if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.') console.error(notAppError())
return return
} }
@ -742,12 +910,12 @@ export async function stashPopApp() {
// Remove stash directory // Remove stash directory
rmSync(stashDir, { recursive: true }) rmSync(stashDir, { recursive: true })
console.log(color.green(`\n✓ Restored ${metadata.files.length} file(s)`)) console.log(color.green(`\n✓ Restored ${metadata.files.length} file${s(metadata.files.length)}`))
} }
export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) { export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) {
if (!isApp()) { if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.') console.error(notAppError())
return return
} }
@ -768,7 +936,7 @@ export async function cleanApp(options: { force?: boolean, dryRun?: boolean } =
if (options.dryRun) { if (options.dryRun) {
console.log('Would remove:') console.log('Would remove:')
for (const file of localOnly) { for (const file of localOnly) {
console.log(` ${color.red('')} ${file}`) console.log(` ${color.red('-')} ${file}`)
} }
return return
} }
@ -776,20 +944,20 @@ export async function cleanApp(options: { force?: boolean, dryRun?: boolean } =
if (!options.force) { if (!options.force) {
console.log('Files not on server:') console.log('Files not on server:')
for (const file of localOnly) { for (const file of localOnly) {
console.log(` ${color.red('')} ${file}`) console.log(` ${color.red('-')} ${file}`)
} }
console.log() console.log()
const ok = await confirm(`Remove ${localOnly.length} file(s)?`) const ok = await confirm(`Remove ${localOnly.length} file${s(localOnly.length)}?`)
if (!ok) return if (!ok) return
} }
for (const file of localOnly) { for (const file of localOnly) {
const fullPath = join(process.cwd(), file) const fullPath = join(process.cwd(), file)
unlinkSync(fullPath) unlinkSync(fullPath)
console.log(` ${color.red('')} ${file}`) console.log(` ${color.red('-')} ${file}`)
} }
console.log(color.green(`✓ Removed ${localOnly.length} file(s)`)) console.log(color.green(`✓ Removed ${localOnly.length} file${s(localOnly.length)}`))
} }
interface VersionsResponse { interface VersionsResponse {
@ -799,7 +967,7 @@ interface VersionsResponse {
async function getVersions(appName: string): Promise<VersionsResponse | null> { async function getVersions(appName: string): Promise<VersionsResponse | null> {
try { try {
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`)) const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`), { signal: getSignal() })
if (res.status === 404) { if (res.status === 404) {
console.error(`App not found: ${appName}`) console.error(`App not found: ${appName}`)
return null return null
@ -829,6 +997,38 @@ function formatVersion(version: string): string {
return date.toLocaleString() return date.toLocaleString()
} }
async function mergeSync(appName: string): Promise<void> {
const diff = await getManifestDiff(appName)
if (!diff) return
const { changed, remoteOnly, remoteManifest, serverChanged } = diff
if (!remoteManifest) return
if (serverChanged) {
// Pull remote changes
const toPull = [...changed, ...remoteOnly]
if (toPull.length > 0) {
for (const file of toPull) {
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
if (!content) continue
const fullPath = join(process.cwd(), file)
const dir = dirname(fullPath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(fullPath, content)
console.log(` ${color.green('↓')} ${file}`)
}
}
}
// Push merged state to server
await pushApp({ quiet: true })
}
async function getManifestDiff(appName: string): Promise<ManifestDiff | null> { async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
const localManifest = generateManifest(process.cwd(), appName) const localManifest = generateManifest(process.cwd(), appName)
const result = await getManifest(appName) const result = await getManifest(appName)
@ -838,15 +1038,20 @@ async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
return null return null
} }
const remoteManifest = result.manifest ?? null
const remoteVersion = result.version ?? null
const syncState = readSyncState(process.cwd())
const serverChanged = !syncState || syncState.version !== remoteVersion
const localFiles = new Set(Object.keys(localManifest.files)) const localFiles = new Set(Object.keys(localManifest.files))
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {})) const remoteFiles = new Set(Object.keys(remoteManifest?.files ?? {}))
// Files that differ // Files that differ
const changed: string[] = [] const changed: string[] = []
for (const file of localFiles) { for (const file of localFiles) {
if (remoteFiles.has(file)) { if (remoteFiles.has(file)) {
const local = localManifest.files[file]! const local = localManifest.files[file]!
const remote = result.manifest!.files[file]! const remote = remoteManifest!.files[file]!
if (local.hash !== remote.hash) { if (local.hash !== remote.hash) {
changed.push(file) changed.push(file)
} }
@ -861,34 +1066,87 @@ async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
} }
} }
// Files only in remote // Files only in remote (filtered by local gitignore)
const gitignore = loadGitignore(process.cwd())
const remoteOnly: string[] = [] const remoteOnly: string[] = []
for (const file of remoteFiles) { for (const file of remoteFiles) {
if (!localFiles.has(file)) { if (!localFiles.has(file) && !gitignore.shouldExclude(file)) {
remoteOnly.push(file) remoteOnly.push(file)
} }
} }
// Detect renames: localOnly + remoteOnly files with matching hashes
const renamed: Rename[] = []
const remoteByHash = new Map<string, string>()
for (const file of remoteOnly) {
const hash = remoteManifest!.files[file]!.hash
remoteByHash.set(hash, file)
}
const matchedLocal = new Set<string>()
const matchedRemote = new Set<string>()
for (const file of localOnly) {
const hash = localManifest.files[file]!.hash
const remoteFile = remoteByHash.get(hash)
if (remoteFile && !matchedRemote.has(remoteFile)) {
renamed.push({ from: remoteFile, to: file })
matchedLocal.add(file)
matchedRemote.add(remoteFile)
}
}
return { return {
changed, changed,
localOnly, localOnly: localOnly.filter(f => !matchedLocal.has(f)),
remoteOnly, remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)),
renamed,
localManifest, localManifest,
remoteManifest: result.manifest ?? null, remoteManifest,
remoteVersion,
serverChanged,
} }
} }
const BINARY_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.heic', '.tiff',
'.woff', '.woff2', '.ttf', '.eot', '.otf',
'.mp3', '.mp4', '.wav', '.ogg', '.webm', '.avi', '.mov',
'.pdf', '.zip', '.tar', '.gz', '.br', '.zst',
'.wasm', '.exe', '.dll', '.so', '.dylib',
])
const isBinary = (filename: string) => {
const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase()
return BINARY_EXTENSIONS.has(ext)
}
function showDiff(remote: string, local: string) { function showDiff(remote: string, local: string) {
const changes = diffLines(remote, local) const changes = diffLines(remote, local)
let lineCount = 0 let lineCount = 0
const maxLines = 50 const maxLines = 50
const contextLines = 3 const contextLines = 3
let remoteLine = 1
let localLine = 1
let needsHeader = true
let hunkCount = 0
const printHeader = (_rStart: number, lStart: number) => {
if (hunkCount > 0) console.log()
if (lStart > 1) {
console.log(color.cyan(`Line ${lStart}:`))
lineCount++
}
needsHeader = false
hunkCount++
}
for (let i = 0; i < changes.length; i++) { for (let i = 0; i < changes.length; i++) {
const part = changes[i]! const part = changes[i]!
const lines = part.value.replace(/\n$/, '').split('\n') const lines = part.value.replace(/\n$/, '').split('\n')
if (part.added) { if (part.added) {
if (needsHeader) printHeader(remoteLine, localLine)
for (const line of lines) { for (const line of lines) {
if (lineCount >= maxLines) { if (lineCount >= maxLines) {
console.log(color.gray('... diff truncated')) console.log(color.gray('... diff truncated'))
@ -897,7 +1155,9 @@ function showDiff(remote: string, local: string) {
console.log(color.green(`+ ${line}`)) console.log(color.green(`+ ${line}`))
lineCount++ lineCount++
} }
localLine += lines.length
} else if (part.removed) { } else if (part.removed) {
if (needsHeader) printHeader(remoteLine, localLine)
for (const line of lines) { for (const line of lines) {
if (lineCount >= maxLines) { if (lineCount >= maxLines) {
console.log(color.gray('... diff truncated')) console.log(color.gray('... diff truncated'))
@ -906,6 +1166,7 @@ function showDiff(remote: string, local: string) {
console.log(color.red(`- ${line}`)) console.log(color.red(`- ${line}`))
lineCount++ lineCount++
} }
remoteLine += lines.length
} else { } else {
// Context: show lines near changes // Context: show lines near changes
const prevHasChange = i > 0 && (changes[i - 1]!.added || changes[i - 1]!.removed) const prevHasChange = i > 0 && (changes[i - 1]!.added || changes[i - 1]!.removed)
@ -926,9 +1187,11 @@ function showDiff(remote: string, local: string) {
if (nextHasChange) { if (nextHasChange) {
const start = Math.max(0, lines.length - contextLines) const start = Math.max(0, lines.length - contextLines)
if (start > 0) { if (start > 0) {
console.log(color.gray(' ...')) needsHeader = true
lineCount++
} }
const headerLine = remoteLine + start
const headerLocalLine = localLine + start
if (needsHeader) printHeader(headerLine, headerLocalLine)
for (let j = start; j < lines.length; j++) { for (let j = start; j < lines.length; j++) {
if (lineCount >= maxLines) { if (lineCount >= maxLines) {
console.log(color.gray('... diff truncated')) console.log(color.gray('... diff truncated'))
@ -939,7 +1202,7 @@ function showDiff(remote: string, local: string) {
} }
} }
// Show context after previous change // Show context after previous change
if (prevHasChange) { if (prevHasChange && !nextHasChange) {
const end = Math.min(lines.length, contextLines) const end = Math.min(lines.length, contextLines)
for (let j = 0; j < end; j++) { for (let j = 0; j < end; j++) {
if (lineCount >= maxLines) { if (lineCount >= maxLines) {
@ -949,11 +1212,13 @@ function showDiff(remote: string, local: string) {
console.log(color.gray(` ${lines[j]}`)) console.log(color.gray(` ${lines[j]}`))
lineCount++ lineCount++
} }
if (end < lines.length && !nextHasChange) { if (end < lines.length) {
console.log(color.gray(' ...')) needsHeader = true
} }
} }
} }
remoteLine += lines.length
localLine += lines.length
} }
} }
} }

View File

@ -1,17 +1,30 @@
import type { Manifest } from '@types' import type { Manifest } from '@types'
import { AsyncLocalStorage } from 'node:async_hooks'
function getDefaultHost(): string { const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
if (process.env.NODE_ENV === 'production') { const signalStore = new AsyncLocalStorage<AbortSignal>()
return `http://toes.local:${process.env.PORT ?? 80}`
const normalizeUrl = (url: string) =>
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
function tryParseError(text: string): string | undefined {
try {
const json = JSON.parse(text)
return json.error
} catch {
return undefined
} }
return `http://localhost:${process.env.PORT ?? 3000}`
} }
const defaultPort = process.env.NODE_ENV === 'production' ? 80 : 3000
export const HOST = process.env.TOES_URL export const HOST = process.env.TOES_URL
?? (process.env.TOES_HOST ? `http://${process.env.TOES_HOST}:${process.env.PORT ?? defaultPort}` : undefined) ? normalizeUrl(process.env.TOES_URL)
?? getDefaultHost() : DEFAULT_HOST
export const getSignal = () => signalStore.getStore()
export function withSignal<T>(signal: AbortSignal, fn: () => T): T {
return signalStore.run(signal, fn)
}
export function makeUrl(path: string): string { export function makeUrl(path: string): string {
return `${HOST}${path}` return `${HOST}${path}`
@ -20,7 +33,11 @@ export function makeUrl(path: string): string {
export function handleError(error: unknown): void { export function handleError(error: unknown): void {
if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') { if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') {
console.error(`🐾 Can't connect to toes server at ${HOST}`) console.error(`🐾 Can't connect to toes server at ${HOST}`)
console.error(` Set TOES_URL or TOES_HOST to connect to a different host`) console.error(` Set TOES_URL to connect to a different host`)
return
}
if (error instanceof Error) {
console.error(`Error: ${error.message}`)
return return
} }
console.error(error) console.error(error)
@ -28,20 +45,26 @@ export function handleError(error: unknown): void {
export async function get<T>(url: string): Promise<T | undefined> { export async function get<T>(url: string): Promise<T | undefined> {
try { try {
const res = await fetch(makeUrl(url)) const res = await fetch(makeUrl(url), { signal: getSignal() })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (!res.ok) {
const text = await res.text()
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
throw new Error(msg)
}
return await res.json() return await res.json()
} catch (error) { } catch (error) {
handleError(error) handleError(error)
} }
} }
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> { export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
try { try {
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`)) const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() })
if (res.status === 404) return { exists: false } if (res.status === 404) return { exists: false }
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return { exists: true, manifest: await res.json() } const data = await res.json()
const { version, ...manifest } = data
return { exists: true, manifest, version }
} catch (error) { } catch (error) {
handleError(error) handleError(error)
return null return null
@ -54,8 +77,13 @@ export async function post<T, B = unknown>(url: string, body?: B): Promise<T | u
method: 'POST', method: 'POST',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
signal: getSignal(),
}) })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (!res.ok) {
const text = await res.text()
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
throw new Error(msg)
}
return await res.json() return await res.json()
} catch (error) { } catch (error) {
handleError(error) handleError(error)
@ -67,8 +95,13 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
const res = await fetch(makeUrl(url), { const res = await fetch(makeUrl(url), {
method: 'PUT', method: 'PUT',
body: body as BodyInit, body: body as BodyInit,
signal: getSignal(),
}) })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (!res.ok) {
const text = await res.text()
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
throw new Error(msg)
}
return true return true
} catch (error) { } catch (error) {
handleError(error) handleError(error)
@ -79,8 +112,12 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
export async function download(url: string): Promise<Buffer | undefined> { export async function download(url: string): Promise<Buffer | undefined> {
try { try {
const fullUrl = makeUrl(url) const fullUrl = makeUrl(url)
const res = await fetch(fullUrl) const res = await fetch(fullUrl, { signal: getSignal() })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (!res.ok) {
const text = await res.text()
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
throw new Error(msg)
}
return Buffer.from(await res.arrayBuffer()) return Buffer.from(await res.arrayBuffer())
} catch (error) { } catch (error) {
handleError(error) handleError(error)
@ -91,8 +128,13 @@ export async function del(url: string): Promise<boolean> {
try { try {
const res = await fetch(makeUrl(url), { const res = await fetch(makeUrl(url), {
method: 'DELETE', method: 'DELETE',
signal: getSignal(),
}) })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (!res.ok) {
const text = await res.text()
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
throw new Error(msg)
}
return true return true
} catch (error) { } catch (error) {
handleError(error) handleError(error)

View File

@ -1,4 +1,13 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { program } from './setup' import { program } from './setup'
program.parse() const isCliUser = process.env.USER === 'cli'
const noArgs = process.argv.length <= 2
const isTTY = !!process.stdin.isTTY
if (isCliUser && noArgs && isTTY) {
const { shell } = await import('./shell')
await shell()
} else {
program.parse()
}

35
src/cli/pager.ts Normal file
View File

@ -0,0 +1,35 @@
export async function withPager(fn: () => Promise<void> | void): Promise<void> {
if (!process.stdout.isTTY) {
await fn()
return
}
const lines: string[] = []
const originalLog = console.log
console.log = (...args: unknown[]) => {
lines.push(args.map(a => typeof a === 'string' ? a : String(a)).join(' '))
}
try {
await fn()
} finally {
console.log = originalLog
}
if (lines.length === 0) return
const text = lines.join('\n') + '\n'
const rows = process.stdout.rows || 24
const lineCount = text.split('\n').length - 1
if (lineCount > rows) {
Bun.spawnSync(['less', '-R'], {
stdin: Buffer.from(text),
stdout: 'inherit',
stderr: 'inherit',
})
} else {
process.stdout.write(text)
}
}

View File

@ -1,11 +1,22 @@
import { program } from 'commander' import { program } from 'commander'
import color from 'kleur' import color from 'kleur'
import pkg from '../../package.json'
import { withPager } from './pager'
import { import {
cleanApp, cleanApp,
configShow, configShow,
cronList,
cronLog,
cronRun,
cronStatus,
diffApp, diffApp,
envList,
envRm,
envSet,
getApp, getApp,
historyApp,
infoApp, infoApp,
listApps, listApps,
logApp, logApp,
@ -21,28 +32,29 @@ import {
stashListApp, stashListApp,
stashPopApp, stashPopApp,
startApp, startApp,
metricsApp,
shareApp,
statusApp, statusApp,
stopApp, stopApp,
syncApp, syncApp,
unshareApp,
versionsApp, versionsApp,
} from './commands' } from './commands'
program program
.name('toes') .name('toes')
.version('v0.0.4', '-v, --version') .version(`v${pkg.version}`, '-v, --version')
.addHelpText('beforeAll', (ctx) => { .addHelpText('beforeAll', (ctx) => {
if (ctx.command === program) { if (ctx.command === program) {
return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n') return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
} }
return '' return ''
}) })
.addHelpCommand(false)
.configureOutput({ .configureOutput({
writeOut: (str) => { writeOut: (str) => {
const colored = str const colored = str
.replace(/^(Usage:)/gm, color.yellow('$1')) .replace(/^([A-Z][\w ]*:)/gm, color.yellow('$1'))
.replace(/^(Commands:)/gm, color.yellow('$1'))
.replace(/^(Options:)/gm, color.yellow('$1'))
.replace(/^(Arguments:)/gm, color.yellow('$1'))
process.stdout.write(colored) process.stdout.write(colored)
}, },
}) })
@ -51,44 +63,102 @@ program
.command('version', { hidden: true }) .command('version', { hidden: true })
.action(() => console.log(program.version())) .action(() => console.log(program.version()))
// Apps
program program
.command('config') .command('list')
.description('Show current host configuration') .helpGroup('Apps:')
.action(configShow) .description('List all apps')
.option('-t, --tools', 'show only tools')
.option('-a, --apps', 'show only apps (exclude tools)')
.action(listApps)
program program
.command('info') .command('info')
.helpGroup('Apps:')
.description('Show info for an app') .description('Show info for an app')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(infoApp) .action(infoApp)
program program
.command('list') .command('new')
.description('List all apps') .helpGroup('Apps:')
.option('-t, --tools', 'show only tools') .description('Create a new toes app')
.option('-a, --all', 'show all apps including tools') .argument('[name]', 'app name (uses current directory if omitted)')
.action(listApps) .option('--ssr', 'SSR template with pages directory (default)')
.option('--bare', 'minimal template with no pages')
.option('--spa', 'single-page app with client-side rendering')
.action(newApp)
program
.command('get')
.helpGroup('Apps:')
.description('Download an app from server')
.argument('<name>', 'app name')
.action(getApp)
program
.command('open')
.helpGroup('Apps:')
.description('Open an app in browser')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(openApp)
program
.command('rename')
.helpGroup('Apps:')
.description('Rename an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<new-name>', 'new app name')
.action(renameApp)
program
.command('rm')
.helpGroup('Apps:')
.description('Remove an app from the server')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(rmApp)
// Lifecycle
program program
.command('start') .command('start')
.helpGroup('Lifecycle:')
.description('Start an app') .description('Start an app')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(startApp) .action(startApp)
program program
.command('stop') .command('stop')
.helpGroup('Lifecycle:')
.description('Stop an app') .description('Stop an app')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(stopApp) .action(stopApp)
program program
.command('restart') .command('restart')
.helpGroup('Lifecycle:')
.description('Restart an app') .description('Restart an app')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(restartApp) .action(restartApp)
program
.command('share')
.helpGroup('Lifecycle:')
.description('Share an app via public tunnel')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(shareApp)
program
.command('unshare')
.helpGroup('Lifecycle:')
.description('Stop sharing an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(unshareApp)
program program
.command('logs') .command('logs')
.helpGroup('Lifecycle:')
.description('Show logs for an app') .description('Show logs for an app')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.option('-f, --follow', 'follow log output') .option('-f, --follow', 'follow log output')
@ -107,54 +177,75 @@ program
.action(logApp) .action(logApp)
program program
.command('open') .command('metrics')
.description('Open an app in browser') .helpGroup('Lifecycle:')
.description('Show CPU, memory, and disk metrics for apps')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(openApp) .action(metricsApp)
program const cron = program
.command('get') .command('cron')
.description('Download an app from server') .helpGroup('Lifecycle:')
.argument('<name>', 'app name') .description('Manage cron jobs')
.action(getApp) .argument('[app]', 'app name (list jobs for specific app)')
.action(cronList)
program cron
.command('new') .command('log')
.description('Create a new toes app') .description('Show cron job logs')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[target]', 'app name or job (app:name)')
.option('--ssr', 'SSR template with pages directory (default)') .option('-f, --follow', 'follow log output')
.option('--bare', 'minimal template with no pages') .action(cronLog)
.option('--spa', 'single-page app with client-side rendering')
.action(newApp) cron
.command('status')
.description('Show detailed status for a job')
.argument('<job>', 'job identifier (app:name)')
.action(cronStatus)
cron
.command('run')
.description('Run a job immediately')
.argument('<job>', 'job identifier (app:name)')
.action(cronRun)
// Sync
program program
.command('push') .command('push')
.helpGroup('Sync:')
.description('Push local changes to server') .description('Push local changes to server')
.option('-f, --force', 'overwrite remote changes')
.action(pushApp) .action(pushApp)
program program
.command('pull') .command('pull')
.helpGroup('Sync:')
.description('Pull changes from server') .description('Pull changes from server')
.option('-f, --force', 'overwrite local changes') .option('-f, --force', 'overwrite local changes')
.action(pullApp) .action(pullApp)
program program
.command('status') .command('status')
.helpGroup('Sync:')
.description('Show what would be pushed/pulled') .description('Show what would be pushed/pulled')
.action(statusApp) .action(statusApp)
program program
.command('diff') .command('diff')
.helpGroup('Sync:')
.description('Show diff of changed files') .description('Show diff of changed files')
.action(diffApp) .action(() => withPager(diffApp))
program program
.command('sync') .command('sync')
.helpGroup('Sync:')
.description('Watch and sync changes bidirectionally') .description('Watch and sync changes bidirectionally')
.action(syncApp) .action(syncApp)
program program
.command('clean') .command('clean')
.helpGroup('Sync:')
.description('Remove local files not on server') .description('Remove local files not on server')
.option('-f, --force', 'skip confirmation') .option('-f, --force', 'skip confirmation')
.option('-n, --dry-run', 'show what would be removed') .option('-n, --dry-run', 'show what would be removed')
@ -162,6 +253,7 @@ program
const stash = program const stash = program
.command('stash') .command('stash')
.helpGroup('Sync:')
.description('Stash local changes') .description('Stash local changes')
.action(stashApp) .action(stashApp)
@ -175,30 +267,69 @@ stash
.description('List all stashes') .description('List all stashes')
.action(stashListApp) .action(stashListApp)
// Config
program
.command('config')
.helpGroup('Config:')
.description('Show current host configuration')
.action(configShow)
const env = program
.command('env')
.helpGroup('Config:')
.description('Manage environment variables')
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-g, --global', 'manage global variables shared by all apps')
.action(envList)
env
.command('set')
.description('Set an environment variable')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<key>', 'variable name')
.argument('[value]', 'variable value (or use KEY=value format)')
.option('-g, --global', 'set a global variable shared by all apps')
.action(envSet)
env
.command('rm')
.description('Remove an environment variable')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<key>', 'variable name to remove')
.option('-g, --global', 'remove a global variable')
.action(envRm)
program program
.command('versions') .command('versions')
.helpGroup('Config:')
.description('List deployed versions') .description('List deployed versions')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(versionsApp) .action(versionsApp)
program
.command('history')
.helpGroup('Config:')
.description('Show file changes between versions')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(historyApp)
program program
.command('rollback') .command('rollback')
.helpGroup('Config:')
.description('Rollback to a previous version') .description('Rollback to a previous version')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)') .option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
.action((name, options) => rollbackApp(name, options.version)) .action((name, options) => rollbackApp(name, options.version))
program // Shell
.command('rm')
.description('Remove an app from the server')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(rmApp)
program program
.command('rename') .command('shell')
.description('Rename an app') .description('Interactive shell')
.argument('[name]', 'app name (uses current directory if omitted)') .action(async () => {
.argument('<new-name>', 'new app name') const { shell } = await import('./shell')
.action(renameApp) await shell()
})
export { program } export { program }

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

@ -0,0 +1,227 @@
import type { App } from '@types'
import * as readline from 'readline'
import color from 'kleur'
import { get, handleError, HOST, withSignal } from './http'
import { program } from './setup'
import { STATE_ICONS } from './commands/manage'
let appNamesCache: string[] = []
let appNamesCacheTime = 0
const APP_CACHE_TTL = 5000
function tokenize(input: string): string[] {
const tokens: string[] = []
let current = ''
let quote: string | null = null
for (const ch of input) {
if (quote) {
if (ch === quote) {
quote = null
} else {
current += ch
}
} else if (ch === '"' || ch === "'") {
quote = ch
} else if (ch === ' ' || ch === '\t') {
if (current) {
tokens.push(current)
current = ''
}
} else {
current += ch
}
}
if (current) tokens.push(current)
return tokens
}
async function fetchAppNames(): Promise<string[]> {
const now = Date.now()
if (appNamesCache.length > 0 && now - appNamesCacheTime < APP_CACHE_TTL) {
return appNamesCache
}
try {
const apps = await get<App[]>('/api/apps')
if (apps) {
appNamesCache = apps.map(a => a.name)
appNamesCacheTime = now
}
} catch {
// use stale cache
}
return appNamesCache
}
function getCommandNames(): string[] {
return program.commands
.filter((cmd: { _hidden?: boolean }) => !cmd._hidden)
.map((cmd: { name: () => string }) => cmd.name())
}
async function printBanner(): Promise<void> {
const apps = await get<App[]>('/api/apps')
if (!apps) {
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
console.log()
return
}
// Cache app names from banner fetch
appNamesCache = apps.map(a => a.name)
appNamesCacheTime = Date.now()
const visibleApps = apps.filter(a => !a.tool)
console.log()
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
console.log()
// App status line
const parts = visibleApps.map(a => {
const icon = STATE_ICONS[a.state] ?? '\u25CB'
return `${icon} ${a.name}`
})
if (parts.length > 0) {
console.log(' ' + parts.join(' '))
console.log()
}
const running = visibleApps.filter(a => a.state === 'running').length
const stopped = visibleApps.filter(a => a.state !== 'running').length
const summary = []
if (running) summary.push(`${running} running`)
if (stopped) summary.push(`${stopped} stopped`)
if (summary.length > 0) {
console.log(color.gray(` ${summary.join(', ')} \u2014 type "help" for commands`))
} else {
console.log(color.gray(' no apps \u2014 type "help" for commands'))
}
console.log()
}
export async function shell(): Promise<void> {
await printBanner()
// Configure Commander to throw instead of exiting
program.exitOverride()
program.configureOutput({
writeOut: (str: string) => process.stdout.write(str),
writeErr: (str: string) => process.stderr.write(str),
})
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: color.cyan('toes> '),
completer: (line: string, callback: (err: null, result: [string[], string]) => void) => {
const tokens = tokenize(line)
const trailing = line.endsWith(' ')
if (tokens.length === 0 || (tokens.length === 1 && !trailing)) {
// Complete command names
const partial = tokens[0] ?? ''
const commands = getCommandNames()
const hits = commands.filter(c => c.startsWith(partial))
callback(null, [hits, partial])
} else {
// Complete app names
const partial = trailing ? '' : (tokens[tokens.length - 1] ?? '')
const names = appNamesCache
const hits = names.filter(n => n.startsWith(partial))
callback(null, [hits, partial])
}
},
})
// Refresh app names cache in background for tab completion
fetchAppNames()
let activeAbort: AbortController | null = null
rl.on('SIGINT', () => {
if (activeAbort) {
activeAbort.abort()
activeAbort = null
console.log()
rl.prompt()
} else {
// Clear current line
rl.write(null, { ctrl: true, name: 'u' })
console.log()
rl.prompt()
}
})
rl.prompt()
for await (const line of rl) {
const input = line.trim()
if (!input) {
rl.prompt()
continue
}
if (input === 'exit' || input === 'quit') {
break
}
if (input === 'clear') {
console.clear()
rl.prompt()
continue
}
if (input === 'help') {
program.outputHelp()
rl.prompt()
continue
}
const tokens = tokenize(input)
// Set up AbortController for this command
activeAbort = new AbortController()
const signal = activeAbort.signal
// Pause readline so commands can use their own prompts
rl.pause()
try {
await withSignal(signal, () => program.parseAsync(['node', 'toes', ...tokens]))
} catch (err: unknown) {
// Commander throws on exitOverride — suppress help/version exits
if (err && typeof err === 'object' && 'code' in err) {
const code = (err as { code: string }).code
if (code === 'commander.helpDisplayed' || code === 'commander.version') {
// Already printed, just continue
} else if (code === 'commander.unknownCommand') {
console.error(`Unknown command: ${tokens[0]}`)
} else {
// Other Commander errors (missing arg, etc.)
// Commander already printed the error message
}
} else if (signal.aborted) {
// Command was cancelled by Ctrl+C
} else {
handleError(err)
}
} finally {
activeAbort = null
}
// Refresh app names cache after commands that might change state
fetchAppNames()
rl.resume()
rl.prompt()
}
rl.close()
console.log()
}

View File

@ -4,6 +4,22 @@ export const getLogDates = (name: string): Promise<string[]> =>
export const getLogsForDate = (name: string, date: string): Promise<string[]> => export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json()) fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
export const getWifiConfig = (): Promise<{ network: string, password: string }> =>
fetch('/api/system/wifi').then(r => r.json())
export const saveWifiConfig = (config: { network: string, password: string }) =>
fetch('/api/system/wifi', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
}).then(r => r.json())
export const shareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
export const unshareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' }) export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })

View File

@ -1,12 +1,15 @@
import { define } from '@because/forge' import { define } from '@because/forge'
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { restartApp, startApp, stopApp } from '../api' import { buildAppUrl } from '../../shared/urls'
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
import { openDeleteAppModal, openRenameAppModal } from '../modals' import { openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, selectedTab } from '../state' import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
import { import {
ActionBar, ActionBar,
Button, Button,
ClickableAppName, ClickableAppName,
HamburgerButton,
HamburgerLine,
HeaderActions, HeaderActions,
InfoLabel, InfoLabel,
InfoRow, InfoRow,
@ -44,16 +47,30 @@ const OpenEmojiPicker = define('OpenEmojiPicker', {
export function AppDetail({ app, render }: { app: App, render: () => void }) { export function AppDetail({ app, render }: { app: App, render: () => void }) {
// Find all tools // Find all tools
const tools = apps.filter(a => a.tool) const tools = apps.filter(a => a.tool)
const selectedTab = getSelectedTab(app.name)
return ( return (
<Main> <Main>
<MainHeader> <MainHeader>
<MainTitle> <MainTitle>
{isNarrow && (
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker> <OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
&nbsp;
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName> <ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle> </MainTitle>
<HeaderActions> <HeaderActions>
{!app.tool && (
app.tunnelUrl
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
: app.tunnelEnabled
? <Button disabled>Sharing...</Button>
: <Button disabled={app.state !== 'running'} onClick={() => { shareApp(app.name) }}>Share</Button>
)}
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button> <Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
</HeaderActions> </HeaderActions>
</MainHeader> </MainHeader>
@ -70,16 +87,24 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
{stateLabels[app.state]} {stateLabels[app.state]}
</InfoValue> </InfoValue>
</InfoRow> </InfoRow>
{app.state === 'running' && app.port && ( {app.state === 'running' && (
<InfoRow> <InfoRow>
<InfoLabel>URL</InfoLabel> <InfoLabel>URL</InfoLabel>
<InfoValue> <InfoValue>
<Link href={`http://localhost:${app.port}`} target="_blank"> <Link href={buildAppUrl(app.name, location.origin)} target="_blank">
http://localhost:{app.port} {buildAppUrl(app.name, location.origin)}
</Link> </Link>
</InfoValue> </InfoValue>
</InfoRow> </InfoRow>
)} )}
{app.tunnelUrl && (
<InfoRow>
<InfoLabel>Tunnel</InfoLabel>
<InfoValue>
<Link href={app.tunnelUrl} target="_blank">{app.tunnelUrl}</Link>
</InfoValue>
</InfoRow>
)}
{app.state === 'running' && app.port && ( {app.state === 'running' && app.port && (
<InfoRow> <InfoRow>
<InfoLabel>Port</InfoLabel> <InfoLabel>Port</InfoLabel>
@ -88,6 +113,14 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
</InfoValue> </InfoValue>
</InfoRow> </InfoRow>
)} )}
{app.state === 'running' && app.pid && (
<InfoRow>
<InfoLabel>PID</InfoLabel>
<InfoValue>
{app.pid}
</InfoValue>
</InfoRow>
)}
{app.started && ( {app.started && (
<InfoRow> <InfoRow>
<InfoLabel>Started</InfoLabel> <InfoLabel>Started</InfoLabel>
@ -107,7 +140,7 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
<LogsSection app={app} /> <LogsSection app={app} />
<ActionBar> <ActionBar>
{app.state === 'stopped' && ( {(app.state === 'stopped' || app.state === 'error') && (
<Button variant="primary" onClick={() => startApp(app.name)}> <Button variant="primary" onClick={() => startApp(app.name)}>
Start Start
</Button> </Button>

View File

@ -0,0 +1,82 @@
import type { CSSProperties } from 'hono/jsx'
import {
apps,
selectedApp,
setSidebarSection,
sidebarSection,
} from '../state'
import {
AppItem,
AppList,
SectionSwitcher,
SectionTab,
StatusDot,
} from '../styles'
interface AppSelectorProps {
render: () => void
onSelect?: () => void
collapsed?: boolean
large?: boolean
switcherStyle?: CSSProperties
listStyle?: CSSProperties
}
export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
const switchSection = (section: 'apps' | 'tools') => {
setSidebarSection(section)
render()
}
const regularApps = apps.filter(app => !app.tool)
const toolApps = apps.filter(app => app.tool)
const activeApps = sidebarSection === 'apps' ? regularApps : toolApps
return (
<>
{!collapsed && toolApps.length > 0 && (
<SectionSwitcher style={switcherStyle}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
Apps
</SectionTab>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
Tools
</SectionTab>
</SectionSwitcher>
)}
<AppList style={listStyle}>
{collapsed && (
<AppItem
href="/"
selected={!selectedApp ? true : undefined}
style={{ justifyContent: 'center', padding: '10px 12px' }}
title="Toes"
>
<span style={{ fontSize: 18 }}>🐾</span>
</AppItem>
)}
{activeApps.map(app => (
<AppItem
key={app.name}
href={`/app/${app.name}`}
onClick={onSelect}
large={large || undefined}
selected={app.name === selectedApp ? true : undefined}
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={collapsed ? app.name : undefined}
>
{collapsed ? (
<span style={{ fontSize: 18 }}>{app.icon}</span>
) : (
<>
<span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
{app.name}
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
</>
)}
</AppItem>
))}
</AppList>
</>
)
}

View File

@ -1,22 +1,60 @@
import { Styles } from '@because/forge' import { Styles } from '@because/forge'
import { Modal } from './modal' import { openNewAppModal } from '../modals'
import { apps, selectedApp } from '../state' import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
import { EmptyState, Layout } from '../styles' import {
HamburgerButton,
HamburgerLine,
Layout,
Main,
MainContent as MainContentContainer,
MainHeader,
MainTitle,
NewAppButton,
} from '../styles'
import { AppDetail } from './AppDetail' import { AppDetail } from './AppDetail'
import { AppSelector } from './AppSelector'
import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal'
import { SettingsPage } from './SettingsPage'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
export function Dashboard({ render }: { render: () => void }) { function MobileSidebar({ render }: { render: () => void }) {
const selected = apps.find(a => a.name === selectedApp) return (
<Main>
<MainHeader>
<MainTitle>
<HamburgerButton onClick={() => { setMobileSidebar(false); render() }} title="Hide apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
<a href="/" style={{ textDecoration: 'none', color: 'inherit' }}>🐾 Toes</a>
</MainTitle>
</MainHeader>
<MainContentContainer>
<AppSelector render={render} large />
<div style={{ padding: '12px 16px' }}>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
</div>
</MainContentContainer>
</Main>
)
}
function MainContent({ render }: { render: () => void }) {
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
const selected = apps.find(a => a.name === selectedApp)
if (selected) return <AppDetail app={selected} render={render} />
if (currentView === 'settings') return <SettingsPage render={render} />
return <DashboardLanding render={render} />
}
export function Dashboard({ render }: { render: () => void }) {
return ( return (
<Layout> <Layout>
<Styles /> <Styles />
<Sidebar render={render} /> {!isNarrow && <Sidebar render={render} />}
{selected ? ( <MainContent render={render} />
<AppDetail app={selected} render={render} />
) : (
<EmptyState>Select an app to view details</EmptyState>
)}
<Modal /> <Modal />
</Layout> </Layout>
) )

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