forked from defunkt/toes
Compare commits
374 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d53e8c8cf1 | |||
| 75b40b7ed1 | |||
| be6d4f58ff | |||
| 70d9fd6fbb | |||
| a8c4975ed5 | |||
| e5e82f3085 | |||
| 3baf943a53 | |||
| 7935469776 | |||
| b303fa1eef | |||
| c634d488b6 | |||
| 31ca1d9849 | |||
| 3743a01c28 | |||
| 875c89de18 | |||
| c0276389eb | |||
| 44dc7527fe | |||
| 2937fb2372 | |||
| c66a40df96 | |||
| 00c37bd9e8 | |||
| 99492a35e8 | |||
| cf018004a2 | |||
| efead147fb | |||
| 7ca1f94160 | |||
| e6afc0d797 | |||
| 211e441dd4 | |||
| 184a77f909 | |||
| 70f52a9b55 | |||
| eb2e5a4436 | |||
| db46287695 | |||
| 5ab634e245 | |||
| 5f4d512cdf | |||
| 5e13948be3 | |||
| 164abebeba | |||
| 433eb0b990 | |||
| 5ce65b3096 | |||
| 3c3a90b4f5 | |||
| 0e467a1bdf | |||
| 7588ef5564 | |||
| df05cbd3aa | |||
| 1f0c7bd099 | |||
| 4cc0ff2bed | |||
| 9a19c0a861 | |||
| b9f94a6c98 | |||
| c42c73fe70 | |||
| c3ad78f1be | |||
| 1e4d66cbe4 | |||
| a824d62058 | |||
| 33d91747af | |||
| 33d21777d3 | |||
| 62f936cdef | |||
| 3a6ed5d546 | |||
| 3328009af6 | |||
| c12d60119f | |||
| 99a3a25131 | |||
| 35a9053308 | |||
| 0abf03e64e | |||
| bbed8c49b7 | |||
| a9f8a3885d | |||
| 21c6c27c92 | |||
| 195be426f1 | |||
| 926e57e34e | |||
| 0a7c2b0f1f | |||
| aebafdf496 | |||
| 63b0709649 | |||
| 0471e7b26d | |||
| 18834fcd2b | |||
| 21e300df90 | |||
| 267e4e59f7 | |||
| 946cdb1794 | |||
| 0e943bda2a | |||
| eef2fabd71 | |||
| b410a74d15 | |||
| d9533032bc | |||
| e0347444aa | |||
| 423c9588da | |||
| ecae0b4a5c | |||
| f16201114e | |||
| 758ad67fd4 | |||
| 711a9db55e | |||
| 5954959208 | |||
| 0aa375f037 | |||
| 98c09dd843 | |||
| 26189e9e4d | |||
| d3b6d97bb6 | |||
| 27860c5e32 | |||
| 0a8287970d | |||
| d2339b8d44 | |||
| 61c0c90695 | |||
| 08e1df544a | |||
| e119aed205 | |||
| ad8ef49439 | |||
| fafef70a33 | |||
| 0af360cef2 | |||
| 079c13e311 | |||
| dfdd5c89b4 | |||
| e7dd220106 | |||
| c2264c42fc | |||
| 0ae4e6e9b2 | |||
| f1fc4fcde8 | |||
| dfb865e433 | |||
| f54cc401dc | |||
|
|
b152e0d3e8 | ||
| da0a67c159 | |||
| 845479fa91 | |||
| f8c5890e07 | |||
| 8f74f9daa0 | |||
| 327c7fd35d | |||
| 93d913f278 | |||
| aee5bb1099 | |||
| 7274910a26 | |||
| c0571978d2 | |||
| 14ac2ae471 | |||
| abdfaf8402 | |||
| 8be9fd7912 | |||
| 0aba9bde63 | |||
| 577bec0d5c | |||
| 002f0a64ef | |||
| a4a08bfe65 | |||
| 0f197849b6 | |||
| d29ab8e37f | |||
| c81513b0ea | |||
| 732b9944d6 | |||
| 03b4634e8b | |||
| 7a0a9fc731 | |||
| 6dc7ad8608 | |||
| 61ccce7d32 | |||
| e17580c366 | |||
| c081785d37 | |||
| 6e5d665846 | |||
| 30ed9b6466 | |||
| affd06bdee | |||
| 09f421ecb9 | |||
| 2dfb6de2ff | |||
| f5c5102fc8 | |||
| 18c585e6a6 | |||
| 360f4cedcf | |||
| aa167f5e29 | |||
| c1f280a41e | |||
| 30b0ac1fc3 | |||
| f475e1791e | |||
| c24c0fac45 | |||
| baa3712fa2 | |||
| c0b48c03da | |||
| d0290433f2 | |||
| 56db56976b | |||
| 9b150543b0 | |||
| 613898395c | |||
| 71091f20a1 | |||
| 64d5295fde | |||
| 2b06d9afdf | |||
| 82c8fc42da | |||
| b99dd16343 | |||
| 52cf99b56d | |||
| 310994b77c | |||
| 0efc25834c | |||
| 13fa2b202a | |||
| 65d4fe85cf | |||
| 8fba4cccba | |||
| fdc14a5021 | |||
| 5e21323b54 | |||
| c7f8f09ba9 | |||
| be7a7bd35b | |||
| 35341600c1 | |||
| 2046af1407 | |||
| 4b920a247d | |||
| 7ee9163f76 | |||
| f9b67c03bb | |||
| dd5d9254c0 | |||
| 01f23ace16 | |||
| 5f1de651eb | |||
| 460d625f60 | |||
| 3ad7145229 | |||
| a87f0a9651 | |||
| d2b0eb410f | |||
| ffe1df22e6 | |||
| 7f82a37c63 | |||
| 6055b9798d | |||
| f7397dc060 | |||
| d69dc6ae9d | |||
| 4853ee4f7a | |||
|
|
74f9062a89 | ||
|
|
55316027c0 | ||
| cfba207077 | |||
| 702019279a | |||
| 141622f86f | |||
| 526678e87a | |||
| dc570cc6e9 | |||
| d29e306e61 | |||
| 671f51ca0c | |||
| 604ac96b30 | |||
| d082af4e33 | |||
| 9bce15b871 | |||
| 7ab27f2767 | |||
| 45b1903e6b | |||
| 68274d8651 | |||
| 98a1c1ad97 | |||
| 6d02f1db3f | |||
| b0c5a11cde | |||
| 029e349c5b | |||
| 1a71656508 | |||
| 363a82a845 | |||
| 271bf018b8 | |||
|
|
488c643342 | ||
|
|
8fc54bd349 | ||
|
|
3cbb25a82a | ||
| 87d0ff50c1 | |||
| 0499060676 | |||
| 51e42dc538 | |||
| 4d42b48c8f | |||
| f910664828 | |||
| 365b5d2365 | |||
| 520606ccb9 | |||
| 26409010a8 | |||
| 236e8ff38e | |||
| 4aebd6a087 | |||
| 36c7913b6c | |||
| f26b382fa6 | |||
| b0323c3655 | |||
| 7ea806b778 | |||
| 9e4629ac2f | |||
| aaf4660816 | |||
| 18cf4243fa | |||
| a041f137c0 | |||
| c16fdaa2a2 | |||
| fca779b064 | |||
| 8e71699ceb | |||
| dc1decafec | |||
| 5510432b42 | |||
| 881517a88f | |||
| 5477470551 | |||
| 5b1a970da1 | |||
| 09e21c738b | |||
| 971ebef21c | |||
| 071f1a02b5 | |||
| 7b12dc9a9b | |||
| c5672e57bd | |||
| b538626baa | |||
| 3004845eee | |||
| 888f12a8f1 | |||
| 96083b640f | |||
| fecc074757 | |||
| 3736202020 | |||
| 82ff55ba99 | |||
| 1dc7b76b31 | |||
| caac6877d7 | |||
| 86dacb0a74 | |||
| 9c0762c882 | |||
| f085d78fc1 | |||
| 6f2f07059d | |||
| 6ba3cdaf14 | |||
| 7f2343fc04 | |||
| f3cc26252c | |||
| 6b70af2943 | |||
| 09f77f099f | |||
| d1caf3fbf4 | |||
| 8fc226ce09 | |||
| fbb860091c | |||
| 1015e20cf9 | |||
| 565f4924e8 | |||
| c49cc2e078 | |||
| bf14ba4ba1 | |||
| 271ff151a1 | |||
| 3eef4c2a0e | |||
| 9649666195 | |||
| fabdd084cb | |||
| 65e19d27e2 | |||
| 6afefcec5b | |||
| 1b563106fe | |||
| c10ebe3c98 | |||
| 2f4d4f5c19 | |||
| 720c0e76fb | |||
|
|
8b31fa3f19 | ||
|
|
50e5c97beb | ||
| 543b5d08bc | |||
|
|
a91f400100 | ||
|
|
14d758ef42 | ||
|
|
cb822bfddc | ||
|
|
322c20bb72 | ||
|
|
6912bc0cdf | ||
|
|
4a31d7bb69 | ||
| 75af5f3d31 | |||
| ecac19a07f | |||
| 512d9fe96b | |||
|
|
ee9c4a1d0a | ||
|
|
bbdcefd1f7 | ||
|
|
7a79133d78 | ||
|
|
a7d4e210c2 | ||
|
|
f0bef491a6 | ||
|
|
2f4d609290 | ||
| a96aa1d2dc | |||
| 14281a1bf5 | |||
|
|
eb8ef0dd4d | ||
| 0e3699da5a | |||
| 9c128eaddc | |||
| 6fa03413bd | |||
| 28e8d0db2c | |||
| 2d7ec7d53a | |||
| 681a3f2f9e | |||
| b6e9ec73de | |||
| 10154dfd4f | |||
|
|
c183fe42e9 | ||
|
|
74d7d2f578 | ||
|
|
d94a4421f9 | ||
|
|
c9986277ab | ||
|
|
f10830ee9b | ||
| 89ea6f586a | |||
| 725f893592 | |||
| 579b082b48 | |||
| bffa4236e7 | |||
| 4a2223d3d7 | |||
| 79a0471383 | |||
| 115d3199e8 | |||
| 891b08ecd8 | |||
| 302ef63485 | |||
| 955aab2152 | |||
| cb0e748068 | |||
| 7c04aceef9 | |||
| 47030d7d36 | |||
| 081c728d12 | |||
| d89a58c0ab | |||
| 8f37274cee | |||
| e8a638d11d | |||
| 86a91469be | |||
| 9517f6d4b2 | |||
| d43e1c1c17 | |||
| 1685cc135d | |||
| d4e8975200 | |||
|
|
823cbb2317 | ||
|
|
b447f7d0ca | ||
|
|
128afcfef8 | ||
|
|
f96c599f49 | ||
|
|
4d3083764a | ||
|
|
a1aa37297f | ||
|
|
d6ae39ac15 | ||
| 3ef7eba0d9 | |||
|
|
4b3ab78ae4 | ||
|
|
31d9ad4520 | ||
| eaae9ae993 | |||
|
|
8fa7dd9993 | ||
| 96289f7e30 | |||
| b43c1b4660 | |||
|
|
396f214eae | ||
| b90a90ae92 | |||
|
|
e6046dafee | ||
|
|
7a98850a57 | ||
|
|
002282ec72 | ||
|
|
ae16734708 | ||
|
|
92cf18f0b5 | ||
|
|
c224dd25a9 | ||
|
|
e7f5e8a636 | ||
| 8f91b676e9 | |||
| a396f740a5 | |||
| 02fca1313c | |||
| 9bf3973020 | |||
| 0d81406190 | |||
| b1fc698b9a | |||
| a3f36a0c98 | |||
| b2d7c72fee | |||
| 2ef00c9d53 | |||
| 067fd12e94 | |||
| 0d572b4b5d | |||
| 913f8f34d7 | |||
| 11caa8fe19 | |||
| 303d2dfc72 | |||
| d8769b2d9d | |||
|
|
ed200431f2 | ||
|
|
52bfa783e1 | ||
|
|
6f03954850 | ||
|
|
d99f80cd0e | ||
|
|
cca93189e0 | ||
| dfb70c84f5 | |||
| 3cf26c7154 | |||
| 81d0e5d2fd | |||
| f14a731cae | |||
| a58c42e0d4 |
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,6 +1,13 @@
|
||||||
|
.sandlot/
|
||||||
|
.rev/
|
||||||
|
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
pub/client/index.js
|
pub/client/index.js
|
||||||
|
toes/
|
||||||
|
|
||||||
|
# generated
|
||||||
|
src/lib/templates.data.ts
|
||||||
|
|
||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
|
|
@ -33,3 +40,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
|
||||||
|
|
|
||||||
300
CLAUDE.md
300
CLAUDE.md
|
|
@ -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,188 @@ 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** + **ansis** 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 (rebuilds client bundle on change)
|
||||||
|
bun run start # Production (generates templates, then runs server)
|
||||||
|
bun run check # Type check
|
||||||
|
bun run test # Tests
|
||||||
|
bun run build # Build client JS bundle (pub/client/index.js)
|
||||||
|
bun run release # Build release tarball for the Pi
|
||||||
```
|
```
|
||||||
|
|
||||||
## App Structure
|
## Building & Releasing
|
||||||
|
|
||||||
```tsx
|
`bun run release` (runs `scripts/release.sh`) produces a self-contained tarball the Pi can install without building anything:
|
||||||
// apps/example/index.tsx
|
|
||||||
import { Hype } from "@because/hype"
|
1. Client JS bundle → `pub/client/index.js`
|
||||||
const app = new Hype()
|
2. Embedded templates → `src/lib/templates.data.ts` (generated from `templates/` by `scripts/embed-templates.ts`)
|
||||||
app.get("/", (c) => c.html(<h1>Content</h1>))
|
3. Pre-built bare git repos for bundled apps → `dist/repos/*.git` (built by `scripts/build-repos.sh`)
|
||||||
export default app.defaults
|
4. Cross-compiled CLI binary → `dist/toes` (linux-arm64, built by `scripts/build.ts`)
|
||||||
|
5. Everything staged and packed into `dist/toes-<version>.tar.gz`
|
||||||
|
|
||||||
|
The Pi installer (`install/install.sh`) downloads this tarball, runs `bun install` for the server and all bundled apps (in parallel), copies pre-built repos and CLI binary into place, and starts the systemd service. No git commands, no compilation on the Pi.
|
||||||
|
|
||||||
|
### Key scripts
|
||||||
|
|
||||||
|
- `scripts/build.sh` -- Builds client JS bundle only (used during dev)
|
||||||
|
- `scripts/build-repos.sh` -- Pre-builds bare git repos for bundled apps (excludes node_modules, snapshots, logs)
|
||||||
|
- `scripts/release.sh` -- Full release pipeline: client + templates + repos + CLI → tarball
|
||||||
|
- `scripts/build.ts` -- CLI binary compiler (current platform or `--all` for cross-compile)
|
||||||
|
- `scripts/embed-templates.ts` -- Generates `src/lib/templates.data.ts` from `templates/`
|
||||||
|
- `install/install.sh` -- Pi installer, downloads release tarball and sets everything up
|
||||||
|
- `scripts/remote-install.sh` -- Runs the installer on a remote Pi over SSH
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
server/ # HTTP server and process management ($)
|
||||||
|
client/ # Browser-side dashboard
|
||||||
|
shared/ # Types shared between server and client (@)
|
||||||
|
lib/ # Code shared between CLI and server (%)
|
||||||
|
cli/ # CLI tool
|
||||||
|
tools/ # @because/toes package exports
|
||||||
|
pages/ # Hype page routes
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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 via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management.
|
||||||
|
- `api/events.ts` -- SSE stream for discrete lifecycle events (`/stream`). Used by app processes (not the dashboard). Includes a 60s heartbeat ping.
|
||||||
|
- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app delete, app reload (triggered by git tool after deploy), file watch SSE.
|
||||||
|
- `api/system.ts` -- System info, metrics (CPU/RAM/disk per-app via `ps`/`du`), unified log aggregation, perf timing toggle, update check/apply, server restart.
|
||||||
|
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), on-demand CLI binary builds (`/dist/:file`), install script endpoint (`/install`), SPA catch-all routes, subdomain proxy (including WebSocket). Initializes apps.
|
||||||
|
- `mdns.ts` -- mDNS publishing via `avahi-publish` on Linux production. Publishes `<app>.hostname.local` A records pointing to local IP. Auto-republishes on unexpected exit with exponential backoff.
|
||||||
|
- `proxy.ts` -- Subdomain routing: extracts subdomain from `*.localhost` or `*.X.local`, proxies HTTP requests and WebSocket connections to the app's port. Sets `x-app-url` header. Optional perf timing.
|
||||||
|
- `shell.tsx` -- Minimal HTML shell for the SPA.
|
||||||
|
- `sync.ts` -- Re-exports `computeHash` and `generateManifest` from `%sync` (lib).
|
||||||
|
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY, plain logs otherwise). Debounced rendering at 50ms.
|
||||||
|
- `tunnels.ts` -- Public tunnel management via `@because/sneaker`. Persists tunnel config in `TOES_DIR/tunnels.json`. Auto-reconnects on drop with exponential backoff (max 10 attempts). Manages share/unshare lifecycle.
|
||||||
|
|
||||||
### 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.
|
||||||
|
- `router.ts` -- Client-side router. Intercepts link clicks, handles popstate, maps URL paths to state (`/app/:name/:tab`, `/settings`, dashboard tabs).
|
||||||
|
- `ansi.ts` -- ANSI escape code handling for log rendering.
|
||||||
|
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection, DashboardLanding, SettingsPage, Vitals, UnifiedLogs, Urls, emoji-picker, modal.
|
||||||
|
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
|
||||||
|
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout, logs, misc).
|
||||||
|
- `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**: `status` (list or info), `new`, `get`, `open`, `rename`, `rm`
|
||||||
|
- **Lifecycle**: `start`, `stop`, `restart`, `share`, `unshare`, `logs`, `metrics`, `cron` (list/log/status/run)
|
||||||
|
- **Config**: `env` (list/set/rm, per-app or `--global`), `perf` (toggle request timing)
|
||||||
|
- **Hidden**: `list`, `info`, `log`, `version`, `shell`
|
||||||
|
|
||||||
|
Some commands (`shell`, `get`, `open`) are disabled when running over SSH (`USER=cli`).
|
||||||
|
|
||||||
|
### Shared (`src/shared/`)
|
||||||
|
|
||||||
|
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
|
||||||
|
|
||||||
|
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`, `DEFAULT_EMOJI`, `VALID_NAME`
|
||||||
|
- `events.ts` -- `ToesEvent`, `ToesEventType`, `ToesEventInput` type definitions
|
||||||
|
- `urls.ts` -- `toSubdomain()` and `buildAppUrl()` for subdomain URL construction
|
||||||
|
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
|
||||||
|
|
||||||
|
### Lib (`src/lib/`)
|
||||||
|
|
||||||
|
Server-side code shared between CLI and server. Can use Node APIs.
|
||||||
|
|
||||||
|
- `config.ts` -- `HOSTNAME` and `LOCAL_HOST` (`hostname.local`) constants
|
||||||
|
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa), reads embedded templates from `templates.data.ts`
|
||||||
|
- `templates.data.ts` -- Generated file containing embedded template contents (built by `scripts/embed-templates.ts`)
|
||||||
|
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
|
||||||
|
|
||||||
|
### Tools Package (`src/tools/`)
|
||||||
|
|
||||||
|
The `@because/toes` package that apps/tools import. Published exports:
|
||||||
|
|
||||||
|
- `@because/toes` -- re-exports `computeHash`, `generateManifest`, `FileInfo`, `Manifest`, `VALID_NAME` (`src/index.ts` -> `src/tools/index.ts`)
|
||||||
|
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`, `on` (event subscription), `appUrl`, `VALID_NAME`
|
||||||
|
|
||||||
|
### Pages (`src/pages/`)
|
||||||
|
|
||||||
|
Hype page routes. `index.tsx` renders the Shell.
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### App Lifecycle
|
||||||
|
|
||||||
|
States: `invalid` | `error` | `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
|
||||||
|
|
||||||
|
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
|
||||||
|
- Spawn: `Bun.spawn(['bun', 'run', 'toes'])` with `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR`, `DATA_ROOT`, `TOES_URL`, `TOES_DIR`, `NO_AUTOPORT`, plus per-app env vars from `loadAppEnv()`
|
||||||
|
- Startup: runs `bun install` first if `node_modules/` missing, then polls `/ok` every 500ms (30s timeout)
|
||||||
|
- Health checks: every 30s to `/ok` (5s timeout), 3 consecutive failures trigger restart
|
||||||
|
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), max 5 attempts, resets after 60s stable run. State becomes `error` after max attempts.
|
||||||
|
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
|
||||||
|
- On startup, kills stale processes on ports 3001-3100 and orphaned `bun run toes` processes
|
||||||
|
|
||||||
|
### Subdomain Proxy
|
||||||
|
|
||||||
|
Every app gets a subdomain: `<app>.localhost` (dev) or `<app>.hostname.local` (prod). The server's fetch handler (`index.tsx`) checks for subdomains first and proxies to the app's port. WebSocket connections are also proxied via Bun's `server.upgrade()` with upstream bridging. The `x-app-url` header is set so apps know their public URL.
|
||||||
|
|
||||||
|
### Tools vs Apps
|
||||||
|
|
||||||
|
Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server via subdomain: `/tool/:tool?app=foo` -> `http://<tool>.host/?app=foo`. Tool API calls can also be proxied: `/api/tools/:tool/*` -> `http://localhost:<toolPort>/*`.
|
||||||
|
|
||||||
|
The `apps` field in package.json controls whether a tool shows on app detail pages (`false` to hide). The `dashboard` field controls whether a tool shows on the dashboard landing page.
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
Apps use git for version control. Each app has a bare git repo at `DATA_DIR/repos/<name>.git`. Deploying is a `git push` to the server's git tool, which extracts HEAD into `APPS_DIR/<name>/` and reloads the app. History, diffing, and rollback use standard git commands.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Per-app env files in `TOES_DIR/env/`:
|
||||||
|
- `_global.env` -- shared by all apps
|
||||||
|
- `<appname>.env` -- per-app overrides
|
||||||
|
|
||||||
|
The server sets these on each app process: `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR` (per-app at `TOES_DIR/<name>`), `DATA_ROOT`, `TOES_DIR`, `TOES_URL`, `NO_AUTOPORT`.
|
||||||
|
|
||||||
|
### 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:reload`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`.
|
||||||
|
|
||||||
## Coding Guidelines
|
## Coding Guidelines
|
||||||
|
|
||||||
|
|
@ -211,3 +258,22 @@ function start(app: App): void {
|
||||||
console.log(`Starting ${app.config.name}`)
|
console.log(`Starting ${app.config.name}`)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Install & Deployment
|
||||||
|
|
||||||
|
The install script (`install/install.sh`) is designed to run on a fresh Pi or as an updater:
|
||||||
|
|
||||||
|
1. Installs system packages (git, fish, avahi-utils, etc.) via apt
|
||||||
|
2. Installs Bun and grants `cap_net_bind_service`
|
||||||
|
3. Downloads and extracts the release tarball into `~/toes`
|
||||||
|
4. Runs `bun install` for the server
|
||||||
|
5. Copies bundled apps to `~/apps/` and runs `bun install` for each (in parallel)
|
||||||
|
6. Copies pre-built bare repos to `~/data/repos/` (for git-based versioning)
|
||||||
|
7. Installs the pre-built CLI binary to `/usr/local/bin/toes`
|
||||||
|
8. Sets up SSH access and the systemd service
|
||||||
|
|
||||||
|
The release tarball URL is configured as `RELEASE_URL` at the top of `install/install.sh`.
|
||||||
|
|
||||||
|
## Writing Apps and Tools
|
||||||
|
|
||||||
|
See `docs/GUIDE.md` for the guide to writing toes apps and tools.
|
||||||
|
|
|
||||||
98
README.md
98
README.md
|
|
@ -1,42 +1,82 @@
|
||||||
# 🐾 Toes
|
# 🐾 Toes
|
||||||
|
|
||||||
Toes is a personal web server you run in your home.
|
Personal web appliance you run on your home network.
|
||||||
|
|
||||||
Plug it in, turn it on, and forget about the cloud.
|
Plug it in, turn it on, and forget about the cloud.
|
||||||
|
|
||||||
## quickstart
|
## Development
|
||||||
|
|
||||||
1. Plug in and turn on your Toes computer.
|
|
||||||
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
|
|
||||||
3. Visit https://toes.local to get started!
|
|
||||||
|
|
||||||
## features
|
|
||||||
- Hosts bun/hono/hype webapps - both SSR and SPA.
|
|
||||||
- `toes` CLI for pushing and pulling from your server.
|
|
||||||
- `toes` CLI for local dev mode.
|
|
||||||
- https://toes.local web UI for managing your projects.
|
|
||||||
- Per-branch staging environments for Claude.
|
|
||||||
|
|
||||||
## cli configuration
|
|
||||||
|
|
||||||
by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
toes config # show current host
|
bun run dev # Hot reload (rebuilds client bundle on change)
|
||||||
TOES_URL=http://192.168.1.50:3000 toes list # full URL
|
bun run start # Production mode
|
||||||
TOES_HOST=mypi.local toes list # hostname (port 80)
|
bun run check # Type check
|
||||||
TOES_HOST=mypi.local PORT=3000 toes list # hostname + port
|
bun run test # Tests
|
||||||
|
bun run build # Build client JS bundle
|
||||||
|
bun run release # Build a release tarball for the Pi
|
||||||
```
|
```
|
||||||
|
|
||||||
set `NODE_ENV=production` to default to `toes.local:80`.
|
### Releasing
|
||||||
|
|
||||||
## fun stuff
|
`bun run release` builds everything the Pi needs into a single tarball:
|
||||||
|
|
||||||
- textOS (TODO, more?)
|
1. Client JS bundle (`pub/client/index.js`)
|
||||||
- Claude that knows about all your toes APIS and your projects.
|
2. Embedded templates (`src/lib/templates.data.ts`)
|
||||||
- HTTPS Tunnel for sharing your apps with the world.
|
3. Pre-built bare git repos for bundled apps (`dist/repos/`)
|
||||||
- Charts and graphs in the webUI.
|
4. Cross-compiled CLI binary for linux-arm64 (`dist/toes`)
|
||||||
|
|
||||||
## february goal
|
Output: `dist/toes-<version>.tar.gz`
|
||||||
|
|
||||||
- [ ] Corey and Chris are running Toes servers on their home networks, hosting personal projects and games.
|
The Pi does zero building — it untars, runs `bun install`, and starts. Upload the tarball to wherever `RELEASE_URL` in `install/install.sh` points (currently `https://toes.dev/release/latest.tar.gz`).
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
|--------|-------------|
|
||||||
|
| `scripts/build.sh` | Builds the client JS bundle into `pub/client/index.js` |
|
||||||
|
| `scripts/build-repos.sh` | Pre-builds bare git repos for bundled apps in `dist/repos/` |
|
||||||
|
| `scripts/release.sh` | Full release: client + templates + repos + CLI → tarball |
|
||||||
|
| `scripts/build.ts` | Builds the CLI binary (current platform or cross-compile) |
|
||||||
|
| `scripts/embed-templates.ts` | Generates `src/lib/templates.data.ts` from `templates/` |
|
||||||
|
| `scripts/setup-ssh.sh` | Configures SSH access for the `cli` user on the Pi |
|
||||||
|
| `scripts/remote-install.sh` | Runs the installer on a remote Pi over SSH |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Toes runs on a Raspberry Pi 5 with a `toes` user and passwordless sudo.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://toes.dev/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer downloads the release tarball, installs bun and system packages, runs `bun install` for the server and all bundled apps (in parallel), copies the pre-built CLI and git repos into place, and starts the systemd service.
|
||||||
|
|
||||||
|
Dashboard: `http://<hostname>.local`
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Hosts Bun/Hype webapps (SSR and SPA)
|
||||||
|
- `git push` Heroku-style deploys
|
||||||
|
- Web dashboard with real-time status, logs, and tools
|
||||||
|
- `toes` CLI (local install or SSH)
|
||||||
|
- Per-app environment variables, cron jobs, metrics
|
||||||
|
- Public sharing via tunnels
|
||||||
|
|
||||||
|
## SSH CLI
|
||||||
|
|
||||||
|
Manage your server from any machine on the network — no install required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh cli@toes.local # interactive shell with tab completion
|
||||||
|
ssh cli@toes.local list # run a single command
|
||||||
|
ssh cli@toes.local logs fog # stream logs for an app
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Configuration
|
||||||
|
|
||||||
|
By default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes config # show current host
|
||||||
|
TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
|
||||||
|
TOES_URL=http://mypi.local toes list # connect to hostname
|
||||||
|
```
|
||||||
|
|
|
||||||
55
TODO.txt
55
TODO.txt
|
|
@ -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
|
|
||||||
[ ] ...
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"name": "basic",
|
|
||||||
"module": "src/index.ts",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"toes": "bun run --watch index.tsx",
|
|
||||||
"start": "bun toes",
|
|
||||||
"dev": "bun run --hot index.tsx"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@because/forge": "^0.0.1",
|
|
||||||
"@because/hype": "^0.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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=="],
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
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, resolve, extname, basename } from 'path'
|
||||||
import type { Child } from 'hono/jsx'
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
const safePath = (base: string, ...segments: string[]) => {
|
||||||
|
const norm = resolve(base)
|
||||||
|
const full = resolve(norm, ...segments)
|
||||||
|
if (!full.startsWith(norm + '/') && full !== norm) return null
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
const Container = define('Container', {
|
const Container = define('Container', {
|
||||||
|
|
@ -88,6 +95,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 +200,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 +256,7 @@ interface LayoutProps {
|
||||||
title: string
|
title: string
|
||||||
children: Child
|
children: Child
|
||||||
highlight?: boolean
|
highlight?: boolean
|
||||||
|
editable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileMemoryScript = `
|
const fileMemoryScript = `
|
||||||
|
|
@ -213,27 +264,21 @@ const fileMemoryScript = `
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.search);
|
||||||
var app = params.get('app');
|
var app = params.get('app');
|
||||||
var file = params.get('file');
|
var file = params.get('file');
|
||||||
var version = params.get('version') || 'current';
|
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
var key = 'code-app:' + app + ':' + version + ':file';
|
var key = 'code-app:' + app + ':file';
|
||||||
if (params.has('file')) {
|
if (params.has('file')) {
|
||||||
// Explicit file param (even empty) - save it
|
|
||||||
if (file) localStorage.setItem(key, file);
|
if (file) localStorage.setItem(key, file);
|
||||||
else localStorage.removeItem(key);
|
else localStorage.removeItem(key);
|
||||||
} else {
|
} else {
|
||||||
// No file param - restore saved location
|
|
||||||
var saved = localStorage.getItem(key);
|
var saved = localStorage.getItem(key);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
var url = '/?app=' + encodeURIComponent(app);
|
window.location.replace('/?app=' + encodeURIComponent(app) + '&file=' + encodeURIComponent(saved));
|
||||||
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
|
|
||||||
url += '&file=' + encodeURIComponent(saved);
|
|
||||||
window.location.replace(url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
`
|
`
|
||||||
|
|
||||||
function Layout({ title, children, highlight }: LayoutProps) {
|
function Layout({ title, children, highlight, editable }: LayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -241,40 +286,56 @@ 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',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
app.get('/raw', async c => {
|
app.get('/raw', async c => {
|
||||||
const appName = c.req.query('app')
|
const appName = c.req.query('app')
|
||||||
const version = c.req.query('version') || 'current'
|
|
||||||
const filePath = c.req.query('file')
|
const filePath = c.req.query('file')
|
||||||
|
|
||||||
if (!appName || !filePath) {
|
if (!appName || !filePath) {
|
||||||
return c.text('Missing app or file parameter', 400)
|
return c.text('Missing app or file parameter', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = join(APPS_DIR, appName, version, filePath)
|
const fullPath = safePath(APPS_DIR, appName, filePath)
|
||||||
|
if (!fullPath) return c.text('Invalid path', 400)
|
||||||
const file = Bun.file(fullPath)
|
const file = Bun.file(fullPath)
|
||||||
|
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
|
|
@ -284,6 +345,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 filePath = c.req.query('file')
|
||||||
|
|
||||||
|
if (!appName || !filePath) {
|
||||||
|
return c.text('Missing app or file parameter', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = safePath(APPS_DIR, appName, filePath)
|
||||||
|
if (!fullPath) return c.text('Invalid path', 400)
|
||||||
|
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 })
|
||||||
|
|
@ -305,10 +386,9 @@ async function listFiles(appPath: string, subPath: string = '') {
|
||||||
interface BreadcrumbProps {
|
interface BreadcrumbProps {
|
||||||
appName: string
|
appName: string
|
||||||
filePath: string
|
filePath: string
|
||||||
versionParam: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
|
function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
|
||||||
const parts = filePath ? filePath.split('/').filter(Boolean) : []
|
const parts = filePath ? filePath.split('/').filter(Boolean) : []
|
||||||
const crumbs: { name: string; path: string }[] = []
|
const crumbs: { name: string; path: string }[] = []
|
||||||
|
|
||||||
|
|
@ -321,7 +401,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
|
||||||
return (
|
return (
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
{crumbs.length > 0 ? (
|
{crumbs.length > 0 ? (
|
||||||
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=`}>{appName}</BreadcrumbLink>
|
<BreadcrumbLink href={`/?app=${appName}&file=`}>{appName}</BreadcrumbLink>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
|
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
|
||||||
)}
|
)}
|
||||||
|
|
@ -331,7 +411,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
|
||||||
{i === crumbs.length - 1 ? (
|
{i === crumbs.length - 1 ? (
|
||||||
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
|
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
|
<BreadcrumbLink href={`/?app=${appName}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
|
|
@ -373,10 +453,32 @@ 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')
|
||||||
const version = c.req.query('version') || 'current'
|
|
||||||
const filePath = c.req.query('file') || ''
|
const filePath = c.req.query('file') || ''
|
||||||
|
|
||||||
if (!appName) {
|
if (!appName) {
|
||||||
|
|
@ -387,19 +489,34 @@ app.get('/', async c => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const appPath = join(APPS_DIR, appName, version)
|
const appPath = safePath(APPS_DIR, appName)
|
||||||
|
if (!appPath) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Code Browser">
|
||||||
|
<ErrorBox>Invalid app name</ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stat(appPath)
|
await stat(appPath)
|
||||||
} catch {
|
} catch {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title="Code Browser">
|
<Layout title="Code Browser">
|
||||||
<ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
|
<ErrorBox>App "{appName}" not found</ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = safePath(appPath, filePath)
|
||||||
|
if (!fullPath) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Code Browser">
|
||||||
|
<ErrorBox>Invalid file path</ErrorBox>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = join(appPath, filePath)
|
|
||||||
let fileStats
|
let fileStats
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -412,18 +529,16 @@ app.get('/', async c => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionParam = version !== 'current' ? `&version=${version}` : ''
|
|
||||||
|
|
||||||
if (fileStats.isFile()) {
|
if (fileStats.isFile()) {
|
||||||
const filename = basename(fullPath)
|
const filename = basename(fullPath)
|
||||||
const fileType = getFileType(filename)
|
const fileType = getFileType(filename)
|
||||||
const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
|
const rawUrl = `/raw?app=${appName}&file=${filePath}`
|
||||||
const downloadUrl = `${rawUrl}&download=1`
|
const downloadUrl = `${rawUrl}&download=1`
|
||||||
|
|
||||||
if (fileType === 'image') {
|
if (fileType === 'image') {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`}>
|
<Layout title={`${appName}/${filePath}`}>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<MediaContainer>
|
<MediaContainer>
|
||||||
<MediaHeader>
|
<MediaHeader>
|
||||||
<span>{filename}</span>
|
<span>{filename}</span>
|
||||||
|
|
@ -440,7 +555,7 @@ app.get('/', async c => {
|
||||||
if (fileType === 'audio') {
|
if (fileType === 'audio') {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`}>
|
<Layout title={`${appName}/${filePath}`}>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<MediaContainer>
|
<MediaContainer>
|
||||||
<MediaHeader>
|
<MediaHeader>
|
||||||
<span>{filename}</span>
|
<span>{filename}</span>
|
||||||
|
|
@ -457,7 +572,7 @@ app.get('/', async c => {
|
||||||
if (fileType === 'video') {
|
if (fileType === 'video') {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`}>
|
<Layout title={`${appName}/${filePath}`}>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<MediaContainer>
|
<MediaContainer>
|
||||||
<MediaHeader>
|
<MediaHeader>
|
||||||
<span>{filename}</span>
|
<span>{filename}</span>
|
||||||
|
|
@ -478,12 +593,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}&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} />
|
||||||
|
<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}&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} />
|
||||||
<CodeBlock>
|
<CodeBlock>
|
||||||
<CodeHeader>{filename}</CodeHeader>
|
<CodeHeader>
|
||||||
|
<span>{filename}</span>
|
||||||
|
<EditLink href={`/?app=${appName}&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>
|
||||||
|
|
@ -494,11 +688,11 @@ app.get('/', async c => {
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
|
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<FileList>
|
<FileList>
|
||||||
{files.map(file => (
|
{files.map(file => (
|
||||||
<FileItem>
|
<FileItem>
|
||||||
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
|
<FileLink href={`/?app=${appName}&file=${file.path}`}>
|
||||||
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
|
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
|
||||||
<span>{file.name}</span>
|
<span>{file.name}</span>
|
||||||
</FileLink>
|
</FileLink>
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { readdir } from 'fs/promises'
|
|
||||||
import { existsSync } from 'fs'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { isValidSchedule, toCronExpr, type CronJob, type Schedule } from './schedules'
|
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
|
||||||
|
|
||||||
export async function getApps(): Promise<string[]> {
|
|
||||||
const entries = await readdir(APPS_DIR, { withFileTypes: true })
|
|
||||||
const apps: string[] = []
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isDirectory()) continue
|
|
||||||
// Check if it has a current symlink (valid app)
|
|
||||||
if (existsSync(join(APPS_DIR, entry.name, 'current'))) {
|
|
||||||
apps.push(entry.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return apps.sort()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function discoverCronJobs(): Promise<CronJob[]> {
|
|
||||||
const jobs: CronJob[] = []
|
|
||||||
const apps = await readdir(APPS_DIR, { withFileTypes: true })
|
|
||||||
|
|
||||||
for (const app of apps) {
|
|
||||||
if (!app.isDirectory()) continue
|
|
||||||
|
|
||||||
const cronDir = join(APPS_DIR, app.name, 'current', 'cron')
|
|
||||||
if (!existsSync(cronDir)) continue
|
|
||||||
|
|
||||||
const files = await readdir(cronDir)
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file.endsWith('.ts')) continue
|
|
||||||
|
|
||||||
const filePath = join(cronDir, file)
|
|
||||||
const name = file.replace(/\.ts$/, '')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mod = await import(filePath)
|
|
||||||
const schedule = mod.schedule as Schedule
|
|
||||||
|
|
||||||
if (!isValidSchedule(schedule)) {
|
|
||||||
console.error(`Invalid schedule in ${filePath}: ${schedule}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs.push({
|
|
||||||
id: `${app.name}:${name}`,
|
|
||||||
app: app.name,
|
|
||||||
name,
|
|
||||||
file: filePath,
|
|
||||||
schedule,
|
|
||||||
cronExpr: toCronExpr(schedule),
|
|
||||||
state: 'idle',
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to load cron file ${filePath}:`, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return jobs
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { join } from 'path'
|
|
||||||
import type { CronJob } from './schedules'
|
|
||||||
import { getNextRun } from './scheduler'
|
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
|
||||||
|
|
||||||
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
|
|
||||||
if (job.state === 'disabled') return
|
|
||||||
|
|
||||||
job.state = 'running'
|
|
||||||
job.lastRun = Date.now()
|
|
||||||
onUpdate()
|
|
||||||
|
|
||||||
const cwd = join(APPS_DIR, job.app, 'current')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const proc = Bun.spawn(['bun', 'run', job.file], {
|
|
||||||
cwd,
|
|
||||||
env: { ...process.env },
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
})
|
|
||||||
|
|
||||||
const [stdout, stderr] = await Promise.all([
|
|
||||||
new Response(proc.stdout).text(),
|
|
||||||
new Response(proc.stderr).text(),
|
|
||||||
])
|
|
||||||
|
|
||||||
const code = await proc.exited
|
|
||||||
|
|
||||||
job.lastDuration = Date.now() - job.lastRun
|
|
||||||
job.lastExitCode = code
|
|
||||||
job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined
|
|
||||||
job.state = 'idle'
|
|
||||||
job.nextRun = getNextRun(job.id)
|
|
||||||
|
|
||||||
// Log result
|
|
||||||
console.log(`[cron] ${job.id} finished: code=${code} duration=${job.lastDuration}ms`)
|
|
||||||
if (stdout) console.log(stdout)
|
|
||||||
if (stderr) console.error(stderr)
|
|
||||||
} catch (e) {
|
|
||||||
job.lastDuration = Date.now() - job.lastRun
|
|
||||||
job.lastExitCode = 1
|
|
||||||
job.lastError = e instanceof Error ? e.message : String(e)
|
|
||||||
job.state = 'idle'
|
|
||||||
console.error(`[cron] ${job.id} failed:`, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate()
|
|
||||||
}
|
|
||||||
49
apps/cron/bun.lock
Normal file
49
apps/cron/bun.lock
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "cron",
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "^0.0.1",
|
||||||
|
"@because/hype": "^0.0.2",
|
||||||
|
"@because/toes": "^0.0.8",
|
||||||
|
"croner": "^9.1.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||||
|
|
||||||
|
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||||
|
|
||||||
|
"@because/sneaker": ["@because/sneaker@0.0.1", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.1.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-rN9hc13ofap+7SvfShJkTJQYBcViCiElyfb8FBMzP1SKIe8B71csZeLh+Ujye/5538ojWfM/5hRRPJ+Aa/0A+g=="],
|
||||||
|
|
||||||
|
"@because/toes": ["@because/toes@0.0.8", "https://npm.nose.space/@because/toes/-/toes-0.0.8.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.1", "commander": "^14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-ei4X+yX97dCRqAHSfsVnE4vAIAMkhG9v1WKW3whlo+BMm3TNdKuEv1o2PQpVfIChSGzO/05Y/YFbd/XdI7p/Kg=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.2.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=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"croner": ["croner@9.1.0", "https://npm.nose.space/croner/-/croner-9.1.0.tgz", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260201-000000
|
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -15,7 +15,7 @@ const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
// Styles (follow versions tool pattern)
|
// Styles
|
||||||
const Container = define('Container', {
|
const Container = define('Container', {
|
||||||
fontFamily: theme('fonts-sans'),
|
fontFamily: theme('fonts-sans'),
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
|
|
@ -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}>← 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}>← 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') || ''
|
||||||
|
|
||||||
|
|
@ -309,7 +572,7 @@ app.post('/new', async c => {
|
||||||
return c.redirect('/new?error=invalid-name')
|
return c.redirect('/new?error=invalid-name')
|
||||||
}
|
}
|
||||||
|
|
||||||
const cronDir = join(APPS_DIR, appName, 'current', 'cron')
|
const cronDir = join(APPS_DIR, appName, 'cron')
|
||||||
const filePath = join(cronDir, `${name}.ts`)
|
const filePath = join(cronDir, `${name}.ts`)
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
|
|
@ -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:reload', 'app:delete'], (event) => {
|
||||||
|
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
|
||||||
|
rediscover()
|
||||||
|
})
|
||||||
|
|
||||||
init()
|
init()
|
||||||
|
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
81
apps/cron/lib/discovery.ts
Normal file
81
apps/cron/lib/discovery.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { readdir, readFile } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { isValidSchedule, toCronExpr, type CronJob, type InvalidJob, type Schedule } from './schedules'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
const SCHEDULE_RE = /export\s+const\s+schedule\s*=\s*['"]([^'"]+)['"]/
|
||||||
|
|
||||||
|
export async function getApps(): Promise<string[]> {
|
||||||
|
const entries = await readdir(APPS_DIR, { withFileTypes: true })
|
||||||
|
const apps: string[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
if (existsSync(join(APPS_DIR, entry.name, 'package.json'))) {
|
||||||
|
apps.push(entry.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return apps.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscoveryResult = {
|
||||||
|
jobs: CronJob[]
|
||||||
|
invalid: InvalidJob[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverCronJobs(): Promise<DiscoveryResult> {
|
||||||
|
const jobs: CronJob[] = []
|
||||||
|
const invalid: InvalidJob[] = []
|
||||||
|
const apps = await readdir(APPS_DIR, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
if (!app.isDirectory()) continue
|
||||||
|
|
||||||
|
const cronDir = join(APPS_DIR, app.name, 'cron')
|
||||||
|
if (!existsSync(cronDir)) continue
|
||||||
|
|
||||||
|
const files = await readdir(cronDir)
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.ts')) continue
|
||||||
|
|
||||||
|
const filePath = join(cronDir, file)
|
||||||
|
const name = file.replace(/\.ts$/, '')
|
||||||
|
const id = `${app.name}:${name}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const source = await readFile(filePath, 'utf-8')
|
||||||
|
const match = source.match(SCHEDULE_RE)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
invalid.push({ id, app: app.name, name, file: filePath, error: 'Missing schedule export' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = match[1] as Schedule
|
||||||
|
|
||||||
|
if (!isValidSchedule(schedule)) {
|
||||||
|
invalid.push({ id, app: app.name, name, file: filePath, error: `Invalid schedule: "${match[1]}"` })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs.push({
|
||||||
|
id,
|
||||||
|
app: app.name,
|
||||||
|
name,
|
||||||
|
file: filePath,
|
||||||
|
schedule,
|
||||||
|
cronExpr: toCronExpr(schedule),
|
||||||
|
state: 'idle',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
invalid.push({ id, app: app.name, name, file: filePath, error: msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { jobs, invalid }
|
||||||
|
}
|
||||||
96
apps/cron/lib/executor.ts
Normal file
96
apps/cron/lib/executor.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { join } from 'path'
|
||||||
|
import { loadAppEnv } from '@because/toes/tools'
|
||||||
|
import type { CronJob } from './schedules'
|
||||||
|
import { getNextRun } from './scheduler'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
const TOES_DIR = process.env.TOES_DIR!
|
||||||
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
const RUNNER = join(import.meta.dir, 'runner.ts')
|
||||||
|
|
||||||
|
function forwardLog(app: string, text: string, stream: 'stdout' | 'stderr' = 'stdout') {
|
||||||
|
fetch(`${TOES_URL}/api/apps/${app}/logs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text, stream }),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void) {
|
||||||
|
const reader = stream.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
append(decoder.decode(value, { stream: true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
|
||||||
|
if (job.state === 'disabled') return
|
||||||
|
|
||||||
|
job.state = 'running'
|
||||||
|
job.lastRun = Date.now()
|
||||||
|
job.lastOutput = undefined
|
||||||
|
job.lastError = undefined
|
||||||
|
job.lastExitCode = undefined
|
||||||
|
job.lastDuration = undefined
|
||||||
|
onUpdate()
|
||||||
|
|
||||||
|
const cwd = join(APPS_DIR, job.app)
|
||||||
|
|
||||||
|
forwardLog(job.app, `[cron] Running ${job.name}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, ...loadAppEnv(job.app), DATA_DIR: join(TOES_DIR, job.app) },
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stream output incrementally into job fields
|
||||||
|
await Promise.all([
|
||||||
|
readStream(proc.stdout as ReadableStream<Uint8Array>, text => {
|
||||||
|
job.lastOutput = (job.lastOutput || '') + text
|
||||||
|
for (const line of text.split('\n').filter(Boolean)) {
|
||||||
|
forwardLog(job.app, `[cron:${job.name}] ${line}`)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
readStream(proc.stderr as ReadableStream<Uint8Array>, text => {
|
||||||
|
job.lastError = (job.lastError || '') + text
|
||||||
|
for (const line of text.split('\n').filter(Boolean)) {
|
||||||
|
forwardLog(job.app, `[cron:${job.name}] ${line}`, 'stderr')
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const code = await proc.exited
|
||||||
|
|
||||||
|
job.lastDuration = Date.now() - job.lastRun
|
||||||
|
job.lastExitCode = code
|
||||||
|
if (!job.lastError && code !== 0) job.lastError = 'Non-zero exit'
|
||||||
|
if (code === 0) job.lastError = undefined
|
||||||
|
if (!job.lastOutput) job.lastOutput = undefined
|
||||||
|
job.state = 'idle'
|
||||||
|
job.nextRun = getNextRun(job.id)
|
||||||
|
|
||||||
|
// Log result
|
||||||
|
const status = code === 0 ? 'ok' : `failed (code=${code})`
|
||||||
|
const summary = `[cron] ${job.name} finished: ${status} duration=${job.lastDuration}ms`
|
||||||
|
console.log(summary)
|
||||||
|
forwardLog(job.app, summary, code === 0 ? 'stdout' : 'stderr')
|
||||||
|
|
||||||
|
if (job.lastOutput) console.log(job.lastOutput)
|
||||||
|
if (job.lastError) console.error(job.lastError)
|
||||||
|
} catch (e) {
|
||||||
|
job.lastDuration = Date.now() - job.lastRun
|
||||||
|
job.lastExitCode = 1
|
||||||
|
job.lastError = e instanceof Error ? e.message : String(e)
|
||||||
|
job.state = 'idle'
|
||||||
|
console.error(`[cron] ${job.id} failed:`, e)
|
||||||
|
forwardLog(job.app, `[cron] ${job.name} failed: ${job.lastError}`, 'stderr')
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate()
|
||||||
|
}
|
||||||
16
apps/cron/lib/runner.ts
Normal file
16
apps/cron/lib/runner.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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} * * *`
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
@ -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.18",
|
||||||
"croner": "^9.0.0"
|
"croner": "^9.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
@ -3,16 +3,18 @@
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cron",
|
"name": "env",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "*",
|
"@because/forge": "*",
|
||||||
"@because/hype": "*",
|
"@because/hype": "*",
|
||||||
"@because/toes": "*",
|
"@because/toes": "*",
|
||||||
"croner": "^9.0.0",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|
@ -30,8 +32,6 @@
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"croner": ["croner@9.1.0", "https://npm.nose.space/croner/-/croner-9.1.0.tgz", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
|
|
||||||
|
|
||||||
"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.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||||
513
apps/env/index.tsx
vendored
Normal file
513
apps/env/index.tsx
vendored
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="key"]').forEach(input => {
|
||||||
|
input.addEventListener('paste', e => {
|
||||||
|
const text = e.clipboardData?.getData('text') ?? '';
|
||||||
|
const eqIndex = text.indexOf('=');
|
||||||
|
if (eqIndex === -1) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const key = text.slice(0, eqIndex).trim();
|
||||||
|
const value = text.slice(eqIndex + 1).trim();
|
||||||
|
input.value = key;
|
||||||
|
const valueInput = input.closest('form').querySelector('input[name="value"]');
|
||||||
|
if (valueInput) {
|
||||||
|
valueInput.value = value;
|
||||||
|
valueInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
app.get('/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) {
|
||||||
|
// Dashboard view: global env vars only
|
||||||
|
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Global Environment Variables">
|
||||||
|
{globalVars.length === 0 ? (
|
||||||
|
<EmptyState>No global environment variables</EmptyState>
|
||||||
|
) : (
|
||||||
|
<EnvList>
|
||||||
|
{globalVars.map(v => (
|
||||||
|
<EnvItem data-env-item>
|
||||||
|
<EnvKey>{v.key}</EnvKey>
|
||||||
|
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
|
||||||
|
<EnvActions>
|
||||||
|
<Button data-reveal>Reveal</Button>
|
||||||
|
<form method="post" action={`/delete-global?key=${v.key}`} style="margin:0">
|
||||||
|
<DangerButton type="submit">Delete</DangerButton>
|
||||||
|
</form>
|
||||||
|
</EnvActions>
|
||||||
|
</EnvItem>
|
||||||
|
))}
|
||||||
|
</EnvList>
|
||||||
|
)}
|
||||||
|
<Form method="POST" action="/set-global">
|
||||||
|
<Input type="text" name="key" placeholder="KEY" required />
|
||||||
|
<Input type="text" name="value" placeholder="value" required />
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
</Form>
|
||||||
|
<Hint>Global vars are available to all apps. Changes take effect on next app restart.</Hint>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = c.req.query('tab') === 'global' ? 'global' : 'app'
|
||||||
|
const appUrl = `/?app=${appName}`
|
||||||
|
const globalUrl = `/?app=${appName}&tab=global`
|
||||||
|
|
||||||
|
if (tab === 'global') {
|
||||||
|
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title={`Env - Global`}>
|
||||||
|
<TabBar>
|
||||||
|
<Tab href={appUrl}>App</Tab>
|
||||||
|
<TabActive href={globalUrl}>Global</TabActive>
|
||||||
|
</TabBar>
|
||||||
|
{globalVars.length === 0 ? (
|
||||||
|
<EmptyState>No global environment variables</EmptyState>
|
||||||
|
) : (
|
||||||
|
<EnvList>
|
||||||
|
{globalVars.map(v => (
|
||||||
|
<EnvItem data-env-item>
|
||||||
|
<EnvKey>{v.key}</EnvKey>
|
||||||
|
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
|
||||||
|
<EnvActions>
|
||||||
|
<Button data-reveal>Reveal</Button>
|
||||||
|
<form method="post" action={`/delete-global?app=${appName}&key=${v.key}`} style="margin:0">
|
||||||
|
<DangerButton type="submit">Delete</DangerButton>
|
||||||
|
</form>
|
||||||
|
</EnvActions>
|
||||||
|
</EnvItem>
|
||||||
|
))}
|
||||||
|
</EnvList>
|
||||||
|
)}
|
||||||
|
<Form method="POST" action={`/set-global?app=${appName}`}>
|
||||||
|
<Input type="text" name="key" placeholder="KEY" required />
|
||||||
|
<Input type="text" name="value" placeholder="value" required />
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
</Form>
|
||||||
|
<Hint>Global vars are available to all apps. Changes take effect on next app restart.</Hint>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appVars = parseEnvFile(appEnvPath(appName))
|
||||||
|
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
|
||||||
|
const globalKeys = new Set(globalVars.map(v => v.key))
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title={`Env - ${appName}`}>
|
||||||
|
<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')
|
||||||
|
|
||||||
|
const body = await c.req.parseBody()
|
||||||
|
const key = String(body.key).trim().toUpperCase()
|
||||||
|
const value = String(body.value)
|
||||||
|
|
||||||
|
if (!key) return c.text('Missing key', 400)
|
||||||
|
|
||||||
|
const vars = parseEnvFile(GLOBAL_ENV_PATH)
|
||||||
|
const existing = vars.findIndex(v => v.key === key)
|
||||||
|
|
||||||
|
if (existing >= 0) {
|
||||||
|
vars[existing]!.value = value
|
||||||
|
} else {
|
||||||
|
vars.push({ key, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
writeEnvFile(GLOBAL_ENV_PATH, vars)
|
||||||
|
return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/delete-global', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
const key = c.req.query('key')
|
||||||
|
if (!key) return c.text('Missing key', 400)
|
||||||
|
|
||||||
|
const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key)
|
||||||
|
writeEnvFile(GLOBAL_ENV_PATH, vars)
|
||||||
|
return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "versions",
|
"name": "env",
|
||||||
"module": "index.tsx",
|
"module": "index.tsx",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
@ -9,8 +9,9 @@
|
||||||
"dev": "bun run --hot index.tsx"
|
"dev": "bun run --hot index.tsx"
|
||||||
},
|
},
|
||||||
"toes": {
|
"toes": {
|
||||||
"tool": true,
|
"tool": ".env",
|
||||||
"icon": "📦"
|
"icon": "🔑",
|
||||||
|
"dashboard": true
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
hi
|
|
||||||
49
apps/git/bun.lock
Normal file
49
apps/git/bun.lock
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "git",
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "^0.0.1",
|
||||||
|
"@because/hype": "^0.0.2",
|
||||||
|
"@because/toes": "^0.0.12",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||||
|
|
||||||
|
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||||
|
|
||||||
|
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
|
||||||
|
|
||||||
|
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.3.3", "https://npm.nose.space/@types/node/-/node-25.3.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.12.3", "https://npm.nose.space/hono/-/hono-4.12.3.tgz", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
869
apps/git/index.tsx
Normal file
869
apps/git/index.tsx
Normal file
|
|
@ -0,0 +1,869 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, ToolScript, theme, on, VALID_NAME } from '@because/toes/tools'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
|
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
|
||||||
|
import { join, resolve } from 'path'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
|
const APP_URL = process.env.APP_URL!
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
const DATA_DIR = process.env.DATA_DIR!
|
||||||
|
const DATA_ROOT = process.env.DATA_ROOT!
|
||||||
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
|
||||||
|
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
|
||||||
|
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false, layout: false })
|
||||||
|
const deployLocks = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Styles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const Badge = define('Badge', {
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const CodeBlock = define('CodeBlock', {
|
||||||
|
base: 'pre',
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
padding: theme('spacing-lg'),
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '13px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
lineHeight: '1.5',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Heading = define('Heading', {
|
||||||
|
base: 'h3',
|
||||||
|
margin: '24px 0 8px',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const HelpText = define('HelpText', {
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
margin: '12px 0',
|
||||||
|
})
|
||||||
|
|
||||||
|
const RepoItem = define('RepoItem', {
|
||||||
|
padding: '12px 15px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
states: {
|
||||||
|
':last-child': { borderBottom: 'none' },
|
||||||
|
':hover': { backgroundColor: theme('colors-bgHover') },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const RepoList = define('RepoList', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: '20px 0',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
const RepoName = define('RepoName', {
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Tab = define('Tab', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '6px 0',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '2px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
states: {
|
||||||
|
':hover': { color: theme('colors-text') },
|
||||||
|
'.active': {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
borderBottomColor: theme('colors-primary'),
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TabBar = define('TabBar', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '24px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Toggle = define('Toggle', {
|
||||||
|
base: 'button',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '3px 10px',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
states: {
|
||||||
|
':hover': { borderColor: theme('colors-textMuted') },
|
||||||
|
'.public': {
|
||||||
|
backgroundColor: theme('colors-statusRunning'),
|
||||||
|
color: 'white',
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AppRepoProps {
|
||||||
|
appName: string
|
||||||
|
baseUrl: string
|
||||||
|
branch: string
|
||||||
|
exists: boolean
|
||||||
|
commits: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
title: string
|
||||||
|
children: Child
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepoListPageProps {
|
||||||
|
baseUrl: string
|
||||||
|
external: boolean
|
||||||
|
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility; tool: boolean }>
|
||||||
|
tunnelUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Visibility = 'public' | 'private'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)
|
||||||
|
|
||||||
|
// resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal
|
||||||
|
const validRepoName = (name: string) =>
|
||||||
|
VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)
|
||||||
|
|
||||||
|
async function activateApp(name: string): Promise<string | null> {
|
||||||
|
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
const msg = (body as Record<string, string>).error ?? `reload returned ${res.status}`
|
||||||
|
console.error(`Reload failed for ${name}:`, msg)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const bare = repoPath(repoName)
|
||||||
|
|
||||||
|
if (!(await hasCommits(bare))) {
|
||||||
|
return { ok: false, error: 'No commits in repository' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate in a temp dir before touching the real app dir
|
||||||
|
const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`)
|
||||||
|
await rm(tmpDir, { recursive: true, force: true })
|
||||||
|
await mkdir(tmpDir, { recursive: true })
|
||||||
|
|
||||||
|
// Extract HEAD into the temp directory
|
||||||
|
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], {
|
||||||
|
stdin: archive.stdout,
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'pipe',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Consume stderr concurrently to prevent pipe buffer from filling and blocking the process
|
||||||
|
const [archiveExit, tarExit, archiveErr, tarErr] = await Promise.all([
|
||||||
|
archive.exited,
|
||||||
|
tar.exited,
|
||||||
|
new Response(archive.stderr).text(),
|
||||||
|
new Response(tar.stderr).text(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (archiveExit !== 0 || tarExit !== 0) {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true })
|
||||||
|
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the app before swapping directories
|
||||||
|
await stopIfRunning(repoName)
|
||||||
|
|
||||||
|
// Validation passed — swap directories (reload endpoint handles restart)
|
||||||
|
const appDir = join(APPS_DIR, repoName)
|
||||||
|
await rm(appDir, { recursive: true, force: true })
|
||||||
|
await rename(tmpDir, appDir)
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bun.file().exists() is for files only — it returns false for directories.
|
||||||
|
// Use stat() to check directory existence instead.
|
||||||
|
async function dirExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await stat(path)).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBareRepo(name: string): Promise<string> {
|
||||||
|
const bare = repoPath(name)
|
||||||
|
if (!(await dirExists(bare))) {
|
||||||
|
await mkdir(bare, { recursive: true })
|
||||||
|
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: bare }).exited
|
||||||
|
await run(['git', 'init', '--bare'])
|
||||||
|
await run(['git', 'symbolic-ref', 'HEAD', 'refs/heads/main'])
|
||||||
|
await run(['git', 'config', 'http.receivepack', 'true'])
|
||||||
|
}
|
||||||
|
return bare
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastFlush(data: Uint8Array): number {
|
||||||
|
for (let i = data.length - 4; i >= 0; i--) {
|
||||||
|
if (data[i] === 0x30 && data[i + 1] === 0x30 &&
|
||||||
|
data[i + 2] === 0x30 && data[i + 3] === 0x30) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVisibility(repo: string): Promise<Visibility> {
|
||||||
|
const all = await loadVisibility()
|
||||||
|
return all[repo] ?? 'private'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDefaultBranch(bare: string): Promise<string> {
|
||||||
|
const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
|
||||||
|
cwd: bare,
|
||||||
|
stdout: 'pipe',
|
||||||
|
// Ignore stderr to avoid filling the pipe buffer and blocking the process
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
if ((await proc.exited) === 0) {
|
||||||
|
const ref = await new Response(proc.stdout).text()
|
||||||
|
return ref.trim().replace('refs/heads/', '')
|
||||||
|
}
|
||||||
|
return 'main'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitRpc(
|
||||||
|
repo: string,
|
||||||
|
service: string,
|
||||||
|
body: Uint8Array | ReadableStream<Uint8Array> | null,
|
||||||
|
): Promise<Response> {
|
||||||
|
const bare = repoPath(repo)
|
||||||
|
|
||||||
|
const proc = Bun.spawn([service, '--stateless-rpc', bare], {
|
||||||
|
stdin: body ?? 'ignore',
|
||||||
|
stdout: 'pipe',
|
||||||
|
// Ignore stderr to avoid filling the pipe buffer and blocking the process
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(proc.stdout, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': `application/x-${service}-result`,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitService(repo: string, service: string): Promise<Response | null> {
|
||||||
|
const bare = repoPath(repo)
|
||||||
|
if (!(await dirExists(bare))) return null
|
||||||
|
|
||||||
|
const proc = Bun.spawn([service, '--stateless-rpc', '--advertise-refs', bare], {
|
||||||
|
stdout: 'pipe',
|
||||||
|
// Ignore stderr to avoid filling the pipe buffer and blocking the process
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
const stdout = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
|
||||||
|
await proc.exited
|
||||||
|
|
||||||
|
const header = serviceHeader(service)
|
||||||
|
const body = new Uint8Array(header.length + stdout.byteLength)
|
||||||
|
body.set(header, 0)
|
||||||
|
body.set(stdout, header.length)
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': `application/x-${service}-advertisement`,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitSidebandMessage(text: string): Uint8Array {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const lines = text.split('\n').filter(Boolean)
|
||||||
|
const parts: Uint8Array[] = []
|
||||||
|
for (const line of lines) {
|
||||||
|
const msg = `\x02remote: ${line}\n`
|
||||||
|
const hex = (4 + msg.length).toString(16).padStart(4, '0')
|
||||||
|
parts.push(encoder.encode(hex + msg))
|
||||||
|
}
|
||||||
|
const total = parts.reduce((sum, p) => sum + p.length, 0)
|
||||||
|
const out = new Uint8Array(total)
|
||||||
|
let offset = 0
|
||||||
|
for (const part of parts) {
|
||||||
|
out.set(part, offset)
|
||||||
|
offset += part.length
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasCommits(bare: string): Promise<boolean> {
|
||||||
|
const proc = Bun.spawn(['git', 'rev-parse', 'HEAD'], {
|
||||||
|
cwd: bare,
|
||||||
|
// Only checking exit code; ignore stdout/stderr to avoid filling the pipe buffer
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'ignore',
|
||||||
|
})
|
||||||
|
return (await proc.exited) === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array {
|
||||||
|
const pos = findLastFlush(gitBody)
|
||||||
|
if (pos === -1) {
|
||||||
|
const out = new Uint8Array(gitBody.length + msg.length)
|
||||||
|
out.set(gitBody, 0)
|
||||||
|
out.set(msg, gitBody.length)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(gitBody.length + msg.length)
|
||||||
|
out.set(gitBody.subarray(0, pos), 0)
|
||||||
|
out.set(msg, pos)
|
||||||
|
out.set(gitBody.subarray(pos), pos + msg.length)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVisibility(): Promise<Record<string, Visibility>> {
|
||||||
|
try {
|
||||||
|
const data = await readFile(VISIBILITY_PATH, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Layout({ title, children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ToolScript />
|
||||||
|
<Container>{children}</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRepos(): Promise<string[]> {
|
||||||
|
if (!(await dirExists(REPOS_DIR))) return []
|
||||||
|
const entries = await readdir(REPOS_DIR, { withFileTypes: true })
|
||||||
|
return entries
|
||||||
|
.filter(e => e.isDirectory() && e.name.endsWith('.git'))
|
||||||
|
.map(e => e.name.replace(/\.git$/, ''))
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceHeader(service: string): Uint8Array {
|
||||||
|
const line = `# service=${service}\n`
|
||||||
|
const hex = (4 + line.length).toString(16).padStart(4, '0')
|
||||||
|
const header = `${hex}${line}0000`
|
||||||
|
return new TextEncoder().encode(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveVisibility(repo: string, visibility: Visibility): Promise<void> {
|
||||||
|
const all = await loadVisibility()
|
||||||
|
all[repo] = visibility
|
||||||
|
await writeFile(VISIBILITY_PATH, JSON.stringify(all, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopIfRunning(name: string): Promise<void> {
|
||||||
|
const res = await fetch(`${TOES_URL}/api/apps/${name}`)
|
||||||
|
if (!res.ok) return
|
||||||
|
|
||||||
|
const app = await res.json() as { state: string }
|
||||||
|
if (app.state !== 'running' && app.state !== 'starting') return
|
||||||
|
|
||||||
|
await fetch(`${TOES_URL}/api/apps/${name}/stop`, { method: 'POST' })
|
||||||
|
|
||||||
|
const maxWait = 10000
|
||||||
|
const poll = 100
|
||||||
|
let waited = 0
|
||||||
|
while (waited < maxWait) {
|
||||||
|
await new Promise(r => setTimeout(r, poll))
|
||||||
|
waited += poll
|
||||||
|
const check = await fetch(`${TOES_URL}/api/apps/${name}`)
|
||||||
|
if (!check.ok) break
|
||||||
|
const { state } = await check.json() as { state: string }
|
||||||
|
if (state !== 'running' && state !== 'stopping' && state !== 'starting') break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withDeployLock<T>(repo: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = deployLocks.get(repo) ?? Promise.resolve()
|
||||||
|
const { promise: lock, resolve: release } = Promise.withResolvers<void>()
|
||||||
|
deployLocks.set(repo, lock)
|
||||||
|
await prev
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
release()
|
||||||
|
if (deployLocks.get(repo) === lock) deployLocks.delete(repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
|
||||||
|
return (
|
||||||
|
<Layout title={`Git - ${appName}`}>
|
||||||
|
{exists && commits ? (
|
||||||
|
<>
|
||||||
|
<Heading>Repository</Heading>
|
||||||
|
<RepoList>
|
||||||
|
<RepoItem>
|
||||||
|
<div>
|
||||||
|
<RepoName>{appName}</RepoName>
|
||||||
|
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
||||||
|
git clone {baseUrl}/{appName}
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center">
|
||||||
|
<Badge>{branch}</Badge>
|
||||||
|
<Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
||||||
|
</div>
|
||||||
|
</RepoItem>
|
||||||
|
</RepoList>
|
||||||
|
|
||||||
|
<Heading>Push Changes</Heading>
|
||||||
|
<CodeBlock>{[
|
||||||
|
`git push toes ${branch}`,
|
||||||
|
'',
|
||||||
|
'# Or if remote not yet added:',
|
||||||
|
`git remote add toes ${baseUrl}/${appName}`,
|
||||||
|
`git push toes ${branch}`,
|
||||||
|
].join('\n')}</CodeBlock>
|
||||||
|
</>
|
||||||
|
) : exists ? (
|
||||||
|
<>
|
||||||
|
<Heading>Repository</Heading>
|
||||||
|
<RepoList>
|
||||||
|
<RepoItem>
|
||||||
|
<div>
|
||||||
|
<RepoName>{appName}</RepoName>
|
||||||
|
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
||||||
|
git clone {baseUrl}/{appName}
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
<Badge>empty</Badge>
|
||||||
|
</RepoItem>
|
||||||
|
</RepoList>
|
||||||
|
|
||||||
|
<Heading>Push to Deploy</Heading>
|
||||||
|
<CodeBlock>{[
|
||||||
|
`git remote add toes ${baseUrl}/${appName}`,
|
||||||
|
'git push toes main',
|
||||||
|
].join('\n')}</CodeBlock>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Heading>Push to Deploy</Heading>
|
||||||
|
<HelpText>
|
||||||
|
No git repository for <strong>{appName}</strong> yet.
|
||||||
|
Push to create one and deploy.
|
||||||
|
</HelpText>
|
||||||
|
<CodeBlock>{[
|
||||||
|
`git remote add toes ${baseUrl}/${appName}`,
|
||||||
|
'git push toes main',
|
||||||
|
].join('\n')}</CodeBlock>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RepoListItems({ baseUrl, external, repos, tunnelUrl }: {
|
||||||
|
baseUrl: string
|
||||||
|
external: boolean
|
||||||
|
repos: RepoListPageProps['repos']
|
||||||
|
tunnelUrl?: string
|
||||||
|
}) {
|
||||||
|
if (repos.length === 0) {
|
||||||
|
return <HelpText>No repositories yet.</HelpText>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RepoList>
|
||||||
|
{repos.map(({ name, commits, branch, visibility }) => (
|
||||||
|
<RepoItem>
|
||||||
|
<div>
|
||||||
|
<RepoName>{name}</RepoName>
|
||||||
|
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
||||||
|
git clone {baseUrl}/{name}
|
||||||
|
</HelpText>
|
||||||
|
{!external && tunnelUrl && visibility === 'public' && (
|
||||||
|
<HelpText style="margin: 2px 0 0; font-size: 12px">
|
||||||
|
git clone {tunnelUrl}/{name}
|
||||||
|
</HelpText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center">
|
||||||
|
{!external && (
|
||||||
|
<Toggle
|
||||||
|
class={visibility === 'public' ? 'public' : ''}
|
||||||
|
data-repo={name}
|
||||||
|
data-visibility={visibility}
|
||||||
|
onclick="toggleVisibility(this)"
|
||||||
|
>
|
||||||
|
{visibility === 'public' ? 'public' : 'private'}
|
||||||
|
</Toggle>
|
||||||
|
)}
|
||||||
|
<Badge>{branch}</Badge>
|
||||||
|
{commits
|
||||||
|
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
||||||
|
: <Badge>empty</Badge>}
|
||||||
|
</div>
|
||||||
|
</RepoItem>
|
||||||
|
))}
|
||||||
|
</RepoList>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) {
|
||||||
|
const appRepos = repos.filter(r => !r.tool)
|
||||||
|
const toolRepos = repos.filter(r => r.tool)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Git">
|
||||||
|
{!external && (
|
||||||
|
<>
|
||||||
|
<Heading>Push to Deploy</Heading>
|
||||||
|
<HelpText>
|
||||||
|
Push a git repository to deploy it as a toes app.
|
||||||
|
The repo must contain a <code>package.json</code> with a <code>scripts.toes</code> entry.
|
||||||
|
</HelpText>
|
||||||
|
|
||||||
|
<CodeBlock>{[
|
||||||
|
'# Add this server as a remote and push',
|
||||||
|
`git remote add toes ${baseUrl}/<app-name>`,
|
||||||
|
'git push toes main',
|
||||||
|
'',
|
||||||
|
'# Or push an existing repo',
|
||||||
|
`git push ${baseUrl}/<app-name> main`,
|
||||||
|
].join('\n')}</CodeBlock>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{repos.length > 0 && appRepos.length > 0 && toolRepos.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Heading>Repositories</Heading>
|
||||||
|
<TabBar>
|
||||||
|
<Tab class="active" data-tab="tab-apps" onclick="switchTab(this)">Apps</Tab>
|
||||||
|
<Tab data-tab="tab-tools" onclick="switchTab(this)">Tools</Tab>
|
||||||
|
</TabBar>
|
||||||
|
<div>
|
||||||
|
<div id="tab-apps">
|
||||||
|
<RepoListItems baseUrl={baseUrl} external={external} repos={appRepos} tunnelUrl={tunnelUrl} />
|
||||||
|
</div>
|
||||||
|
<div id="tab-tools" style="display: none">
|
||||||
|
<RepoListItems baseUrl={baseUrl} external={external} repos={toolRepos} tunnelUrl={tunnelUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!external && <script src="/client/toggle.js" />}
|
||||||
|
<script src="/client/tabs.js" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{repos.length > 0 && (appRepos.length === 0 || toolRepos.length === 0) && (
|
||||||
|
<>
|
||||||
|
<Heading>Repositories</Heading>
|
||||||
|
<RepoListItems baseUrl={baseUrl} external={external} repos={repos} tunnelUrl={tunnelUrl} />
|
||||||
|
{!external && <script src="/client/toggle.js" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{repos.length === 0 && (
|
||||||
|
<HelpText>No repositories yet. Push one to get started.</HelpText>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
mkdirSync(REPOS_DIR, { recursive: true })
|
||||||
|
|
||||||
|
// Auto-deploy bare repos that don't have a corresponding app directory
|
||||||
|
async function deployUndeployedRepos() {
|
||||||
|
const repos = await listRepos()
|
||||||
|
for (const name of repos) {
|
||||||
|
const appDir = join(APPS_DIR, name)
|
||||||
|
if (await dirExists(appDir)) continue
|
||||||
|
|
||||||
|
const bare = repoPath(name)
|
||||||
|
if (!(await hasCommits(bare))) continue
|
||||||
|
|
||||||
|
console.log(`Auto-deploying undeployed repo: ${name}`)
|
||||||
|
const result = await deploy(name)
|
||||||
|
if (result.ok) {
|
||||||
|
await activateApp(name)
|
||||||
|
} else {
|
||||||
|
console.error(`Auto-deploy failed for ${name}: ${result.error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deployUndeployedRepos()
|
||||||
|
|
||||||
|
on('app:delete', async ({ app: name }) => {
|
||||||
|
const bare = repoPath(name)
|
||||||
|
if (await dirExists(bare)) await rm(bare, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
app.get('/styles.css', c =>
|
||||||
|
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
|
||||||
|
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
|
||||||
|
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
||||||
|
const service = c.req.query('service')
|
||||||
|
|
||||||
|
if (!validRepoName(repoParam)) {
|
||||||
|
return c.text('Invalid repository name', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
|
||||||
|
return c.text('Invalid service', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.req.header('x-sneaker')) {
|
||||||
|
if (service === 'git-receive-pack') {
|
||||||
|
return c.text('Push access denied over sneaker', 403)
|
||||||
|
}
|
||||||
|
if (await getVisibility(repoParam) !== 'public') {
|
||||||
|
return c.text('Repository not found', 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service === 'git-receive-pack') {
|
||||||
|
await ensureBareRepo(repoParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bare = repoPath(repoParam)
|
||||||
|
if (!(await dirExists(bare))) {
|
||||||
|
return c.text('Repository not found', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await gitService(repoParam, service)
|
||||||
|
return res ?? c.text('Repository not found', 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /:repo[.git]/git-upload-pack
|
||||||
|
app.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], async c => {
|
||||||
|
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
||||||
|
|
||||||
|
if (!validRepoName(repoParam)) {
|
||||||
|
return c.text('Invalid repository name', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.req.header('x-sneaker') && await getVisibility(repoParam) !== 'public') {
|
||||||
|
return c.text('Repository not found', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bare = repoPath(repoParam)
|
||||||
|
if (!(await dirExists(bare))) {
|
||||||
|
return c.text('Repository not found', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gitRpc(repoParam, 'git-upload-pack', c.req.raw.body)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /:repo[.git]/git-receive-pack
|
||||||
|
app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'], async c => {
|
||||||
|
if (c.req.header('x-sneaker')) {
|
||||||
|
return c.text('Push access denied over sneaker', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
||||||
|
|
||||||
|
if (!validRepoName(repoParam)) {
|
||||||
|
return c.text('Invalid repository name', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureBareRepo(repoParam)
|
||||||
|
|
||||||
|
// Buffer the request body before passing to git-receive-pack. Piping a live
|
||||||
|
// HTTP ReadableStream directly to subprocess stdin deadlocks on large pushes:
|
||||||
|
// the pipe buffer fills, stalling the stream reader, while git-receive-pack
|
||||||
|
// can't finish reading stdin to produce stdout — both sides block.
|
||||||
|
const body = new Uint8Array(await c.req.raw.arrayBuffer())
|
||||||
|
const response = await gitRpc(repoParam, 'git-receive-pack', body)
|
||||||
|
// Buffer the full response so we can inject sideband error messages before the
|
||||||
|
// final flush-pkt on deploy failure. The receive-pack response is just ref status
|
||||||
|
// lines (not pack data), so the buffer is small regardless of push size.
|
||||||
|
const gitBody = new Uint8Array(await response.arrayBuffer())
|
||||||
|
|
||||||
|
const deployError = await withDeployLock(repoParam, async () => {
|
||||||
|
try {
|
||||||
|
const result = await deploy(repoParam)
|
||||||
|
if (result.ok) {
|
||||||
|
const err = await activateApp(repoParam)
|
||||||
|
if (err) {
|
||||||
|
console.error(`Reload failed for ${repoParam}: ${err}`)
|
||||||
|
return `Deploy succeeded but reload failed: ${err}`
|
||||||
|
}
|
||||||
|
console.log(`Deployed ${repoParam}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
console.error(`Deploy failed for ${repoParam}: ${result.error}`)
|
||||||
|
return `Deploy failed: ${result.error}`
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Deploy error for ${repoParam}:`, e)
|
||||||
|
return `Deploy failed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': response.headers.get('Content-Type') ?? 'application/x-git-receive-pack-result',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deployError) {
|
||||||
|
return new Response(insertBeforeFlush(gitBody, gitSidebandMessage(deployError)), { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(gitBody, { headers })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/visibility/:repo', async c => {
|
||||||
|
if (c.req.header('x-sneaker')) return c.json({ error: 'Forbidden' }, 403)
|
||||||
|
|
||||||
|
const repo = c.req.param('repo')
|
||||||
|
if (!validRepoName(repo)) return c.json({ error: 'Invalid repository name' }, 400)
|
||||||
|
|
||||||
|
const body = await c.req.json<{ visibility: string }>()
|
||||||
|
if (body.visibility !== 'public' && body.visibility !== 'private') {
|
||||||
|
return c.json({ error: 'Visibility must be "public" or "private"' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveVisibility(repo, body.visibility)
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
const sneakerHost = c.req.header('x-sneaker')
|
||||||
|
const external = !!sneakerHost
|
||||||
|
const baseUrl = sneakerHost ? `https://${sneakerHost}` : APP_URL
|
||||||
|
|
||||||
|
// When viewing a specific app, only show that app's repo
|
||||||
|
if (appName) {
|
||||||
|
const bare = repoPath(appName)
|
||||||
|
const exists = await dirExists(bare)
|
||||||
|
const [commits, branch] = exists
|
||||||
|
? await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
|
||||||
|
: [false, 'main']
|
||||||
|
|
||||||
|
return c.html(<AppRepo appName={appName} baseUrl={baseUrl} branch={branch} exists={exists} commits={commits} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No app selected — show all repos
|
||||||
|
const repos = await listRepos()
|
||||||
|
|
||||||
|
// Fetch all apps to determine which repos are tools
|
||||||
|
let toolSet = new Set<string>()
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${TOES_URL}/api/apps`)
|
||||||
|
if (res.ok) {
|
||||||
|
const apps = await res.json() as Array<{ name: string; tool?: boolean | string }>
|
||||||
|
for (const a of apps) {
|
||||||
|
if (a.tool) toolSet.add(a.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const repoData = await Promise.all(repos.map(async name => {
|
||||||
|
const bare = repoPath(name)
|
||||||
|
const [commits, branch, visibility] = await Promise.all([
|
||||||
|
hasCommits(bare),
|
||||||
|
getDefaultBranch(bare),
|
||||||
|
getVisibility(name),
|
||||||
|
])
|
||||||
|
return { name, commits, branch, visibility, tool: toolSet.has(name) }
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Hide private repos from external (sneaker) requests
|
||||||
|
const filtered = external
|
||||||
|
? repoData.filter(r => r.visibility === 'public')
|
||||||
|
: repoData
|
||||||
|
|
||||||
|
// Fetch tunnel URL for the git tool so we can show it for public repos
|
||||||
|
let tunnelUrl: string | undefined
|
||||||
|
if (!external) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${TOES_URL}/api/apps/git`)
|
||||||
|
if (res.ok) {
|
||||||
|
const info = await res.json() as { tunnelUrl?: string }
|
||||||
|
tunnelUrl = info.tunnelUrl
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} tunnelUrl={tunnelUrl} />)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "todo",
|
"name": "git",
|
||||||
"module": "index.tsx",
|
"module": "index.tsx",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
@ -9,18 +9,20 @@
|
||||||
"dev": "bun run --hot index.tsx"
|
"dev": "bun run --hot index.tsx"
|
||||||
},
|
},
|
||||||
"toes": {
|
"toes": {
|
||||||
"tool": "TODO",
|
"tool": true,
|
||||||
"icon": "✅"
|
"dashboard": true,
|
||||||
|
"share": true,
|
||||||
|
"icon": "🔀"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@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.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
13
apps/git/src/client/tabs.ts
Normal file
13
apps/git/src/client/tabs.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
function switchTab(btn: HTMLButtonElement) {
|
||||||
|
const tabs = btn.parentElement!.querySelectorAll('button')
|
||||||
|
for (const tab of tabs) tab.classList.remove('active')
|
||||||
|
btn.classList.add('active')
|
||||||
|
|
||||||
|
const panels = btn.parentElement!.nextElementSibling!.children
|
||||||
|
for (const panel of panels) (panel as HTMLElement).style.display = 'none'
|
||||||
|
|
||||||
|
const target = document.getElementById(btn.dataset.tab!)
|
||||||
|
if (target) target.style.display = 'block'
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { switchTab })
|
||||||
19
apps/git/src/client/toggle.ts
Normal file
19
apps/git/src/client/toggle.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
function toggleVisibility(btn: HTMLButtonElement) {
|
||||||
|
const repo = btn.dataset.repo!
|
||||||
|
const current = btn.dataset.visibility!
|
||||||
|
const next = current === 'public' ? 'private' : 'public'
|
||||||
|
btn.dataset.visibility = next
|
||||||
|
btn.textContent = next
|
||||||
|
btn.classList.toggle('public', next === 'public')
|
||||||
|
fetch('/api/visibility/' + encodeURIComponent(repo), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ visibility: next }),
|
||||||
|
}).catch(() => {
|
||||||
|
btn.dataset.visibility = current
|
||||||
|
btn.textContent = current
|
||||||
|
btn.classList.toggle('public', current === 'public')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { toggleVisibility })
|
||||||
|
|
@ -3,30 +3,30 @@
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "versions",
|
"name": "stats",
|
||||||
"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=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
1128
apps/metrics/index.tsx
Normal file
1128
apps/metrics/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -1,24 +1,27 @@
|
||||||
{
|
{
|
||||||
"name": "profile",
|
"name": "metrics",
|
||||||
"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": "📊",
|
||||||
|
"dashboard": true
|
||||||
|
},
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
|
|
@ -8,21 +7,15 @@
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "hono/jsx",
|
"jsxImportSource": "hono/jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|
||||||
// Bundler mode
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
// Best practices
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
// Some stricter flags (disabled by default)
|
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
|
@ -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=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "todo",
|
|
||||||
"dependencies": {
|
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/hype": "*",
|
|
||||||
"@because/toes": "*",
|
|
||||||
},
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"@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/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
|
||||||
|
|
||||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 |
|
|
@ -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>
|
|
||||||
|
|
||||||
<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)
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-181927
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
registry=https://npm.nose.space
|
|
||||||
|
|
@ -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=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
registry=https://npm.nose.space
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
|
||||||
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
|
||||||
import { readdir, readlink, stat } from 'fs/promises'
|
|
||||||
import { join } from 'path'
|
|
||||||
import type { Child } from 'hono/jsx'
|
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
|
||||||
const TOES_URL = process.env.TOES_URL!
|
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
|
||||||
|
|
||||||
const Container = define('Container', {
|
|
||||||
fontFamily: theme('fonts-sans'),
|
|
||||||
padding: '20px',
|
|
||||||
paddingTop: 0,
|
|
||||||
maxWidth: '800px',
|
|
||||||
margin: '0 auto',
|
|
||||||
color: theme('colors-text'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const 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', {
|
|
||||||
listStyle: 'none',
|
|
||||||
padding: 0,
|
|
||||||
margin: '20px 0',
|
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
overflow: 'hidden',
|
|
||||||
})
|
|
||||||
|
|
||||||
const VersionItem = define('VersionItem', {
|
|
||||||
padding: '12px 15px',
|
|
||||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
states: {
|
|
||||||
':last-child': {
|
|
||||||
borderBottom: 'none',
|
|
||||||
},
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: theme('colors-bgHover'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const VersionLink = define('VersionLink', {
|
|
||||||
base: 'a',
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: theme('colors-link'),
|
|
||||||
fontFamily: theme('fonts-mono'),
|
|
||||||
fontSize: '15px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
states: {
|
|
||||||
':hover': {
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Badge = define('Badge', {
|
|
||||||
fontSize: '12px',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
backgroundColor: theme('colors-bgElement'),
|
|
||||||
color: theme('colors-statusRunning'),
|
|
||||||
fontWeight: 'bold',
|
|
||||||
})
|
|
||||||
|
|
||||||
const ErrorBox = define('ErrorBox', {
|
|
||||||
color: theme('colors-error'),
|
|
||||||
padding: '20px',
|
|
||||||
backgroundColor: theme('colors-bgElement'),
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
margin: '20px 0',
|
|
||||||
})
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
children: Child
|
|
||||||
}
|
|
||||||
|
|
||||||
function Layout({ title, subtitle, 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>
|
|
||||||
<Header>
|
|
||||||
<Title>Versions</Title>
|
|
||||||
</Header>
|
|
||||||
{children}
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
|
||||||
}))
|
|
||||||
|
|
||||||
async function getVersions(appPath: string): Promise<{ name: string; isCurrent: boolean }[]> {
|
|
||||||
const entries = await readdir(appPath, { withFileTypes: true })
|
|
||||||
|
|
||||||
let currentTarget = ''
|
|
||||||
try {
|
|
||||||
currentTarget = await readlink(join(appPath, 'current'))
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
return entries
|
|
||||||
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
|
|
||||||
.map(e => ({ name: e.name, isCurrent: e.name === currentTarget }))
|
|
||||||
.sort((a, b) => b.name.localeCompare(a.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(ts: string): string {
|
|
||||||
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}:${ts.slice(13, 15)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/', async c => {
|
|
||||||
const appName = c.req.query('app')
|
|
||||||
|
|
||||||
if (!appName) {
|
|
||||||
return c.html(
|
|
||||||
<Layout title="Versions">
|
|
||||||
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const appPath = join(APPS_DIR, appName)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stat(appPath)
|
|
||||||
} catch {
|
|
||||||
return c.html(
|
|
||||||
<Layout title="Versions">
|
|
||||||
<ErrorBox>App "{appName}" not found</ErrorBox>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const versions = await getVersions(appPath)
|
|
||||||
|
|
||||||
if (versions.length === 0) {
|
|
||||||
return c.html(
|
|
||||||
<Layout title="Versions" subtitle={appName}>
|
|
||||||
<ErrorBox>No versions found</ErrorBox>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.html(
|
|
||||||
<Layout title="Versions" subtitle={appName}>
|
|
||||||
<VersionList>
|
|
||||||
{versions.map(v => (
|
|
||||||
<VersionItem>
|
|
||||||
<VersionLink
|
|
||||||
href={`${TOES_URL}/tool/code?app=${appName}&version=${v.name}`}
|
|
||||||
>
|
|
||||||
{formatTimestamp(v.name)}
|
|
||||||
</VersionLink>
|
|
||||||
{v.isCurrent && <Badge>current</Badge>}
|
|
||||||
</VersionItem>
|
|
||||||
))}
|
|
||||||
</VersionList>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
43
bun.lock
43
bun.lock
|
|
@ -3,46 +3,51 @@
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "toes",
|
"name": "@because/toes",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.7",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.9",
|
||||||
"commander": "^14.0.2",
|
"@because/sneaker": "^0.0.5",
|
||||||
"diff": "^8.0.3",
|
"@because/toes": "^0.0.15",
|
||||||
"kleur": "^4.1.5",
|
"ansis": "^4.2.0",
|
||||||
|
"commander": "14.0.3",
|
||||||
|
"diff": "^8.0.4",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/diff": "^8.0.0",
|
"@types/diff": "^8.0.0",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.9.2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
"@because/forge": ["@because/forge@0.0.7", "https://npm.nose.space/@because/forge/-/forge-0.0.7.tgz", {}, "sha512-vrpo9/l3YbpJikr4eGNBbXDoXa1q0TtretyXwlJys/5qWEuHJJ3F8sFQ8SEeqWq3j0k9LQfS1278YE6a9mpv6g=="],
|
||||||
|
|
||||||
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
"@because/hype": ["@because/hype@0.0.9", "https://npm.nose.space/@because/hype/-/hype-0.0.9.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" } }, "sha512-pCaGAP0d4JDkeuVDR+8x0AemaC/EjFNNBNHhC8eKcw7soQVoXG0kYJW4bIBlEBrY8pd2TBXmMEzRIcW4QnwXNw=="],
|
||||||
|
|
||||||
"@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.5", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.5.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" } }, "sha512-GAvsh/i6N+KRV5mK/YosOMlZ3Lnm/y8kppQiKTZ5HlBDBnJfuxGJSp7aLyHZlHtSQi1jcGJWsx6NJ4RqCD7hNA=="],
|
||||||
|
|
||||||
|
"@because/toes": ["@because/toes@0.0.15", "https://npm.nose.space/@because/toes/-/toes-0.0.15.tgz", { "dependencies": { "@because/forge": "^0.0.7", "@because/hype": "^0.0.9", "@because/sneaker": "^0.0.5", "@because/toes": "^0.0.15", "ansis": "^4.2.0", "commander": "14.0.3", "diff": "^8.0.4" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-vHCMIx3w7AK1buWIKXTTWb2oxKCrOdXj0/p4+r+prjjfp1Q6RPmkfPFy1GVRZ4P1kVr8LO7PpDITCHNMr8cAlQ=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||||
|
|
||||||
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
|
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.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.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
"ansis": ["ansis@4.2.0", "https://npm.nose.space/ansis/-/ansis-4.2.0.tgz", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
"bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"diff": ["diff@8.0.4", "https://npm.nose.space/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,8 @@ An app is an HTTP server that runs on its assigned port.
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/<name>/
|
apps/<name>/
|
||||||
<timestamp>/ # YYYYMMDD-HHMMSS
|
package.json
|
||||||
package.json
|
index.tsx
|
||||||
index.tsx
|
|
||||||
current -> <timestamp> # symlink to active version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**package.json** must have `scripts.toes`:
|
**package.json** must have `scripts.toes`:
|
||||||
|
|
@ -48,6 +46,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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ The cron tool discovers jobs from all apps and runs them automatically.
|
||||||
Add a file to `cron/` in any app:
|
Add a file to `cron/` in any app:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// apps/my-app/current/cron/daily-cleanup.ts
|
// apps/my-app/cron/daily-cleanup.ts
|
||||||
export const schedule = "day"
|
export const schedule = "day"
|
||||||
|
|
||||||
export default async function() {
|
export default async function() {
|
||||||
|
|
@ -73,7 +73,7 @@ Jobs track:
|
||||||
## discovery
|
## discovery
|
||||||
|
|
||||||
The cron tool:
|
The cron tool:
|
||||||
1. Scans `APPS_DIR/*/current/cron/*.ts`
|
1. Scans `APPS_DIR/*/cron/*.ts`
|
||||||
2. Imports each file to read `schedule`
|
2. Imports each file to read `schedule`
|
||||||
3. Validates the schedule
|
3. Validates the schedule
|
||||||
4. Registers with croner
|
4. Registers with croner
|
||||||
|
|
|
||||||
51
docs/ENV.md
Normal file
51
docs/ENV.md
Normal 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
|
||||||
915
docs/GUIDE.md
Normal file
915
docs/GUIDE.md
Normal file
|
|
@ -0,0 +1,915 @@
|
||||||
|
# Toes User Guide
|
||||||
|
|
||||||
|
Toes is a personal web appliance that runs multiple web apps on your home network. Plug it in, turn it on, and forget about the cloud.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Creating an App](#creating-an-app)
|
||||||
|
- [App Templates](#app-templates)
|
||||||
|
- [App Structure](#app-structure)
|
||||||
|
- [The Bare Minimum](#the-bare-minimum)
|
||||||
|
- [Using Hype](#using-hype)
|
||||||
|
- [Using Forge](#using-forge)
|
||||||
|
- [Creating a Tool](#creating-a-tool)
|
||||||
|
- [What's a Tool?](#whats-a-tool)
|
||||||
|
- [Tool Setup](#tool-setup)
|
||||||
|
- [Theme Tokens](#theme-tokens)
|
||||||
|
- [Accessing App Data](#accessing-app-data)
|
||||||
|
- [CLI Reference](#cli-reference)
|
||||||
|
- [App Management](#app-management)
|
||||||
|
- [Lifecycle](#lifecycle)
|
||||||
|
- [Deploying Code](#deploying-code)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
|
- [Cron Jobs](#cron-jobs-1)
|
||||||
|
- [Metrics](#metrics)
|
||||||
|
- [Sharing](#sharing)
|
||||||
|
- [Environment Variables](#environment-variables-1)
|
||||||
|
- [Health Checks](#health-checks)
|
||||||
|
- [Running over HTTP](#running-over-http)
|
||||||
|
- [App Lifecycle](#app-lifecycle)
|
||||||
|
- [Cron Jobs](#cron-jobs)
|
||||||
|
- [Data Persistence](#data-persistence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the CLI
|
||||||
|
curl -fsSL http://toes.local/install | bash
|
||||||
|
|
||||||
|
# Create a new app (scaffolds, inits git, and pushes to server)
|
||||||
|
toes new my-app
|
||||||
|
|
||||||
|
# Enter the directory, install deps, and develop locally
|
||||||
|
cd my-app
|
||||||
|
bun install
|
||||||
|
bun dev
|
||||||
|
|
||||||
|
# Deploy changes (standard git)
|
||||||
|
git add . && git commit -m "my changes"
|
||||||
|
git push toes main
|
||||||
|
|
||||||
|
# Open in browser
|
||||||
|
toes open
|
||||||
|
```
|
||||||
|
|
||||||
|
Your app is now running at `http://my-app.toes.local`.
|
||||||
|
|
||||||
|
`toes new` automatically sets up a `toes` git remote pointing at the server. Pushing to it triggers a deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating an App
|
||||||
|
|
||||||
|
### App Templates
|
||||||
|
|
||||||
|
Toes ships with three templates. Pick one when creating an app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes new my-app # SSR (default)
|
||||||
|
toes new my-app --bare # Minimal
|
||||||
|
toes new my-app --spa # Single-page app
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSR** — Server-side rendered with a pages directory. Best for most apps. Uses Hype's built-in layout and page routing.
|
||||||
|
|
||||||
|
**Bare** — Just an `index.tsx` with a single route. Good when you want to start from scratch.
|
||||||
|
|
||||||
|
**SPA** — Client-side rendering with `hono/jsx/dom`. Hype serves the HTML shell and static files; the browser handles routing and rendering.
|
||||||
|
|
||||||
|
### App Structure
|
||||||
|
|
||||||
|
A generated SSR app looks like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
.gitignore # Files to exclude from sync and deploy
|
||||||
|
.npmrc # Points to the private registry
|
||||||
|
package.json # Must have scripts.toes
|
||||||
|
tsconfig.json # TypeScript config
|
||||||
|
index.tsx # Entry point (re-exports from src/server)
|
||||||
|
src/
|
||||||
|
server/
|
||||||
|
index.tsx # Hype app with routes
|
||||||
|
pages/
|
||||||
|
index.tsx # Page components
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Bare Minimum
|
||||||
|
|
||||||
|
Every app needs three things:
|
||||||
|
|
||||||
|
1. **`package.json`** with a `scripts.toes` entry
|
||||||
|
2. **`index.tsx`** that exports `app.defaults`
|
||||||
|
3. **A `GET /ok` route** that returns 200 (health check)
|
||||||
|
|
||||||
|
**package.json:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"icon": "🎨"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/forge": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `scripts.toes` field is how Toes discovers your app. The `toes.icon` field sets the emoji shown in the dashboard.
|
||||||
|
|
||||||
|
**.npmrc:**
|
||||||
|
|
||||||
|
```
|
||||||
|
registry=https://npm.nose.space
|
||||||
|
```
|
||||||
|
|
||||||
|
Required for installing `@because/*` packages.
|
||||||
|
|
||||||
|
**index.tsx:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype()
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<h1>Hello World</h1>))
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. Push to the server and it runs.
|
||||||
|
|
||||||
|
### Using Hype
|
||||||
|
|
||||||
|
Hype wraps [Hono](https://hono.dev). Everything you know from Hono works here. Hype adds a few extras:
|
||||||
|
|
||||||
|
**Basic routing:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype()
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<h1>Home</h1>))
|
||||||
|
app.get('/about', c => c.html(<h1>About</h1>))
|
||||||
|
app.post('/api/items', async c => {
|
||||||
|
const body = await c.req.json()
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sub-routers:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const api = Hype.router()
|
||||||
|
api.get('/items', c => c.json([]))
|
||||||
|
api.post('/items', async c => {
|
||||||
|
const body = await c.req.json()
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.route('/api', api) // mounts at /api/items
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server-Sent Events:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
app.sse('/stream', (send, c) => {
|
||||||
|
send({ hello: 'world' })
|
||||||
|
const interval = setInterval(() => send({ time: Date.now() }), 1000)
|
||||||
|
return () => clearInterval(interval) // cleanup on disconnect
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constructor options:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const app = new Hype({
|
||||||
|
layout: true, // Wraps pages in an HTML layout (default: true)
|
||||||
|
prettyHTML: true, // Pretty-print HTML output (default: true)
|
||||||
|
logging: true, // Log requests to stdout (default: true)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Forge
|
||||||
|
|
||||||
|
Forge is a CSS-in-JS library that creates styled JSX components. Define a component once, use it everywhere.
|
||||||
|
|
||||||
|
**Basic usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
|
||||||
|
const Box = define('Box', {
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: '6px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
})
|
||||||
|
// <Box>content</Box> renders <div class="Box">content</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Numbers auto-convert to `px` (except `flex`, `opacity`, `zIndex`, `fontWeight`).
|
||||||
|
|
||||||
|
**Set the HTML element:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Button = define('Button', { base: 'button', padding: '8px 16px' })
|
||||||
|
const Link = define('Link', { base: 'a', textDecoration: 'none' })
|
||||||
|
const Input = define('Input', { base: 'input', padding: 8, border: '1px solid #ccc' })
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pseudo-classes (`states`):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Item = define('Item', {
|
||||||
|
padding: 12,
|
||||||
|
states: {
|
||||||
|
':hover': { backgroundColor: '#eee' },
|
||||||
|
':last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nested selectors:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const List = define('List', {
|
||||||
|
selectors: {
|
||||||
|
'& > li:last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Button = define('Button', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '8px 16px',
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: { backgroundColor: '#2563eb', color: 'white' },
|
||||||
|
danger: { backgroundColor: '#dc2626', color: 'white' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// <Button variant="primary">Save</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Serving CSS:**
|
||||||
|
|
||||||
|
Forge generates CSS at runtime. Serve it from a route:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { stylesToCSS } from '@because/forge'
|
||||||
|
|
||||||
|
app.get('/styles.css', c =>
|
||||||
|
c.text(stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then link it in your HTML:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a Tool
|
||||||
|
|
||||||
|
### What's a Tool?
|
||||||
|
|
||||||
|
A tool is an app that appears as a tab inside the Toes dashboard instead of in the sidebar. Tools render in an iframe and receive the currently selected app as a `?app=` query parameter. Good for things like a code editor, log viewer, env manager, or cron scheduler.
|
||||||
|
|
||||||
|
From the server's perspective, a tool is identical to an app — same lifecycle, same health checks, same port allocation. The only differences are in `package.json` and how you render.
|
||||||
|
|
||||||
|
### Tool Setup
|
||||||
|
|
||||||
|
A tool needs three extra things compared to a regular app:
|
||||||
|
|
||||||
|
1. Set `"tool": true` in `package.json`
|
||||||
|
2. Include `<ToolScript />` in the HTML body
|
||||||
|
3. Prepend `baseStyles` to CSS output
|
||||||
|
|
||||||
|
**package.json:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-tool",
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"icon": "🔧",
|
||||||
|
"tool": true
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/toes": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `"tool"` to `true` for a tab labeled with the app name, or to a string for a custom label (e.g., `"tool": ".env"`).
|
||||||
|
|
||||||
|
**index.tsx:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function Layout({ title, children }: { title: string; children: Child }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ToolScript />
|
||||||
|
<Container>{children}</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
app.get('/styles.css', c =>
|
||||||
|
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(<Layout title="My Tool"><p>No app selected</p></Layout>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="My Tool">
|
||||||
|
<h2>{appName}</h2>
|
||||||
|
<p>Tool content for {appName}</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
- `<ToolScript />` handles dark/light mode syncing and iframe height communication with the dashboard.
|
||||||
|
- `baseStyles` sets the body background to match the dashboard theme.
|
||||||
|
- `prettyHTML: false` is recommended for tools since their output is inside an iframe.
|
||||||
|
- The `?app=` query parameter tells you which app the user has selected in the sidebar.
|
||||||
|
|
||||||
|
### Theme Tokens
|
||||||
|
|
||||||
|
Tools should use theme tokens to match the dashboard's look. Import `theme` from `@because/toes/tools`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { theme } from '@because/toes/tools'
|
||||||
|
|
||||||
|
const Card = define('Card', {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
padding: theme('spacing-lg'),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Available tokens:
|
||||||
|
|
||||||
|
| Token | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `colors-bg` | Page background |
|
||||||
|
| `colors-bgSubtle` | Subtle background |
|
||||||
|
| `colors-bgElement` | Element background (cards, inputs) |
|
||||||
|
| `colors-bgHover` | Hover background |
|
||||||
|
| `colors-text` | Primary text |
|
||||||
|
| `colors-textMuted` | Secondary text |
|
||||||
|
| `colors-textFaint` | Tertiary/disabled text |
|
||||||
|
| `colors-border` | Borders |
|
||||||
|
| `colors-link` | Link text |
|
||||||
|
| `colors-primary` | Primary action color |
|
||||||
|
| `colors-primaryText` | Text on primary color |
|
||||||
|
| `colors-error` | Error color |
|
||||||
|
| `colors-dangerBorder` | Danger state border |
|
||||||
|
| `colors-dangerText` | Danger text |
|
||||||
|
| `colors-success` | Success color |
|
||||||
|
| `colors-successBg` | Success background |
|
||||||
|
| `colors-statusRunning` | Running indicator |
|
||||||
|
| `colors-statusStopped` | Stopped indicator |
|
||||||
|
| `fonts-sans` | Sans-serif font stack |
|
||||||
|
| `fonts-mono` | Monospace font stack |
|
||||||
|
| `spacing-xs` | 4px |
|
||||||
|
| `spacing-sm` | 8px |
|
||||||
|
| `spacing-md` | 12px |
|
||||||
|
| `spacing-lg` | 16px |
|
||||||
|
| `spacing-xl` | 24px |
|
||||||
|
| `radius-md` | 6px |
|
||||||
|
|
||||||
|
### Accessing App Data
|
||||||
|
|
||||||
|
**Reading app files:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
app.get('/', c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
if (!appName) return c.html(<p>No app selected</p>)
|
||||||
|
|
||||||
|
const appPath = join(APPS_DIR, appName)
|
||||||
|
// Read files from appPath...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Calling the Toes API:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
|
||||||
|
// List all apps
|
||||||
|
const apps = await fetch(`${TOES_URL}/api/apps`).then(r => r.json())
|
||||||
|
|
||||||
|
// Get a specific app
|
||||||
|
const app = await fetch(`${TOES_URL}/api/apps/${name}`).then(r => r.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linking between tools:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="/tool/code?app=my-app&file=index.tsx">Edit in Code</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool URLs go through `/tool/:name` which redirects to the tool's subdomain with query params preserved.
|
||||||
|
|
||||||
|
**Listening to lifecycle events:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { on } from '@because/toes/tools'
|
||||||
|
|
||||||
|
const unsub = on('app:start', event => {
|
||||||
|
console.log(`${event.app} started at ${event.time}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Event types: 'app:start', 'app:stop', 'app:create', 'app:delete', 'app:activate'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
The CLI connects to your Toes server over HTTP. By default it connects to `http://toes.local`. Set `TOES_URL` to point elsewhere, or set `DEV=1` to use `http://localhost:3000`.
|
||||||
|
|
||||||
|
Most commands accept an optional app name. If omitted, the CLI uses the current directory's `package.json` name.
|
||||||
|
|
||||||
|
### App Management
|
||||||
|
|
||||||
|
**`toes list`** — List all apps and their status.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes list # Show apps and tools
|
||||||
|
toes list --apps # Apps only (exclude tools)
|
||||||
|
toes list --tools # Tools only
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes new [name]`** — Create a new app from a template.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes new my-app # SSR template (default)
|
||||||
|
toes new my-app --bare # Minimal template
|
||||||
|
toes new my-app --spa # SPA template
|
||||||
|
```
|
||||||
|
|
||||||
|
Scaffolds the app locally, initializes a git repo with a `toes` remote pointing at the server, and pushes. The git push triggers a deploy. If run without a name, scaffolds the current directory.
|
||||||
|
|
||||||
|
**`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime).
|
||||||
|
|
||||||
|
**`toes get <name>`** — Clone an app from the server to your local machine.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes get my-app # Clones into ./my-app/
|
||||||
|
cd my-app
|
||||||
|
bun install
|
||||||
|
bun dev # Develop locally
|
||||||
|
```
|
||||||
|
|
||||||
|
The clone comes with a `toes` remote already configured, so `git push toes main` deploys.
|
||||||
|
|
||||||
|
**`toes open [name]`** — Open a running app in your browser.
|
||||||
|
|
||||||
|
**`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation.
|
||||||
|
|
||||||
|
**`toes rm [name]`** — Permanently delete an app from the server. Requires typing a confirmation.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
**`toes start [name]`** — Start a stopped app.
|
||||||
|
|
||||||
|
**`toes stop [name]`** — Stop a running app.
|
||||||
|
|
||||||
|
**`toes restart [name]`** — Stop and start an app.
|
||||||
|
|
||||||
|
**`toes logs [name]`** — View logs for an app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes logs my-app # Today's logs
|
||||||
|
toes logs my-app -f # Follow (tail) logs in real-time
|
||||||
|
toes logs my-app -d 2026-01-15 # Logs from a specific date
|
||||||
|
toes logs my-app -s 2d # Logs from the last 2 days
|
||||||
|
toes logs my-app -g error # Filter logs by pattern
|
||||||
|
toes logs my-app -f -g error # Follow and filter
|
||||||
|
```
|
||||||
|
|
||||||
|
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
|
||||||
|
|
||||||
|
### Deploying Code
|
||||||
|
|
||||||
|
Toes uses git for deployments. Each app has a `toes` remote that points to the server's git tool. Pushing to it extracts the latest commit and deploys it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make changes, commit, and deploy
|
||||||
|
git add .
|
||||||
|
git commit -m "update homepage"
|
||||||
|
git push toes main
|
||||||
|
```
|
||||||
|
|
||||||
|
The git push triggers the server to:
|
||||||
|
1. Store the commit in a bare repo at `DATA_DIR/repos/<name>.git`
|
||||||
|
2. Extract HEAD into the app directory
|
||||||
|
3. Run `bun install` and restart the app
|
||||||
|
|
||||||
|
Use standard git commands for history, diffing, and rollback:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log # View deploy history
|
||||||
|
git diff HEAD~1 # See what changed
|
||||||
|
git revert HEAD # Undo last deploy
|
||||||
|
git push toes main # Deploy the revert
|
||||||
|
```
|
||||||
|
|
||||||
|
To clone an existing app from the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://git.toes.local/my-app
|
||||||
|
cd my-app
|
||||||
|
bun install
|
||||||
|
bun dev # Develop locally
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
**`toes env [name]`** — List environment variables for an app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env my-app # List app vars
|
||||||
|
toes env -g # List global vars
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes env set [name] <KEY> [value]`** — Set a variable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env set my-app API_KEY sk-123 # Set for an app
|
||||||
|
toes env set my-app API_KEY=sk-123 # KEY=value format also works
|
||||||
|
toes env set -g API_KEY sk-123 # Set globally (shared by all apps)
|
||||||
|
```
|
||||||
|
|
||||||
|
Setting a variable automatically restarts the app.
|
||||||
|
|
||||||
|
**`toes env rm [name] <KEY>`** — Remove a variable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env rm my-app API_KEY # Remove from an app
|
||||||
|
toes env rm -g API_KEY # Remove global var
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
Every `git push toes main` creates a new deploy. Version history is managed through git.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline # List deploys
|
||||||
|
git revert HEAD # Undo last change
|
||||||
|
git push toes main # Deploy the revert
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Jobs
|
||||||
|
|
||||||
|
Cron commands talk to the cron tool running on your Toes server.
|
||||||
|
|
||||||
|
**`toes cron [app]`** — List all cron jobs, or jobs for a specific app.
|
||||||
|
|
||||||
|
**`toes cron status <app:name>`** — Show details for a specific job.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes cron status my-app:backup
|
||||||
|
# my-app:backup ok
|
||||||
|
#
|
||||||
|
# Schedule: day
|
||||||
|
# State: idle
|
||||||
|
# Last run: 2h ago
|
||||||
|
# Duration: 3s
|
||||||
|
# Exit code: 0
|
||||||
|
# Next run: in 22h
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes cron run <app:name>`** — Trigger a job immediately.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes cron run my-app:backup
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes cron log [target]`** — View cron logs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes cron log # All cron logs
|
||||||
|
toes cron log my-app # Cron logs for an app
|
||||||
|
toes cron log my-app:backup # Logs for a specific job
|
||||||
|
toes cron log -f # Follow logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
**`toes metrics [name]`** — Show CPU, memory, and disk usage.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes metrics # All apps
|
||||||
|
toes metrics my-app # Single app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sharing
|
||||||
|
|
||||||
|
**`toes share [name]`** — Create a public tunnel to share an app over the internet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes share my-app
|
||||||
|
# ↗ Sharing my-app... https://myapp.toes.space
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes unshare [name]`** — Stop sharing an app.
|
||||||
|
|
||||||
|
Every request to your app includes an `x-app-url` header with the app's public-facing URL. When shared, this is the tunnel URL (e.g., `https://myapp.toes.space`). When not shared, it's the local URL (e.g., `http://myapp.toes.local`). This works whether the request arrives through the local proxy or through a tunnel.
|
||||||
|
|
||||||
|
Use `appUrl()` from `@because/toes/tools` to read it — never hardcode your app's URL:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { appUrl } from '@because/toes/tools'
|
||||||
|
|
||||||
|
app.get('/callback', c => {
|
||||||
|
const url = appUrl(c.req.raw)
|
||||||
|
// "https://myapp.toes.space" when shared, "http://myapp.toes.local" otherwise
|
||||||
|
return c.redirect(`${url}/done`)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`). |
|
||||||
|
| `TOES_DIR` | Path to the Toes config directory. |
|
||||||
|
| `APP_URL` | The app's local URL (e.g., `http://myapp.toes.local`). For the public URL that accounts for sharing, use `appUrl(req)` from `@because/toes` (see [Sharing](#sharing)). |
|
||||||
|
|
||||||
|
You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals.
|
||||||
|
|
||||||
|
```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'))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running over HTTP
|
||||||
|
|
||||||
|
Toes serves apps over plain HTTP (`http://<app>.toes.local`), not HTTPS. This is fine for a home network appliance, but a few browser features assume HTTPS and will silently break if you're not aware of them.
|
||||||
|
|
||||||
|
> **Note:** `localhost` gets a special pass — browsers treat it as a secure context even over HTTP. But `.local` domains don't get that exemption, so these gotchas apply when accessing your apps at `<app>.toes.local` from another device.
|
||||||
|
|
||||||
|
### Cookies
|
||||||
|
|
||||||
|
If you set cookies with the `Secure` flag, browsers will silently ignore them — the cookie just won't be stored.
|
||||||
|
|
||||||
|
Don't do this:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
c.header('Set-Cookie', 'session=abc123; HttpOnly; Secure; SameSite=Lax')
|
||||||
|
```
|
||||||
|
|
||||||
|
Do this instead:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
c.header('Set-Cookie', 'session=abc123; HttpOnly; SameSite=Lax')
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're using a cookie library, make sure `secure` is set to `false` (or omitted):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { setCookie } from 'hono/cookie'
|
||||||
|
|
||||||
|
setCookie(c, 'session', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
secure: false, // toes apps run over HTTP
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clipboard API
|
||||||
|
|
||||||
|
`navigator.clipboard.writeText()` and `navigator.clipboard.readText()` require a secure context. They'll throw on `.local` domains.
|
||||||
|
|
||||||
|
Use the legacy fallback instead:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function copyToClipboard(text: string) {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Workers
|
||||||
|
|
||||||
|
Service workers only register on HTTPS origins (plus `localhost`). If you're building a PWA or want offline caching, it won't work on `.local`. This is a hard browser restriction with no workaround.
|
||||||
|
|
||||||
|
### Web Push Notifications
|
||||||
|
|
||||||
|
The Push API and `Notification.requestPermission()` require a secure context. For notifications on the local network, consider polling or SSE instead:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
app.sse('/notifications', (send, c) => {
|
||||||
|
// push updates over SSE instead of Web Push
|
||||||
|
send({ title: 'New item', body: 'Something happened' })
|
||||||
|
return () => {}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Geolocation & Camera/Mic
|
||||||
|
|
||||||
|
`navigator.geolocation` and `navigator.mediaDevices.getUserMedia()` require a secure context. These won't work on `.local` domains.
|
||||||
|
|
||||||
|
### Web Crypto
|
||||||
|
|
||||||
|
`crypto.subtle` (for hashing, encryption, key generation) requires a secure context. Use a library like `tweetnacl` if you need crypto in the browser, or do it server-side:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Server-side — works fine, no secure context needed
|
||||||
|
const hash = new Bun.CryptoHasher('sha256').update(data).digest('hex')
|
||||||
|
```
|
||||||
|
|
||||||
|
### What about `toes share`?
|
||||||
|
|
||||||
|
`toes share` tunnels your app through HTTPS, so all of the above works when accessed through the tunnel URL. But since your app should also work locally, don't rely on secure-context APIs unless you're okay with them only working when shared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Lifecycle
|
||||||
|
|
||||||
|
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
149
docs/TAILSCALE.md
Normal 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.
|
||||||
|
|
@ -34,23 +34,27 @@ app.get('/', c => {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## environment
|
||||||
|
|
||||||
|
- `PORT` - your assigned port
|
||||||
|
- `APPS_DIR` - path to `/apps` directory
|
||||||
|
- `DATA_DIR` - per-app data directory (`toes/<tool-name>/`)
|
||||||
|
- `TOES_URL` - base URL of the Toes server
|
||||||
|
- `TOES_DIR` - path to the toes config directory
|
||||||
|
|
||||||
## accessing app files
|
## accessing app files
|
||||||
|
|
||||||
Always go through the `current` symlink:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const APPS_DIR = process.env.APPS_DIR ?? '.'
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
const appPath = join(APPS_DIR, appName, 'current')
|
const appPath = join(APPS_DIR, appName)
|
||||||
```
|
```
|
||||||
|
|
||||||
Not `APPS_DIR/appName` directly.
|
|
||||||
|
|
||||||
## linking to tools
|
## linking to tools
|
||||||
|
|
||||||
Use `/tool/:name` URLs to link directly to tools with params:
|
Use `/tool/:name` URLs to link directly to tools with params:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<a href="/tool/code?app=my-app&version=20260130-000000">
|
<a href="/tool/code?app=my-app">
|
||||||
View in Code
|
View in Code
|
||||||
</a>
|
</a>
|
||||||
```
|
```
|
||||||
|
|
|
||||||
81
docs/WEBHOOKS.md
Normal file
81
docs/WEBHOOKS.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Rev Webhooks for Toes
|
||||||
|
|
||||||
|
Deploy Toes apps by saving to rev.host — no manual deploy step, no rsync scripts.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
rev save "fix combat" → rev.host → relay (sneaker.toes.space) → toes.local pulls + deploys
|
||||||
|
```
|
||||||
|
|
||||||
|
toes.local can't receive inbound connections (home NAT), so it maintains an outbound connection to a relay — same tunnel infrastructure used by `toes share`.
|
||||||
|
|
||||||
|
## Setup Flow
|
||||||
|
|
||||||
|
1. In the Toes dashboard (or `toes` CLI via SSH), enable rev webhooks for an app
|
||||||
|
2. Toes connects to the relay and gets a stable webhook URL
|
||||||
|
3. Add that URL in rev.host project settings as a webhook endpoint
|
||||||
|
4. Done — `rev save` and `rev merge` now trigger deploys
|
||||||
|
|
||||||
|
## What Toes Does on Webhook
|
||||||
|
|
||||||
|
1. Receives event from relay (repo, ref, timestamp)
|
||||||
|
2. Pulls latest from rev.host (needs a rev auth token stored in Toes env)
|
||||||
|
3. Runs `scripts.predeploy` if defined in package.json (type-check, build, etc.)
|
||||||
|
4. Runs `bun install`
|
||||||
|
5. Restarts the app
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable/disable rev webhooks
|
||||||
|
toes webhook enable [name] # Shows the relay URL to paste into rev.host
|
||||||
|
toes webhook disable [name]
|
||||||
|
|
||||||
|
# Manual trigger (pull latest and deploy now)
|
||||||
|
toes deploy [name]
|
||||||
|
|
||||||
|
# Check webhook status
|
||||||
|
toes webhook status [name]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings UI
|
||||||
|
|
||||||
|
App settings page gets a "Rev Webhooks" section:
|
||||||
|
- Toggle to enable/disable
|
||||||
|
- Displays the relay URL (copy button)
|
||||||
|
- Field for rev.host auth token
|
||||||
|
- Last deploy timestamp + status
|
||||||
|
- "Deploy Now" button (manual trigger)
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Toes needs read access to pull from rev.host. Store a rev API token per-app (or globally):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env set -g REV_TOKEN rt_abc123
|
||||||
|
# or per-app
|
||||||
|
toes env set my-app REV_TOKEN rt_abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Predeploy Scripts
|
||||||
|
|
||||||
|
Project-specific build steps go in package.json:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx",
|
||||||
|
"predeploy": "bunx tsc --noEmit && bun build client/main.tsx --outdir dist --minify"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Toes runs `predeploy` after pulling but before restarting. If it exits non-zero, the deploy is aborted and the previous version stays running.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should the relay URL be per-app or per-Toes-instance? (Per-instance with app routing via path seems simpler: `https://sneaker.toes.space/hooks/<instance-id>/<app-name>`)
|
||||||
|
- Webhook secret/signature verification — rev.host should sign payloads so the relay can't be spoofed
|
||||||
|
- Should `toes deploy` work without webhooks enabled? (Just pull from rev.host on demand — useful as a migration path from deploy.sh)
|
||||||
|
- Rollback: `toes rollback [name]` to revert to previous rev version?
|
||||||
26
install/bun.lock
Normal file
26
install/bun.lock
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "toes-install",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.3.2", "https://npm.nose.space/@types/node/-/node-25.3.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
135
install/install.sh
Executable file
135
install/install.sh
Executable file
|
|
@ -0,0 +1,135 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
##
|
||||||
|
# toes installer
|
||||||
|
# Usage: curl -fsSL https://toes.dev/install | sh
|
||||||
|
#
|
||||||
|
# Installs or updates toes on a Raspberry Pi.
|
||||||
|
# Must be run as the 'toes' user with passwordless sudo.
|
||||||
|
|
||||||
|
RELEASE_URL="https://toes.dev/release/latest.tar.gz"
|
||||||
|
DEST=~/toes
|
||||||
|
APPS_DIR=~/apps
|
||||||
|
DATA_DIR=~/data
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
|
||||||
|
|
||||||
|
quiet() { "$@" > /dev/null 2>&1; }
|
||||||
|
|
||||||
|
info() { echo " ${d}>>${r} $1"; }
|
||||||
|
|
||||||
|
fail() { echo " ${y}ERROR:${r} $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── Preflight ────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " ${d}╔══════════════════════════════════╗${r}"
|
||||||
|
echo " ${d}║${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
|
||||||
|
echo " ${d}╚══════════════════════════════════╝${r}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
[ "$(whoami)" = "toes" ] || fail "Must be run as the 'toes' user."
|
||||||
|
sudo -n true 2>/dev/null || fail "Requires passwordless sudo."
|
||||||
|
|
||||||
|
# ── System packages ──────────────────────────────────────
|
||||||
|
|
||||||
|
info "Updating system packages"
|
||||||
|
quiet sudo apt-get update
|
||||||
|
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
|
||||||
|
|
||||||
|
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
|
||||||
|
info "Setting fish as default shell"
|
||||||
|
quiet sudo chsh -s /usr/bin/fish toes
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Bun ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BUN="$HOME/.bun/bin/bun"
|
||||||
|
|
||||||
|
if [ ! -x "$BUN" ]; then
|
||||||
|
info "Installing bun"
|
||||||
|
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
|
||||||
|
[ -x "$BUN" ] || fail "bun installation failed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo ln -sf "$BUN" /usr/local/bin/bun
|
||||||
|
|
||||||
|
sudo setcap 'cap_net_bind_service=+ep' "$BUN"
|
||||||
|
|
||||||
|
# ── Download ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Downloading toes"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
curl -fsSL "$RELEASE_URL" | tar xz --strip-components=1 -C "$DEST"
|
||||||
|
|
||||||
|
# ── Directories ──────────────────────────────────────────
|
||||||
|
|
||||||
|
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
|
||||||
|
|
||||||
|
# ── Dependencies ─────────────────────────────────────────
|
||||||
|
|
||||||
|
cd "$DEST"
|
||||||
|
|
||||||
|
info "Installing dependencies"
|
||||||
|
quiet bun install
|
||||||
|
|
||||||
|
# ── Bundled apps ─────────────────────────────────────────
|
||||||
|
|
||||||
|
REPOS_DIR="$DATA_DIR/repos"
|
||||||
|
mkdir -p "$REPOS_DIR"
|
||||||
|
|
||||||
|
info "Installing bundled apps"
|
||||||
|
pids=()
|
||||||
|
for app_dir in "$DEST"/apps/*/; do
|
||||||
|
app=$(basename "$app_dir")
|
||||||
|
[ -f "$app_dir/package.json" ] || continue
|
||||||
|
echo " $app"
|
||||||
|
(
|
||||||
|
cp -a "$app_dir" "$APPS_DIR/$app"
|
||||||
|
quiet bun install --frozen-lockfile --cwd "$APPS_DIR/$app" || quiet bun install --cwd "$APPS_DIR/$app"
|
||||||
|
) &
|
||||||
|
pids+=("$!")
|
||||||
|
done
|
||||||
|
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
wait "$pid" || fail "A bundled app failed to install."
|
||||||
|
done
|
||||||
|
|
||||||
|
# Copy pre-built bare repos for git-based versioning
|
||||||
|
cp -a "$DEST"/dist/repos/*.git "$REPOS_DIR/"
|
||||||
|
|
||||||
|
# ── CLI + SSH ────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Setting up SSH access"
|
||||||
|
sudo bash "$DEST/scripts/setup-ssh.sh"
|
||||||
|
|
||||||
|
info "Installing CLI"
|
||||||
|
sudo install -m 755 "$DEST/dist/toes" /usr/local/bin/toes
|
||||||
|
|
||||||
|
# ── Systemd ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Installing toes service"
|
||||||
|
sudo install -m 644 "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable toes
|
||||||
|
|
||||||
|
info "Restarting toes"
|
||||||
|
sudo systemctl restart toes
|
||||||
|
|
||||||
|
# ── Done ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
VERSION=$(grep '"version"' "$DEST/package.json" | head -1 | sed 's/.*"version": *"\(.*\)".*/\1/')
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
|
||||||
|
echo " ${d}─────────────────────────────${r}"
|
||||||
|
echo ""
|
||||||
|
echo " Dashboard: ${c}http://$(hostname).local${r}"
|
||||||
|
echo " SSH CLI: ${c}ssh cli@$(hostname).local${r}"
|
||||||
|
echo ""
|
||||||
|
echo " ${d}Grab the CLI:${r}"
|
||||||
|
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"
|
||||||
|
echo ""
|
||||||
16
install/package.json
Normal file
16
install/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "toes-install",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "install toes",
|
||||||
|
"module": "server.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run server.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
install/server.ts
Normal file
25
install/server.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { resolve } from "path"
|
||||||
|
|
||||||
|
const script = await Bun.file(resolve(import.meta.dir, "install.sh")).text()
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
port: parseInt(process.env.PORT || "3000"),
|
||||||
|
fetch(req) {
|
||||||
|
if (new URL(req.url).pathname === "/install") {
|
||||||
|
return new Response(script, {
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new URL(req.url).pathname === "/shout") {
|
||||||
|
return Response.redirect(
|
||||||
|
"https://git.nose.space/defunkt/go-shout/raw/branch/main/install.sh",
|
||||||
|
302
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("404 Not Found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)
|
||||||
|
|
@ -1,29 +1,43 @@
|
||||||
{
|
{
|
||||||
|
"exclude": ["apps", "templates"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["ESNext"],
|
// Environment setup & latest features
|
||||||
|
"lib": [
|
||||||
|
"ESNext",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "hono/jsx",
|
"jsxImportSource": "hono/jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"$*": ["src/server/*"],
|
"$*": [
|
||||||
"#*": ["src/client/*"],
|
"./src/server/*"
|
||||||
"@*": ["src/shared/*"]
|
],
|
||||||
|
"@*": [
|
||||||
|
"./src/shared/*"
|
||||||
|
],
|
||||||
|
"%*": [
|
||||||
|
"./src/lib/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
32
package.json
32
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/toes",
|
"name": "@because/toes",
|
||||||
"version": "0.0.4",
|
"version": "0.0.19",
|
||||||
"description": "personal web appliance - turn it on and forget about the cloud",
|
"description": "personal web appliance - turn it on and forget about the cloud",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -15,32 +15,38 @@
|
||||||
"toes": "src/cli/index.ts"
|
"toes": "src/cli/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"check": "bun run templates && bunx tsc --noEmit",
|
||||||
"build": "./scripts/build.sh",
|
"build": "./scripts/build.sh",
|
||||||
|
"release": "./scripts/release.sh",
|
||||||
"cli:build": "bun run scripts/build.ts",
|
"cli:build": "bun run scripts/build.ts",
|
||||||
"cli:build:all": "bun run scripts/build.ts --all",
|
"cli:build:all": "bun run scripts/build.ts --all",
|
||||||
"cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin",
|
"cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin",
|
||||||
|
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes",
|
||||||
"cli:uninstall": "sudo rm /usr/local/bin",
|
"cli:uninstall": "sudo rm /usr/local/bin",
|
||||||
"deploy": "./scripts/deploy.sh",
|
"debug": "DEBUG=1 bun run dev",
|
||||||
"dev": "bun run --hot src/server/index.tsx",
|
"dev": "bun run templates && rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
|
||||||
|
"remote:migrate": "bun run scripts/migrate.ts",
|
||||||
|
"remote:deploy": "./scripts/remote-deploy.sh",
|
||||||
"remote:install": "./scripts/remote-install.sh",
|
"remote:install": "./scripts/remote-install.sh",
|
||||||
|
"remote:logs": "./scripts/remote-logs.sh",
|
||||||
"remote:restart": "./scripts/remote-restart.sh",
|
"remote:restart": "./scripts/remote-restart.sh",
|
||||||
"remote:start": "./scripts/remote-start.sh",
|
"remote:start": "./scripts/remote-start.sh",
|
||||||
"remote:stop": "./scripts/remote-stop.sh",
|
"remote:stop": "./scripts/remote-stop.sh",
|
||||||
"start": "bun run src/server/index.tsx",
|
"start": "bun run templates && bun run src/server/index.tsx",
|
||||||
|
"templates": "bun run scripts/embed-templates.ts",
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/diff": "^8.0.0"
|
"@types/diff": "^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.7",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.9",
|
||||||
"commander": "^14.0.2",
|
"@because/sneaker": "^0.0.5",
|
||||||
"diff": "^8.0.3",
|
"@because/toes": "^0.0.15",
|
||||||
"kleur": "^4.1.5"
|
"ansis": "^4.2.0",
|
||||||
|
"commander": "14.0.3",
|
||||||
|
"diff": "^8.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
scripts/build-repos.sh
Executable file
36
scripts/build-repos.sh
Executable file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pre-builds bare git repos for bundled apps so the install script
|
||||||
|
# doesn't need to run any git commands.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ROOT="$SCRIPT_DIR/.."
|
||||||
|
APPS_DIR="$ROOT/apps"
|
||||||
|
OUT_DIR="$ROOT/dist/repos"
|
||||||
|
|
||||||
|
rm -rf "$OUT_DIR"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
for app_dir in "$APPS_DIR"/*/; do
|
||||||
|
app=$(basename "$app_dir")
|
||||||
|
[ -f "$app_dir/package.json" ] || continue
|
||||||
|
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
tar -C "$app_dir" \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='logs' \
|
||||||
|
--exclude='current' \
|
||||||
|
--exclude='[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' \
|
||||||
|
-cf - . | tar -C "$tmp" -xf -
|
||||||
|
|
||||||
|
git -C "$tmp" init -b main -q
|
||||||
|
git -C "$tmp" add -A
|
||||||
|
git -C "$tmp" -c user.name=toes -c user.email=toes@localhost commit -q -m "install"
|
||||||
|
git clone --bare -q "$tmp" "$OUT_DIR/$app.git"
|
||||||
|
git -C "$OUT_DIR/$app.git" config http.receivepack true
|
||||||
|
|
||||||
|
rm -rf "$tmp"
|
||||||
|
echo " $app"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ">> Bare repos built in dist/repos/"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -15,3 +16,4 @@ bun build src/client/index.tsx \
|
||||||
|
|
||||||
echo ">> Client bundle created at pub/client/index.js"
|
echo ">> Client bundle created at pub/client/index.js"
|
||||||
ls -lh pub/client/index.js
|
ls -lh pub/client/index.js
|
||||||
|
|
||||||
|
|
|
||||||
101
scripts/build.ts
101
scripts/build.ts
|
|
@ -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)
|
||||||
|
|
@ -8,6 +9,7 @@ import { join } from 'path'
|
||||||
|
|
||||||
const DIST_DIR = join(import.meta.dir, '..', 'dist')
|
const DIST_DIR = join(import.meta.dir, '..', 'dist')
|
||||||
const ENTRY_POINT = join(import.meta.dir, '..', 'src', 'cli', 'index.ts')
|
const ENTRY_POINT = join(import.meta.dir, '..', 'src', 'cli', 'index.ts')
|
||||||
|
const GIT_SHA = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD']).stdout.toString().trim() || 'unknown'
|
||||||
|
|
||||||
interface BuildTarget {
|
interface BuildTarget {
|
||||||
arch: string
|
arch: string
|
||||||
|
|
@ -22,52 +24,6 @@ const TARGETS: BuildTarget[] = [
|
||||||
{ os: 'linux', arch: 'x64', name: 'toes-linux-x64' },
|
{ os: 'linux', arch: 'x64', name: 'toes-linux-x64' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Ensure dist directory exists
|
|
||||||
if (!existsSync(DIST_DIR)) {
|
|
||||||
mkdirSync(DIST_DIR, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse command line args
|
|
||||||
const args = process.argv.slice(2)
|
|
||||||
const buildAll = args.includes('--all')
|
|
||||||
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
|
|
||||||
|
|
||||||
async function buildTarget(target: BuildTarget) {
|
|
||||||
console.log(`Building ${target.name}...`)
|
|
||||||
|
|
||||||
const output = join(DIST_DIR, target.name)
|
|
||||||
|
|
||||||
const proc = Bun.spawn([
|
|
||||||
'bun',
|
|
||||||
'build',
|
|
||||||
ENTRY_POINT,
|
|
||||||
'--compile',
|
|
||||||
'--target',
|
|
||||||
'bun',
|
|
||||||
'--minify',
|
|
||||||
'--sourcemap=external',
|
|
||||||
'--outfile',
|
|
||||||
output,
|
|
||||||
], {
|
|
||||||
stdout: 'inherit',
|
|
||||||
stderr: 'inherit',
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_TARGET_OS: target.os,
|
|
||||||
BUN_TARGET_ARCH: target.arch,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const exitCode = await proc.exited
|
|
||||||
|
|
||||||
if (exitCode === 0) {
|
|
||||||
console.log(`✓ Built ${target.name}`)
|
|
||||||
} else {
|
|
||||||
console.error(`✗ Failed to build ${target.name}`)
|
|
||||||
process.exit(exitCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildCurrent() {
|
async function buildCurrent() {
|
||||||
const platform = process.platform
|
const platform = process.platform
|
||||||
const arch = process.arch
|
const arch = process.arch
|
||||||
|
|
@ -85,6 +41,7 @@ async function buildCurrent() {
|
||||||
'bun',
|
'bun',
|
||||||
'--minify',
|
'--minify',
|
||||||
'--sourcemap=external',
|
'--sourcemap=external',
|
||||||
|
`--define=__GIT_SHA__="${GIT_SHA}"`,
|
||||||
'--outfile',
|
'--outfile',
|
||||||
output,
|
output,
|
||||||
], {
|
], {
|
||||||
|
|
@ -104,6 +61,58 @@ async function buildCurrent() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildTarget(target: BuildTarget) {
|
||||||
|
console.log(`Building ${target.name}...`)
|
||||||
|
|
||||||
|
const output = join(DIST_DIR, target.name)
|
||||||
|
|
||||||
|
const proc = Bun.spawn([
|
||||||
|
'bun',
|
||||||
|
'build',
|
||||||
|
ENTRY_POINT,
|
||||||
|
'--compile',
|
||||||
|
'--target',
|
||||||
|
`bun-${target.os}-${target.arch}`,
|
||||||
|
'--minify',
|
||||||
|
'--sourcemap=external',
|
||||||
|
`--define=__GIT_SHA__="${GIT_SHA}"`,
|
||||||
|
'--outfile',
|
||||||
|
output,
|
||||||
|
], {
|
||||||
|
stdout: 'inherit',
|
||||||
|
stderr: 'inherit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
|
||||||
|
if (exitCode === 0) {
|
||||||
|
console.log(`✓ Built ${target.name}`)
|
||||||
|
} else {
|
||||||
|
console.error(`✗ Failed to build ${target.name}`)
|
||||||
|
process.exit(exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed template files before compiling
|
||||||
|
const embedProc = Bun.spawn(['bun', 'run', join(import.meta.dir, 'embed-templates.ts')], {
|
||||||
|
stdout: 'inherit',
|
||||||
|
stderr: 'inherit',
|
||||||
|
})
|
||||||
|
if (await embedProc.exited !== 0) {
|
||||||
|
console.error('✗ Failed to embed templates')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure dist directory exists
|
||||||
|
if (!existsSync(DIST_DIR)) {
|
||||||
|
mkdirSync(DIST_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command line args
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const buildAll = args.includes('--all')
|
||||||
|
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
|
||||||
|
|
||||||
// Main build logic
|
// Main build logic
|
||||||
if (buildAll) {
|
if (buildAll) {
|
||||||
console.log('Building for all targets...\n')
|
console.log('Building for all targets...\n')
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user