Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ad7145229 | |||
| d2b0eb410f | |||
| ffe1df22e6 | |||
| 7f82a37c63 | |||
| 6055b9798d | |||
| f7397dc060 | |||
| d69dc6ae9d | |||
| 4853ee4f7a | |||
|
|
74f9062a89 | ||
|
|
55316027c0 | ||
| cfba207077 | |||
| 702019279a | |||
| 141622f86f | |||
| 526678e87a | |||
| 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 | |||
|
|
8347177c77 | ||
| f14a731cae | |||
| a58c42e0d4 | |||
| 054c73b926 | |||
| f3040abc5d | |||
| a81d61f910 | |||
| 27c1bfd969 | |||
| 1fbc7a9858 | |||
| b8434ef2df | |||
| e4310bda6d | |||
| a56af4ed47 | |||
| 68ebf1d7a7 | |||
| 51f347a544 | |||
| a25088e723 | |||
| d1b7e973d3 | |||
| 4cbe5c2566 | |||
| 6f13ba0f66 | |||
| b1060dbdce | |||
| cd1a1cdbe5 | |||
| f1b78197c7 | |||
| ae38084440 | |||
| d45144478d | |||
| 7574a8702e | |||
| 4c1701a06d | |||
| 1c51427034 | |||
| 2dea6d948f | |||
| ed46638b50 | |||
| 5ea9d5f843 |
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,6 +1,9 @@
|
||||||
|
.sandlot/
|
||||||
|
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
pub/client/index.js
|
pub/client/index.js
|
||||||
|
toes/
|
||||||
|
|
||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
|
|
@ -33,3 +36,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# app symlinks (created on boot)
|
||||||
|
apps/*/current
|
||||||
|
|
|
||||||
191
CLAUDE.md
191
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,93 +6,142 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. Host server scans `/apps` directory for valid apps
|
1. Server scans `APPS_DIR` for directories with a `package.json` containing a `scripts.toes` entry
|
||||||
2. Valid app = has `package.json` with `scripts.toes` entry
|
2. Each app is spawned as a child process with a unique port (3001-3100)
|
||||||
3. Each app spawned as child process with unique port (3001+)
|
3. Dashboard UI shows all apps with status, logs, and links via SSE
|
||||||
4. Dashboard UI shows all apps with current status, logs, and links
|
4. CLI communicates with the server over HTTP
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
### Server (`src/server/`)
|
|
||||||
- `apps.ts` - **The heart**: app discovery, process management, health checks, auto-restart
|
|
||||||
- `api/apps.ts` - REST API for app lifecycle (start/stop/restart, logs, icons, rename)
|
|
||||||
- `api/sync.ts` - File sync protocol (manifest, push/pull, watch)
|
|
||||||
- `index.tsx` - Entry point (minimal, initializes Hype)
|
|
||||||
- `shell.tsx` - HTML shell for web UI
|
|
||||||
|
|
||||||
### Client (`src/client/`)
|
|
||||||
- `components/` - Dashboard, Sidebar, AppDetail, Nav
|
|
||||||
- `modals/` - NewApp, RenameApp, DeleteApp dialogs
|
|
||||||
- `styles/` - Forge CSS-in-JS (themes, buttons, forms, layout)
|
|
||||||
- `state.ts` - Client state management
|
|
||||||
- `api.ts` - API client
|
|
||||||
|
|
||||||
### CLI (`src/cli/`)
|
|
||||||
- `commands/manage.ts` - list, start, stop, restart, info, new, rename, delete, open
|
|
||||||
- `commands/sync.ts` - push, pull, sync
|
|
||||||
- `commands/logs.ts` - log viewing with tail support
|
|
||||||
|
|
||||||
### Shared (`src/shared/`)
|
|
||||||
- Code shared between frontend (browser) and backend (server)
|
|
||||||
- `types.ts` - App, AppState, Manifest interfaces
|
|
||||||
- IMPORTANT: Cannot use filesystem or Node APIs (runs in browser)
|
|
||||||
|
|
||||||
### Lib (`src/lib/`)
|
|
||||||
- Code shared between CLI and server (server-side only)
|
|
||||||
- `templates.ts` - Template generation for new apps
|
|
||||||
- Can use filesystem and Node APIs (never runs in browser)
|
|
||||||
|
|
||||||
### Other
|
|
||||||
- `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script
|
|
||||||
- `TODO.txt` - Task list
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Bun** runtime (not Node)
|
- **Bun** runtime (not Node)
|
||||||
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype
|
- **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
|
||||||
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
|
- **Forge** (typed CSS-in-JS) from `@because/forge`
|
||||||
- **Commander** + **kleur** for CLI
|
- **Commander** + **kleur** for CLI
|
||||||
- TypeScript + Hono JSX
|
- TypeScript + Hono JSX
|
||||||
|
- Client renders with `hono/jsx/dom` (no build step, served directly)
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run --hot src/server/index.tsx # Dev mode with hot reload
|
bun run dev # Hot reload (deletes pub/client/index.js first)
|
||||||
|
bun run start # Production
|
||||||
|
bun run check # Type check
|
||||||
|
bun run test # Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## App Structure
|
## Project Structure
|
||||||
|
|
||||||
```tsx
|
```
|
||||||
// apps/example/index.tsx
|
src/
|
||||||
import { Hype } from "@because/hype"
|
server/ # HTTP server and process management ($)
|
||||||
const app = new Hype()
|
client/ # Browser-side dashboard
|
||||||
app.get("/", (c) => c.html(<h1>Content</h1>))
|
shared/ # Types shared between server and client (@)
|
||||||
export default app.defaults
|
lib/ # Code shared between CLI and server (%)
|
||||||
|
cli/ # CLI tool
|
||||||
|
tools/ # @because/toes package exports
|
||||||
|
pages/ # Hype page routes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
|
||||||
|
|
||||||
- Apps get `PORT` env var from host
|
### Server (`src/server/`)
|
||||||
- Each app is isolated process with own dependencies
|
|
||||||
- No path-based routing - apps run on separate ports
|
|
||||||
- `DATA_DIR` env controls where apps are discovered
|
|
||||||
- Path aliases: `$` → server, `@` → shared, `%` → lib
|
|
||||||
|
|
||||||
## Current State
|
- `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
|
||||||
|
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create), `DELETE /:name`, `PUT /:name/rename`, `PUT /:name/icon`.
|
||||||
|
- `api/sync.ts` -- File sync protocol: manifest comparison, push/pull with hash-based diffing.
|
||||||
|
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps.
|
||||||
|
- `shell.tsx` -- Minimal HTML shell for the SPA.
|
||||||
|
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
|
||||||
|
|
||||||
### Infrastructure (Complete)
|
### Client (`src/client/`)
|
||||||
- App discovery, spawn, watch, auto-restart with exponential backoff
|
|
||||||
- Health checks every 30s (3 failures trigger restart)
|
|
||||||
- Port pool (3001-3100), sticky allocation per app
|
|
||||||
- SSE streams for real-time app state and log updates
|
|
||||||
- File sync protocol with hash-based manifests
|
|
||||||
|
|
||||||
### CLI
|
Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx` files directly.
|
||||||
- Full management: `toes list|start|stop|restart|info|new|rename|delete|open`
|
|
||||||
- File sync: `toes push|pull|sync`
|
|
||||||
- Logs: `toes logs [-f] <app>`
|
|
||||||
|
|
||||||
Check `TODO.txt` for planned features
|
- `index.tsx` -- Entry point. Initializes rendering, SSE connection, theme, tool iframes.
|
||||||
|
- `state.ts` -- Mutable module-level state (`apps`, `selectedApp`, `sidebarCollapsed`, etc.) with localStorage persistence. Components import state directly.
|
||||||
|
- `api.ts` -- Fetch wrappers for server API calls.
|
||||||
|
- `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication).
|
||||||
|
- `update.tsx` -- SSE connection to `/api/apps/stream` for real-time state updates.
|
||||||
|
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection.
|
||||||
|
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
|
||||||
|
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout).
|
||||||
|
- `themes/` -- Light/dark theme token definitions.
|
||||||
|
|
||||||
|
### CLI (`src/cli/`)
|
||||||
|
|
||||||
|
- `index.ts` -- Entry point (`#!/usr/bin/env bun`).
|
||||||
|
- `setup.ts` -- Commander program definition with all commands.
|
||||||
|
- `commands/` -- Command implementations.
|
||||||
|
- `http.ts` -- HTTP client for talking to the toes server.
|
||||||
|
- `name.ts` -- App name resolution (argument or current directory).
|
||||||
|
- `prompts.ts` -- Interactive prompts.
|
||||||
|
- `pager.ts` -- Pipe output through system pager.
|
||||||
|
|
||||||
|
CLI commands:
|
||||||
|
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
|
||||||
|
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`
|
||||||
|
- **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash`
|
||||||
|
- **Config**: `config`, `env`, `versions`, `history`, `rollback`
|
||||||
|
|
||||||
|
### Shared (`src/shared/`)
|
||||||
|
|
||||||
|
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
|
||||||
|
|
||||||
|
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`
|
||||||
|
- `gitignore.ts` -- `.toesignore` pattern matching
|
||||||
|
|
||||||
|
### Lib (`src/lib/`)
|
||||||
|
|
||||||
|
Server-side code shared between CLI and server. Can use Node APIs.
|
||||||
|
|
||||||
|
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
|
||||||
|
- `sync.ts` -- Manifest generation, hash computation
|
||||||
|
|
||||||
|
### Tools Package (`src/tools/`)
|
||||||
|
|
||||||
|
The `@because/toes` package that apps/tools import. Published exports:
|
||||||
|
|
||||||
|
- `@because/toes` -- re-exports from server (`src/index.ts` -> `src/server/sync.ts`)
|
||||||
|
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`
|
||||||
|
|
||||||
|
### Pages (`src/pages/`)
|
||||||
|
|
||||||
|
Hype page routes. `index.tsx` renders the Shell.
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### App Lifecycle
|
||||||
|
|
||||||
|
States: `invalid` -> `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
|
||||||
|
|
||||||
|
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
|
||||||
|
- Spawn: `Bun.spawn()` with `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, plus per-app env vars
|
||||||
|
- Health checks: every 30s to `/ok`, 3 consecutive failures trigger restart
|
||||||
|
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), resets after 60s stable run
|
||||||
|
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
|
||||||
|
|
||||||
|
### Tools vs Apps
|
||||||
|
|
||||||
|
Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server: `/tool/:tool?app=foo` -> `http://host:toolPort/?app=foo`.
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
Apps live at `APPS_DIR/<name>/` with timestamped version directories and a `current` symlink. Push creates a new version; rollback moves the symlink.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Per-app env files in `TOES_DIR/env/`:
|
||||||
|
- `_global.env` -- shared by all apps
|
||||||
|
- `<appname>.env` -- per-app overrides
|
||||||
|
|
||||||
|
The server sets these on each app process: `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, `DATA_DIR`.
|
||||||
|
|
||||||
|
### SSE Streaming
|
||||||
|
|
||||||
|
Two SSE endpoints serve different consumers:
|
||||||
|
|
||||||
|
- `/api/apps/stream` -- Full app state snapshots on every change. Used by the dashboard UI. Driven by `onChange()` in `apps.ts`.
|
||||||
|
- `/api/events/stream` -- Discrete lifecycle events (`app:start`, `app:stop`, `app:activate`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`.
|
||||||
|
|
||||||
## Coding Guidelines
|
## Coding Guidelines
|
||||||
|
|
||||||
|
|
@ -165,3 +212,7 @@ function start(app: App): void {
|
||||||
console.log(`Starting ${app.config.name}`)
|
console.log(`Starting ${app.config.name}`)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Writing Apps and Tools
|
||||||
|
|
||||||
|
See `docs/CLAUDE.md` for the guide to writing toes apps and tools.
|
||||||
|
|
|
||||||
194
ISSUES.md
194
ISSUES.md
|
|
@ -1,194 +0,0 @@
|
||||||
# Issues - Versioned Deployment Implementation
|
|
||||||
|
|
||||||
## Critical Issues
|
|
||||||
|
|
||||||
### 1. Watch Filter Breaks File Change Detection
|
|
||||||
|
|
||||||
**Location**: `src/server/apps.ts:589-593`
|
|
||||||
|
|
||||||
**Problem**: The watch logic ignores all changes deeper than 2 levels:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Ignore changes deeper than 2 levels (inside timestamp dirs)
|
|
||||||
if (parts.length > 2) return
|
|
||||||
|
|
||||||
// For versioned apps, only care about changes to "current" directory
|
|
||||||
if (parts.length === 2 && parts[1] !== 'current' && parts[1] !== 'package.json') return
|
|
||||||
```
|
|
||||||
|
|
||||||
Files inside `current/` are 3 levels deep: `appname/current/somefile.ts`
|
|
||||||
|
|
||||||
This **ignores all file changes** inside the current directory, breaking hot reload and auto-restart.
|
|
||||||
|
|
||||||
**Fix**:
|
|
||||||
```ts
|
|
||||||
// Ignore changes inside old timestamp dirs (but allow current/)
|
|
||||||
if (parts.length > 2 && parts[1] !== 'current') return
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. App Restart Race Condition
|
|
||||||
|
|
||||||
**Location**: `src/server/api/sync.ts:145-148`
|
|
||||||
|
|
||||||
**Problem**: Activation uses arbitrary 1 second delay without confirming stop completed:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Restart app to use new version
|
|
||||||
const app = allApps().find(a => a.name === appName)
|
|
||||||
if (app?.state === 'running') {
|
|
||||||
stopApp(appName)
|
|
||||||
setTimeout(() => startApp(appName), 1000) // ⚠️ Arbitrary 1s delay
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Issues**:
|
|
||||||
- 1 second may not be enough for app to fully stop
|
|
||||||
- No confirmation that stop completed before start
|
|
||||||
- If app crashes during startup, activation still returns success
|
|
||||||
|
|
||||||
**Fix**: Make activation async and wait for stop to complete, or move restart logic to after symlink succeeds and poll for stop completion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Major Issues
|
|
||||||
|
|
||||||
### 3. No Version Cleanup
|
|
||||||
|
|
||||||
**Problem**: Old timestamp directories accumulate forever with no cleanup mechanism.
|
|
||||||
|
|
||||||
**Impact**: Disk space grows indefinitely as deployments pile up.
|
|
||||||
|
|
||||||
**Recommendation**: Add cleanup logic:
|
|
||||||
- Keep last N versions (e.g., 5-10)
|
|
||||||
- Delete versions older than X days
|
|
||||||
- Expose as `toes clean <app>` command or automatic post-activation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. safePath Behavior Change
|
|
||||||
|
|
||||||
**Location**: `src/server/api/sync.ts:14-21`
|
|
||||||
|
|
||||||
**Problem**: Security model changed by resolving symlinks in base path:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const canonicalBase = existsSync(base) ? realpathSync(base) : base
|
|
||||||
```
|
|
||||||
|
|
||||||
Previously paths were checked against literal base, now against resolved base. This changes behavior if someone creates a symlink attack.
|
|
||||||
|
|
||||||
**Recommendation**: Document this intentional change, or keep original check and add separate symlink resolution only where needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Non-Atomic Deploy Copy
|
|
||||||
|
|
||||||
**Location**: `src/server/api/sync.ts:133-141`
|
|
||||||
|
|
||||||
**Problem**: Race condition possible during deploy:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (existsSync(currentLink)) {
|
|
||||||
const currentReal = realpathSync(currentLink)
|
|
||||||
cpSync(currentReal, newVersion, { recursive: true })
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If `current` changes between `existsSync` and `cpSync`, stale code might be copied.
|
|
||||||
|
|
||||||
**Impact**: Low probability for single-user system, but possible during concurrent deploys.
|
|
||||||
|
|
||||||
**Fix**: Read symlink once and reuse: `const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Upload Error Handling Inconsistency
|
|
||||||
|
|
||||||
**Location**: `src/cli/commands/sync.ts:113-115`
|
|
||||||
|
|
||||||
**Problem**: Upload continues if a file fails but doesn't track failures:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (success) {
|
|
||||||
console.log(` ${color.green('↑')} ${file}`)
|
|
||||||
} else {
|
|
||||||
console.log(` ${color.red('✗')} ${file}`)
|
|
||||||
// Continue even if one file fails
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If any file fails to upload, deployment still activates with incomplete files.
|
|
||||||
|
|
||||||
**Fix**: Either:
|
|
||||||
- Abort entire deployment on first failure
|
|
||||||
- Track failures and warn/abort before activating
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Minor Issues
|
|
||||||
|
|
||||||
### 7. POST Body Check Too Loose
|
|
||||||
|
|
||||||
**Location**: `src/cli/http.ts:42-44`
|
|
||||||
|
|
||||||
**Problem**: Falsy values like `0`, `false`, or `""` would incorrectly skip body:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fix**:
|
|
||||||
```ts
|
|
||||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Unconventional Timestamp Format
|
|
||||||
|
|
||||||
**Location**: `src/server/api/sync.ts:115-123`
|
|
||||||
|
|
||||||
**Problem**: Unusual array construction with separator as element:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const timestamp = [
|
|
||||||
now.getFullYear(),
|
|
||||||
String(now.getMonth() + 1).padStart(2, '0'),
|
|
||||||
String(now.getDate()).padStart(2, '0'),
|
|
||||||
'-', // Unusual to put separator as array element
|
|
||||||
String(now.getHours()).padStart(2, '0'),
|
|
||||||
String(now.getMinutes()).padStart(2, '0'),
|
|
||||||
String(now.getSeconds()).padStart(2, '0'),
|
|
||||||
].join('')
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation**: Use template literal:
|
|
||||||
```ts
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
const timestamp = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
||||||
```
|
|
||||||
|
|
||||||
Or ISO format: `now.toISOString().replace(/[:.]/g, '-').slice(0, -5)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Positive Points
|
|
||||||
|
|
||||||
✓ Atomic symlink swapping with temp file pattern is correct
|
|
||||||
✓ Clear 3-step deployment protocol (deploy, upload, activate)
|
|
||||||
✓ Proper use of `realpathSync` to resolve canonical paths for running apps
|
|
||||||
✓ Good separation of concerns in API routes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority
|
|
||||||
|
|
||||||
1. **BLOCKER**: Fix watch filter (#1) - breaks hot reload
|
|
||||||
2. **HIGH**: Fix restart race condition (#2) - affects deployment reliability
|
|
||||||
3. **HIGH**: Add version cleanup (#3) - disk space concern
|
|
||||||
4. **MEDIUM**: Fix upload error handling (#6) - data integrity
|
|
||||||
5. **LOW**: Everything else
|
|
||||||
33
README.md
33
README.md
|
|
@ -4,11 +4,28 @@ Toes is a personal web server you run in your home.
|
||||||
|
|
||||||
Plug it in, turn it on, and forget about the cloud.
|
Plug it in, turn it on, and forget about the cloud.
|
||||||
|
|
||||||
## quickstart
|
## setup
|
||||||
|
|
||||||
1. Plug in and turn on your Toes computer.
|
Toes runs on a Raspberry Pi. You'll need:
|
||||||
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
|
|
||||||
3. Visit https://toes.local to get started!
|
- A Raspberry Pi running Raspberry Pi OS
|
||||||
|
- A `toes` user with passwordless sudo
|
||||||
|
|
||||||
|
SSH into your Pi as the `toes` user and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://toes.dev/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
|
||||||
|
1. Install system dependencies (git, fish shell, networking tools)
|
||||||
|
2. Install Bun and grant it network binding capabilities
|
||||||
|
3. Clone and build the toes server
|
||||||
|
4. Set up bundled apps (clock, code, cron, env, stats, versions)
|
||||||
|
5. Install and enable a systemd service for auto-start
|
||||||
|
|
||||||
|
Once complete, visit `http://<hostname>.local` on your local network.
|
||||||
|
|
||||||
## features
|
## features
|
||||||
- Hosts bun/hono/hype webapps - both SSR and SPA.
|
- Hosts bun/hono/hype webapps - both SSR and SPA.
|
||||||
|
|
@ -23,9 +40,8 @@ by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in p
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
toes config # show current host
|
toes config # show current host
|
||||||
TOES_URL=http://192.168.1.50:3000 toes list # full URL
|
TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
|
||||||
TOES_HOST=mypi.local toes list # hostname (port 80)
|
TOES_URL=http://mypi.local toes list # connect to hostname
|
||||||
TOES_HOST=mypi.local PORT=3000 toes list # hostname + port
|
|
||||||
```
|
```
|
||||||
|
|
||||||
set `NODE_ENV=production` to default to `toes.local:80`.
|
set `NODE_ENV=production` to default to `toes.local:80`.
|
||||||
|
|
@ -34,8 +50,7 @@ set `NODE_ENV=production` to default to `toes.local:80`.
|
||||||
|
|
||||||
- textOS (TODO, more?)
|
- textOS (TODO, more?)
|
||||||
- Claude that knows about all your toes APIS and your projects.
|
- Claude that knows about all your toes APIS and your projects.
|
||||||
- HTTPS Tunnel for sharing your apps with the world.
|
- non-webapps
|
||||||
- Charts and graphs in the webUI.
|
|
||||||
|
|
||||||
## february goal
|
## february goal
|
||||||
|
|
||||||
|
|
|
||||||
51
TODO.txt
51
TODO.txt
|
|
@ -1,51 +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
|
|
||||||
[ ] todo.txt
|
|
||||||
[ ] ...
|
|
||||||
|
|
@ -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,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 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
3
apps/clock/20260130-000000/TODO.txt
Normal file
3
apps/clock/20260130-000000/TODO.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# clock TODO
|
||||||
|
[x] do something
|
||||||
|
[ ] do more things
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
|
|
@ -5,15 +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": "^0.0.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -22,7 +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.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=="],
|
||||||
|
|
||||||
|
|
@ -30,6 +33,10 @@
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"hono": ["hono@4.11.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=="],
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,12 @@
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/hype": "*",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/forge": "*",
|
"@because/howl": "^0.0.2",
|
||||||
"@because/howl": "*"
|
"@because/hype": "^0.0.2",
|
||||||
|
"@because/toes": "^0.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,42 @@
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
import { readdir, stat } from 'fs/promises'
|
import { readdir, stat } from 'fs/promises'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { join, extname, basename } from 'path'
|
import { join, extname, basename } from 'path'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
// Styles
|
const Container = define('Container', {
|
||||||
const Container = define('CodeBrowserContainer', {
|
fontFamily: theme('fonts-sans'),
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
maxWidth: '1200px',
|
maxWidth: '1200px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
})
|
color: theme('colors-text'),
|
||||||
|
|
||||||
const Header = define('Header', {
|
|
||||||
marginBottom: '20px',
|
|
||||||
paddingBottom: '10px',
|
|
||||||
borderBottom: '2px solid #333',
|
|
||||||
})
|
|
||||||
|
|
||||||
const Title = define('Title', {
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
})
|
|
||||||
|
|
||||||
const AppName = define('AppName', {
|
|
||||||
color: '#666',
|
|
||||||
fontSize: '18px',
|
|
||||||
marginTop: '5px',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const FileList = define('FileList', {
|
const FileList = define('FileList', {
|
||||||
listStyle: 'none',
|
listStyle: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
border: '1px solid #ddd',
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
borderRadius: '4px',
|
borderRadius: theme('radius-md'),
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
})
|
})
|
||||||
|
|
||||||
const FileItem = define('FileItem', {
|
const FileItem = define('FileItem', {
|
||||||
padding: '10px 15px',
|
padding: '10px 15px',
|
||||||
borderBottom: '1px solid #eee',
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
states: {
|
states: {
|
||||||
':last-child': {
|
':last-child': {
|
||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
},
|
},
|
||||||
':hover': {
|
':hover': {
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: theme('colors-bgHover'),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -59,7 +44,7 @@ const FileItem = define('FileItem', {
|
||||||
const FileLink = define('FileLink', {
|
const FileLink = define('FileLink', {
|
||||||
base: 'a',
|
base: 'a',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: '#0066cc',
|
color: theme('colors-link'),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
|
|
@ -70,49 +55,65 @@ const FileLink = define('FileLink', {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FileIcon = define('FileIcon', {
|
||||||
|
base: 'svg',
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
flexShrink: 0,
|
||||||
|
fill: theme('colors-textMuted'),
|
||||||
|
})
|
||||||
|
|
||||||
const CodeBlock = define('CodeBlock', {
|
const CodeBlock = define('CodeBlock', {
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
border: '1px solid #ddd',
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
borderRadius: '4px',
|
borderRadius: theme('radius-md'),
|
||||||
overflow: 'auto',
|
overflowX: 'auto',
|
||||||
selectors: {
|
selectors: {
|
||||||
'& pre': {
|
'& pre': {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: '15px',
|
padding: '15px',
|
||||||
overflow: 'auto',
|
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre',
|
||||||
|
backgroundColor: theme('colors-bgSubtle'),
|
||||||
},
|
},
|
||||||
'& pre code': {
|
'& pre code': {
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre',
|
||||||
fontFamily: 'monospace',
|
fontFamily: theme('fonts-mono'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const CodeHeader = define('CodeHeader', {
|
const CodeHeader = define('CodeHeader', {
|
||||||
padding: '10px 15px',
|
padding: '10px 15px',
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: theme('colors-bgElement'),
|
||||||
borderBottom: '1px solid #ddd',
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
})
|
})
|
||||||
|
|
||||||
const Error = define('Error', {
|
const ErrorBox = define('ErrorBox', {
|
||||||
color: '#d32f2f',
|
color: theme('colors-error'),
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
backgroundColor: '#ffebee',
|
backgroundColor: theme('colors-bgElement'),
|
||||||
borderRadius: '4px',
|
borderRadius: theme('radius-md'),
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
})
|
})
|
||||||
|
|
||||||
const BackLink = define('BackLink', {
|
const Breadcrumb = define('Breadcrumb', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '15px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
})
|
||||||
|
|
||||||
|
const BreadcrumbLink = define('BreadcrumbLink', {
|
||||||
base: 'a',
|
base: 'a',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: '#0066cc',
|
color: theme('colors-link'),
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '5px',
|
|
||||||
marginBottom: '15px',
|
|
||||||
states: {
|
states: {
|
||||||
':hover': {
|
':hover': {
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
|
|
@ -120,25 +121,258 @@ const BackLink = define('BackLink', {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(stylesToCSS(), 200, {
|
const BreadcrumbSeparator = define('BreadcrumbSeparator', {
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const BreadcrumbCurrent = define('BreadcrumbCurrent', {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
fontWeight: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
const MediaContainer = define('MediaContainer', {
|
||||||
|
margin: '20px 0',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: theme('colors-bgSubtle'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const MediaHeader = define('MediaHeader', {
|
||||||
|
padding: '10px 15px',
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
const MediaContent = define('MediaContent', {
|
||||||
|
padding: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ImagePreview = define('ImagePreview', {
|
||||||
|
base: 'img',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '600px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
})
|
||||||
|
|
||||||
|
const AudioPlayer = define('AudioPlayer', {
|
||||||
|
base: 'audio',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '500px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const VideoPlayer = define('VideoPlayer', {
|
||||||
|
base: 'video',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '600px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const DownloadButton = define('DownloadButton', {
|
||||||
|
base: 'a',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: theme('colors-primary'),
|
||||||
|
color: 'white',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
fontSize: '14px',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = () => (
|
||||||
|
<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" />
|
||||||
|
</FileIcon>
|
||||||
|
)
|
||||||
|
|
||||||
|
const FileIconSvg = () => (
|
||||||
|
<FileIcon viewBox="0 0 24 24">
|
||||||
|
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z" />
|
||||||
|
</FileIcon>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
title: string
|
||||||
|
children: Child
|
||||||
|
highlight?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileMemoryScript = `
|
||||||
|
(function() {
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
var app = params.get('app');
|
||||||
|
var file = params.get('file');
|
||||||
|
var version = params.get('version') || 'current';
|
||||||
|
if (!app) return;
|
||||||
|
var key = 'code-app:' + app + ':' + version + ':file';
|
||||||
|
if (params.has('file')) {
|
||||||
|
// Explicit file param (even empty) - save it
|
||||||
|
if (file) localStorage.setItem(key, file);
|
||||||
|
else localStorage.removeItem(key);
|
||||||
|
} else {
|
||||||
|
// No file param - restore saved location
|
||||||
|
var saved = localStorage.getItem(key);
|
||||||
|
if (saved) {
|
||||||
|
var url = '/?app=' + encodeURIComponent(app);
|
||||||
|
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
|
||||||
|
url += '&file=' + encodeURIComponent(saved);
|
||||||
|
window.location.replace(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
function Layout({ title, children, highlight, editable }: 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" />
|
||||||
|
{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-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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
<body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: fileMemoryScript }} />
|
||||||
|
<ToolScript />
|
||||||
|
<Container>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
{highlight && !editable && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
|
||||||
|
</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',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
app.get('/raw', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
const version = c.req.query('version') || 'current'
|
||||||
|
const filePath = c.req.query('file')
|
||||||
|
|
||||||
|
if (!appName || !filePath) {
|
||||||
|
return c.text('Missing app or file parameter', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = join(APPS_DIR, appName, version, filePath)
|
||||||
|
const file = Bun.file(fullPath)
|
||||||
|
|
||||||
|
if (!await file.exists()) {
|
||||||
|
return c.text(`File not found: ${fullPath}`, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/save', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
const version = c.req.query('version') || 'current'
|
||||||
|
const filePath = c.req.query('file')
|
||||||
|
|
||||||
|
if (!appName || !filePath) {
|
||||||
|
return c.text('Missing app or file parameter', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = join(APPS_DIR, appName, version, filePath)
|
||||||
|
const content = await c.req.text()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Bun.write(fullPath, content)
|
||||||
|
return c.text('OK')
|
||||||
|
} catch (err) {
|
||||||
|
return c.text(`Failed to save: ${err}`, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function listFiles(appPath: string, subPath: string = '') {
|
async function listFiles(appPath: string, subPath: string = '') {
|
||||||
const fullPath = join(appPath, subPath)
|
const fullPath = join(appPath, subPath)
|
||||||
const entries = await readdir(fullPath, { withFileTypes: true })
|
const entries = await readdir(fullPath, { withFileTypes: true })
|
||||||
|
|
||||||
const items = await Promise.all(
|
const items = entries.map(entry => ({
|
||||||
entries.map(async entry => {
|
|
||||||
const itemPath = join(fullPath, entry.name)
|
|
||||||
const stats = await stat(itemPath)
|
|
||||||
return {
|
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
isDirectory: entry.isDirectory(),
|
isDirectory: entry.isDirectory(),
|
||||||
path: subPath ? `${subPath}/${entry.name}` : entry.name,
|
path: subPath ? `${subPath}/${entry.name}` : entry.name,
|
||||||
}
|
}))
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return items.sort((a, b) => {
|
return items.sort((a, b) => {
|
||||||
if (a.isDirectory !== b.isDirectory) {
|
if (a.isDirectory !== b.isDirectory) {
|
||||||
|
|
@ -148,13 +382,57 @@ async function listFiles(appPath: string, subPath: string = '') {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
interface BreadcrumbProps {
|
||||||
return text
|
appName: string
|
||||||
.replace(/&/g, '&')
|
filePath: string
|
||||||
.replace(/</g, '<')
|
versionParam: string
|
||||||
.replace(/>/g, '>')
|
}
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
|
||||||
|
const parts = filePath ? filePath.split('/').filter(Boolean) : []
|
||||||
|
const crumbs: { name: string; path: string }[] = []
|
||||||
|
|
||||||
|
let currentPath = ''
|
||||||
|
for (const part of parts) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${part}` : part
|
||||||
|
crumbs.push({ name: part, path: currentPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
{crumbs.length > 0 ? (
|
||||||
|
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=`}>{appName}</BreadcrumbLink>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
|
||||||
|
)}
|
||||||
|
{crumbs.map((crumb, i) => (
|
||||||
|
<>
|
||||||
|
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||||
|
{i === crumbs.length - 1 ? (
|
||||||
|
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Breadcrumb>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp'])
|
||||||
|
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'])
|
||||||
|
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi'])
|
||||||
|
const BINARY_EXTS = new Set(['.pdf', '.zip', '.tar', '.gz', '.exe', '.dmg', '.woff', '.woff2', '.ttf', '.otf', '.eot'])
|
||||||
|
|
||||||
|
type FileType = 'text' | 'image' | 'audio' | 'video' | 'binary'
|
||||||
|
|
||||||
|
function getFileType(filename: string): FileType {
|
||||||
|
const ext = extname(filename).toLowerCase()
|
||||||
|
if (IMAGE_EXTS.has(ext)) return 'image'
|
||||||
|
if (AUDIO_EXTS.has(ext)) return 'audio'
|
||||||
|
if (VIDEO_EXTS.has(ext)) return 'video'
|
||||||
|
if (BINARY_EXTS.has(ext)) return 'binary'
|
||||||
|
return 'text'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLanguage(filename: string): string {
|
function getLanguage(filename: string): string {
|
||||||
|
|
@ -175,152 +453,241 @@ 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) {
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title="Code Browser">
|
||||||
<head>
|
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
||||||
<meta charset="UTF-8" />
|
</Layout>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Code Browser</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
</Header>
|
|
||||||
<Error>Please specify an app name with ?app=<name></Error>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const appPath = join(APPS_DIR, appName)
|
const appPath = join(APPS_DIR, appName, version)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stat(appPath)
|
await stat(appPath)
|
||||||
} catch {
|
} catch {
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title="Code Browser">
|
||||||
<head>
|
<ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
|
||||||
<meta charset="UTF-8" />
|
</Layout>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Code Browser</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
</Header>
|
|
||||||
<Error>App "{appName}" not found</Error>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = join(appPath, filePath)
|
const fullPath = join(appPath, filePath)
|
||||||
let stats
|
let fileStats
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stats = await stat(fullPath)
|
fileStats = await stat(fullPath)
|
||||||
} catch {
|
} catch {
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title="Code Browser">
|
||||||
<head>
|
<ErrorBox>Path "{filePath}" not found</ErrorBox>
|
||||||
<meta charset="UTF-8" />
|
</Layout>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Code Browser</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
</Header>
|
|
||||||
<Error>Path "{filePath}" not found in app "{appName}"</Error>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.isFile()) {
|
const versionParam = version !== 'current' ? `&version=${version}` : ''
|
||||||
|
|
||||||
|
if (fileStats.isFile()) {
|
||||||
|
const filename = basename(fullPath)
|
||||||
|
const fileType = getFileType(filename)
|
||||||
|
const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
|
||||||
|
const downloadUrl = `${rawUrl}&download=1`
|
||||||
|
|
||||||
|
if (fileType === 'image') {
|
||||||
|
return c.html(
|
||||||
|
<Layout title={`${appName}/${filePath}`}>
|
||||||
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||||
|
<MediaContainer>
|
||||||
|
<MediaHeader>
|
||||||
|
<span>{filename}</span>
|
||||||
|
<DownloadButton href={downloadUrl}>Download</DownloadButton>
|
||||||
|
</MediaHeader>
|
||||||
|
<MediaContent>
|
||||||
|
<ImagePreview src={rawUrl} alt={filename} />
|
||||||
|
</MediaContent>
|
||||||
|
</MediaContainer>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType === 'audio') {
|
||||||
|
return c.html(
|
||||||
|
<Layout title={`${appName}/${filePath}`}>
|
||||||
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||||
|
<MediaContainer>
|
||||||
|
<MediaHeader>
|
||||||
|
<span>{filename}</span>
|
||||||
|
<DownloadButton href={downloadUrl}>Download</DownloadButton>
|
||||||
|
</MediaHeader>
|
||||||
|
<MediaContent>
|
||||||
|
<AudioPlayer controls src={rawUrl} />
|
||||||
|
</MediaContent>
|
||||||
|
</MediaContainer>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType === 'video') {
|
||||||
|
return c.html(
|
||||||
|
<Layout title={`${appName}/${filePath}`}>
|
||||||
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||||
|
<MediaContainer>
|
||||||
|
<MediaHeader>
|
||||||
|
<span>{filename}</span>
|
||||||
|
<DownloadButton href={downloadUrl}>Download</DownloadButton>
|
||||||
|
</MediaHeader>
|
||||||
|
<MediaContent>
|
||||||
|
<VideoPlayer controls src={rawUrl} />
|
||||||
|
</MediaContent>
|
||||||
|
</MediaContainer>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType === 'binary') {
|
||||||
|
return c.redirect(downloadUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text file - show with syntax highlighting
|
||||||
const content = readFileSync(fullPath, 'utf-8')
|
const content = readFileSync(fullPath, 'utf-8')
|
||||||
const language = getLanguage(basename(fullPath))
|
const language = getLanguage(filename)
|
||||||
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
const prismLang = getPrismLanguage(filename)
|
||||||
|
const edit = c.req.query('edit') === '1'
|
||||||
|
|
||||||
|
if (edit) {
|
||||||
|
const editorScript = `
|
||||||
|
import { CodeJar } from 'https://cdn.jsdelivr.net/npm/codejar@4.2.0/dist/codejar.js';
|
||||||
|
|
||||||
|
const editor = document.getElementById('editor');
|
||||||
|
const saveBtn = document.getElementById('save-btn');
|
||||||
|
const status = document.getElementById('save-status');
|
||||||
|
let dirty = false;
|
||||||
|
|
||||||
|
const highlight = (el) => {
|
||||||
|
Prism.highlightElement(el);
|
||||||
|
};
|
||||||
|
|
||||||
|
const jar = CodeJar(editor, highlight, { tab: ' ', addClosing: false });
|
||||||
|
|
||||||
|
// Initial highlight
|
||||||
|
highlight(editor);
|
||||||
|
|
||||||
|
jar.onUpdate(() => {
|
||||||
|
if (!dirty) {
|
||||||
|
dirty = true;
|
||||||
|
saveBtn.textContent = 'Save *';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.onclick = async () => {
|
||||||
|
if (!dirty) return;
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
status.textContent = 'Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
body: jar.toString()
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Save failed');
|
||||||
|
dirty = false;
|
||||||
|
saveBtn.textContent = 'Save';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
status.textContent = 'Saved!';
|
||||||
|
setTimeout(() => { status.textContent = ''; }, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
status.textContent = 'Error: ' + err.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveBtn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`
|
||||||
|
return c.html(
|
||||||
|
<Layout title={`${appName}/${filePath}`} editable>
|
||||||
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||||
|
<CodeBlock>
|
||||||
|
<CodeHeader>
|
||||||
|
<span>{filename}</span>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span>
|
||||||
|
<EditButton id="save-btn">Save</EditButton>
|
||||||
|
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}`}>Done</EditLink>
|
||||||
|
</div>
|
||||||
|
</CodeHeader>
|
||||||
|
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
|
||||||
|
</CodeBlock>
|
||||||
|
<script type="module" dangerouslySetInnerHTML={{ __html: editorScript }} />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title={`${appName}/${filePath}`} highlight>
|
||||||
<head>
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>{`${appName}/${filePath}`}</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
<AppName>{appName}/{filePath}</AppName>
|
|
||||||
</Header>
|
|
||||||
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
|
|
||||||
⬅️ Back
|
|
||||||
</BackLink>
|
|
||||||
<CodeBlock>
|
<CodeBlock>
|
||||||
<CodeHeader>{basename(fullPath)}</CodeHeader>
|
<CodeHeader>
|
||||||
|
<span>{filename}</span>
|
||||||
|
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
|
||||||
|
</CodeHeader>
|
||||||
<pre><code class={`language-${language}`}>{content}</code></pre>
|
<pre><code class={`language-${language}`}>{content}</code></pre>
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
</Container>
|
</Layout>
|
||||||
<script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await listFiles(appPath, filePath)
|
const files = await listFiles(appPath, filePath)
|
||||||
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
|
||||||
<head>
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>{`${appName}${filePath ? `/${filePath}` : ''}`}</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
<AppName>{appName}{filePath ? `/${filePath}` : ''}</AppName>
|
|
||||||
</Header>
|
|
||||||
{filePath && (
|
|
||||||
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
|
|
||||||
⬅️ Back
|
|
||||||
</BackLink>
|
|
||||||
)}
|
|
||||||
<FileList>
|
<FileList>
|
||||||
{files.map(file => (
|
{files.map(file => (
|
||||||
<FileItem>
|
<FileItem>
|
||||||
<FileLink href={`/?app=${appName}&file=${file.path}`}>
|
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
|
||||||
{file.isDirectory ? '📁' : '📄'}
|
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
|
||||||
<span>{file.name}</span>
|
<span>{file.name}</span>
|
||||||
</FileLink>
|
</FileLink>
|
||||||
</FileItem>
|
</FileItem>
|
||||||
))}
|
))}
|
||||||
</FileList>
|
</FileList>
|
||||||
</Container>
|
</Layout>
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
20260130-000000
|
|
||||||
1
apps/cron/20260201-000000/.npmrc
Normal file
1
apps/cron/20260201-000000/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
49
apps/cron/20260201-000000/bun.lock
Normal file
49
apps/cron/20260201-000000/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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
701
apps/cron/20260201-000000/index.tsx
Normal file
701
apps/cron/20260201-000000/index.tsx
Normal file
|
|
@ -0,0 +1,701 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, on, ToolScript, theme } from '@because/toes/tools'
|
||||||
|
import { discoverCronJobs } from './lib/discovery'
|
||||||
|
import { scheduleJob, stopJob } from './lib/scheduler'
|
||||||
|
import { executeJob } from './lib/executor'
|
||||||
|
import { setJobs, setInvalidJobs, getJob, getAllJobs, getInvalidJobs, broadcast } from './lib/state'
|
||||||
|
import { SCHEDULES, type CronJob, type InvalidJob } from './lib/schedules'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { mkdir, writeFile } from 'fs/promises'
|
||||||
|
import { existsSync, watch } from 'fs'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
// Styles (follow versions tool pattern)
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '900px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const JobList = define('JobList', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: '20px 0',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
const JobItem = define('JobItem', {
|
||||||
|
padding: '12px 15px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '15px',
|
||||||
|
states: {
|
||||||
|
':last-child': { borderBottom: 'none' },
|
||||||
|
':hover': { backgroundColor: theme('colors-bgHover') },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StatusDot = define('StatusDot', {
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const JobName = define('JobName', {
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '14px',
|
||||||
|
flex: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const Schedule = define('Schedule', {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
minWidth: '80px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Time = define('Time', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
minWidth: '100px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const RunButton = define('RunButton', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '4px 10px',
|
||||||
|
marginTop: '10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: theme('colors-primary'),
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': { opacity: 0.9 },
|
||||||
|
':disabled': { opacity: 0.5, cursor: 'not-allowed' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptyState = define('EmptyState', {
|
||||||
|
padding: '40px 20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const InvalidItem = define('InvalidItem', {
|
||||||
|
padding: '12px 15px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '15px',
|
||||||
|
opacity: 0.7,
|
||||||
|
states: {
|
||||||
|
':last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ErrorText = define('ErrorText', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: theme('colors-error'),
|
||||||
|
flex: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ActionRow = define('ActionRow', {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginBottom: '15px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const NewButton = define('NewButton', {
|
||||||
|
base: 'a',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
backgroundColor: theme('colors-primary'),
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'inline-block',
|
||||||
|
states: {
|
||||||
|
':hover': { opacity: 0.9 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttonStyles = {
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
backgroundColor: theme('colors-primary'),
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Form = define('Form', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '16px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const FormGroup = define('FormGroup', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Label = define('Label', {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputStyles = {
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
backgroundColor: theme('colors-bg'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonRow = define('ButtonRow', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
marginTop: '10px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const CancelButton = define('CancelButton', {
|
||||||
|
base: 'a',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
states: {
|
||||||
|
':hover': { backgroundColor: theme('colors-bgHover') },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const BackLink = define('BackLink', {
|
||||||
|
base: 'a',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
textDecoration: 'none',
|
||||||
|
states: {
|
||||||
|
':hover': { color: theme('colors-text') },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DetailHeader = define('DetailHeader', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const DetailTitle = define('DetailTitle', {
|
||||||
|
base: 'h1',
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
flex: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DetailMeta = define('DetailMeta', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const MetaItem = define('MetaItem', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const MetaLabel = define('MetaLabel', {
|
||||||
|
fontWeight: 500,
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const OutputSection = define('OutputSection', {
|
||||||
|
marginTop: '20px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const OutputLabel = define('OutputLabel', {
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: '8px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const OutputBlock = define('OutputBlock', {
|
||||||
|
base: 'pre',
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'auto',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ErrorBlock = define('ErrorBlock', {
|
||||||
|
base: 'pre',
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-error')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
color: theme('colors-error'),
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'auto',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const StatusBadge = define('StatusBadge', {
|
||||||
|
base: 'span',
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
fontWeight: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
function Layout({ title, children, refresh }: { title: string; children: Child; refresh?: boolean }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
{refresh && <meta http-equiv="refresh" content="2" />}
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ToolScript />
|
||||||
|
<Container>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(ts?: number): string {
|
||||||
|
if (!ts) return '-'
|
||||||
|
const diff = Date.now() - ts
|
||||||
|
if (diff < 0) {
|
||||||
|
const mins = Math.round(-diff / 60000)
|
||||||
|
if (mins < 60) return `in ${mins}m`
|
||||||
|
const hours = Math.round(mins / 60)
|
||||||
|
if (hours < 24) return `in ${hours}h`
|
||||||
|
return `in ${Math.round(hours / 24)}d`
|
||||||
|
}
|
||||||
|
const mins = Math.round(diff / 60000)
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hours = Math.round(mins / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
return `${Math.round(hours / 24)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(job: CronJob): string {
|
||||||
|
if (job.state === 'running') return theme('colors-statusRunning')
|
||||||
|
if (job.state === 'disabled') return theme('colors-textMuted')
|
||||||
|
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return theme('colors-error')
|
||||||
|
return theme('colors-statusRunning')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// JSON API
|
||||||
|
app.get('/api/jobs', c => {
|
||||||
|
const appFilter = c.req.query('app')
|
||||||
|
let jobs = getAllJobs()
|
||||||
|
if (appFilter) jobs = jobs.filter(j => j.app === appFilter)
|
||||||
|
jobs.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
return c.json(jobs.map(j => ({
|
||||||
|
app: j.app,
|
||||||
|
name: j.name,
|
||||||
|
schedule: j.schedule,
|
||||||
|
state: j.state,
|
||||||
|
status: statusLabel(j),
|
||||||
|
lastRun: j.lastRun,
|
||||||
|
lastDuration: j.lastDuration,
|
||||||
|
lastExitCode: j.lastExitCode,
|
||||||
|
nextRun: j.nextRun,
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/jobs/:app/:name', c => {
|
||||||
|
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
||||||
|
const job = getJob(id)
|
||||||
|
if (!job) return c.json({ error: 'Job not found' }, 404)
|
||||||
|
return c.json({
|
||||||
|
app: job.app,
|
||||||
|
name: job.name,
|
||||||
|
schedule: job.schedule,
|
||||||
|
state: job.state,
|
||||||
|
status: statusLabel(job),
|
||||||
|
lastRun: job.lastRun,
|
||||||
|
lastDuration: job.lastDuration,
|
||||||
|
lastExitCode: job.lastExitCode,
|
||||||
|
lastError: job.lastError,
|
||||||
|
lastOutput: job.lastOutput,
|
||||||
|
nextRun: job.nextRun,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/jobs/:app/:name/run', async c => {
|
||||||
|
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
||||||
|
const job = getJob(id)
|
||||||
|
if (!job) return c.json({ error: 'Job not found' }, 404)
|
||||||
|
if (job.state === 'running') return c.json({ error: 'Job is already running' }, 409)
|
||||||
|
executeJob(job, broadcast)
|
||||||
|
return c.json({ ok: true, message: `Started ${id}` })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appFilter = c.req.query('app')
|
||||||
|
let jobs = getAllJobs()
|
||||||
|
let invalid = getInvalidJobs()
|
||||||
|
|
||||||
|
if (appFilter) {
|
||||||
|
jobs = jobs.filter(j => j.app === appFilter)
|
||||||
|
invalid = invalid.filter(j => j.app === appFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
invalid.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
const hasAny = jobs.length > 0 || invalid.length > 0
|
||||||
|
const anyRunning = jobs.some(j => j.state === 'running')
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Cron Jobs" refresh={anyRunning}>
|
||||||
|
<ActionRow>
|
||||||
|
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
|
||||||
|
</ActionRow>
|
||||||
|
{!hasAny ? (
|
||||||
|
<EmptyState>
|
||||||
|
No cron jobs found.
|
||||||
|
<br />
|
||||||
|
Create a cron/*.ts file in any app to get started.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<JobList>
|
||||||
|
{jobs.map(job => (
|
||||||
|
<JobItem>
|
||||||
|
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
||||||
|
<JobName>
|
||||||
|
<a href={`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||||
|
{job.app}/{job.name}
|
||||||
|
</a>
|
||||||
|
</JobName>
|
||||||
|
<Schedule>{job.schedule}</Schedule>
|
||||||
|
<Time title="Last run">{formatRelative(job.lastRun)}</Time>
|
||||||
|
<Time title="Next run">{formatRelative(job.nextRun)}</Time>
|
||||||
|
<form method="post" action={`/run/${job.app}/${job.name}`}>
|
||||||
|
<RunButton type="submit" disabled={job.state === 'running'}>
|
||||||
|
{job.state === 'running' ? 'Running...' : 'Run Now'}
|
||||||
|
</RunButton>
|
||||||
|
</form>
|
||||||
|
</JobItem>
|
||||||
|
))}
|
||||||
|
{invalid.map(job => (
|
||||||
|
<InvalidItem>
|
||||||
|
<StatusDot style={{ backgroundColor: theme('colors-error') }} />
|
||||||
|
<JobName>{job.app}/{job.name}</JobName>
|
||||||
|
<ErrorText>{job.error}</ErrorText>
|
||||||
|
</InvalidItem>
|
||||||
|
))}
|
||||||
|
</JobList>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusBadgeStyle(job: CronJob): Record<string, string> {
|
||||||
|
if (job.state === 'running') return { backgroundColor: theme('colors-statusRunning'), color: 'white' }
|
||||||
|
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return { backgroundColor: theme('colors-error'), color: 'white' }
|
||||||
|
return { backgroundColor: theme('colors-bgElement'), color: theme('colors-textMuted') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(job: CronJob): string {
|
||||||
|
if (job.state === 'running') return 'running'
|
||||||
|
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return `exit ${job.lastExitCode}`
|
||||||
|
if (job.lastRun) return 'ok'
|
||||||
|
return 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms?: number): string {
|
||||||
|
if (!ms) return '-'
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
if (ms < 60000) return `${Math.round(ms / 1000)}s`
|
||||||
|
return `${Math.round(ms / 60000)}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/job/:app/:name', async c => {
|
||||||
|
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
||||||
|
const job = getJob(id)
|
||||||
|
const appFilter = c.req.query('app')
|
||||||
|
const backUrl = appFilter ? `/?app=${appFilter}` : '/'
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Job Not Found">
|
||||||
|
<BackLink href={backUrl}>← 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 => {
|
||||||
|
const appName = c.req.query('app') || ''
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="New Cron Job">
|
||||||
|
<form method="post" action="/new">
|
||||||
|
<Form>
|
||||||
|
<input type="hidden" name="app" value={appName} />
|
||||||
|
<FormGroup>
|
||||||
|
<Label>Job Name</Label>
|
||||||
|
<input name="name" placeholder="my-job" required pattern="[a-z0-9\-]+" title="lowercase letters, numbers, and hyphens only" style={inputStyles} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup>
|
||||||
|
<Label>Run every</Label>
|
||||||
|
<select name="schedule" style={inputStyles}>
|
||||||
|
{SCHEDULES.map(s => (
|
||||||
|
<option value={s} selected={s === 'day'}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormGroup>
|
||||||
|
<ButtonRow>
|
||||||
|
<CancelButton href="/">Cancel</CancelButton>
|
||||||
|
<button type="submit" style={buttonStyles}>Create Job</button>
|
||||||
|
</ButtonRow>
|
||||||
|
</Form>
|
||||||
|
</form>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/new', async c => {
|
||||||
|
const body = await c.req.parseBody()
|
||||||
|
const appName = body.app as string
|
||||||
|
const name = body.name as string
|
||||||
|
const schedule = body.schedule as string
|
||||||
|
|
||||||
|
if (!appName || !name || !schedule) {
|
||||||
|
return c.redirect('/new?error=missing-fields')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name (lowercase, numbers, hyphens)
|
||||||
|
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||||
|
return c.redirect('/new?error=invalid-name')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cronDir = join(APPS_DIR, appName, 'current', 'cron')
|
||||||
|
const filePath = join(cronDir, `${name}.ts`)
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
return c.redirect('/new?error=already-exists')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cron directory if needed
|
||||||
|
if (!existsSync(cronDir)) {
|
||||||
|
await mkdir(cronDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the cron file
|
||||||
|
const content = `export const schedule = "${schedule}"
|
||||||
|
|
||||||
|
export default async function() {
|
||||||
|
console.log("${appName}/${name} executed at", new Date().toISOString())
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
await writeFile(filePath, content)
|
||||||
|
console.log(`[cron] Created ${appName}:${name}`)
|
||||||
|
|
||||||
|
// Trigger rediscovery
|
||||||
|
const { jobs, invalid } = await discoverCronJobs()
|
||||||
|
setJobs(jobs)
|
||||||
|
setInvalidJobs(invalid)
|
||||||
|
for (const job of jobs) {
|
||||||
|
if (job.id === `${appName}:${name}`) {
|
||||||
|
scheduleJob(job, broadcast)
|
||||||
|
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.redirect('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/run/:app/:name', async c => {
|
||||||
|
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
||||||
|
const job = getJob(id)
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return c.redirect('/?error=not-found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget so the redirect happens immediately
|
||||||
|
executeJob(job, broadcast)
|
||||||
|
|
||||||
|
const returnTo = c.req.query('return')
|
||||||
|
const appFilter = c.req.query('app')
|
||||||
|
if (returnTo === 'detail') {
|
||||||
|
return c.redirect(`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`)
|
||||||
|
}
|
||||||
|
return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
async function init() {
|
||||||
|
const { jobs, invalid } = await discoverCronJobs()
|
||||||
|
setJobs(jobs)
|
||||||
|
setInvalidJobs(invalid)
|
||||||
|
console.log(`[cron] Discovered ${jobs.length} jobs, ${invalid.length} invalid`)
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
scheduleJob(job, broadcast)
|
||||||
|
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const job of invalid) {
|
||||||
|
console.log(`[cron] Invalid ${job.id}: ${job.error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for cron file changes
|
||||||
|
let debounceTimer: Timer | null = null
|
||||||
|
|
||||||
|
async function rediscover() {
|
||||||
|
const { jobs, invalid } = await discoverCronJobs()
|
||||||
|
const existing = getAllJobs()
|
||||||
|
|
||||||
|
// Stop removed jobs
|
||||||
|
for (const old of existing) {
|
||||||
|
if (!jobs.find(j => j.id === old.id)) {
|
||||||
|
stopJob(old.id)
|
||||||
|
console.log(`[cron] Removed ${old.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update jobs
|
||||||
|
for (const job of jobs) {
|
||||||
|
const old = existing.find(j => j.id === job.id)
|
||||||
|
if (!old || old.cronExpr !== job.cronExpr) {
|
||||||
|
scheduleJob(job, broadcast)
|
||||||
|
console.log(`[cron] Updated ${job.id}: ${job.schedule}`)
|
||||||
|
} else {
|
||||||
|
// Preserve runtime state
|
||||||
|
job.state = old.state
|
||||||
|
job.lastRun = old.lastRun
|
||||||
|
job.lastDuration = old.lastDuration
|
||||||
|
job.lastExitCode = old.lastExitCode
|
||||||
|
job.lastError = old.lastError
|
||||||
|
job.lastOutput = old.lastOutput
|
||||||
|
job.nextRun = old.nextRun
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setJobs(jobs)
|
||||||
|
setInvalidJobs(invalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
||||||
|
if (!filename?.includes('/cron/') && !filename?.includes('\\cron\\')) return
|
||||||
|
if (!filename.endsWith('.ts')) return
|
||||||
|
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(rediscover, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
on(['app:activate', 'app:delete'], (event) => {
|
||||||
|
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
|
||||||
|
rediscover()
|
||||||
|
})
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
82
apps/cron/20260201-000000/lib/discovery.ts
Normal file
82
apps/cron/20260201-000000/lib/discovery.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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
|
||||||
|
// 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 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, '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$/, '')
|
||||||
|
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/20260201-000000/lib/executor.ts
Normal file
96
apps/cron/20260201-000000/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, 'current')
|
||||||
|
|
||||||
|
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/20260201-000000/lib/runner.ts
Normal file
16
apps/cron/20260201-000000/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)
|
||||||
|
}
|
||||||
26
apps/cron/20260201-000000/lib/scheduler.ts
Normal file
26
apps/cron/20260201-000000/lib/scheduler.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Cron } from 'croner'
|
||||||
|
import type { CronJob } from './schedules'
|
||||||
|
import { executeJob } from './executor'
|
||||||
|
|
||||||
|
const scheduled = new Map<string, Cron>()
|
||||||
|
|
||||||
|
export function scheduleJob(job: CronJob, onUpdate: () => void) {
|
||||||
|
// Stop existing if any
|
||||||
|
scheduled.get(job.id)?.stop()
|
||||||
|
|
||||||
|
const cron = new Cron(job.cronExpr, async () => {
|
||||||
|
await executeJob(job, onUpdate)
|
||||||
|
})
|
||||||
|
|
||||||
|
scheduled.set(job.id, cron)
|
||||||
|
job.nextRun = cron.nextRun()?.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopJob(jobId: string) {
|
||||||
|
scheduled.get(jobId)?.stop()
|
||||||
|
scheduled.delete(jobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextRun(jobId: string): number | undefined {
|
||||||
|
return scheduled.get(jobId)?.nextRun()?.getTime()
|
||||||
|
}
|
||||||
119
apps/cron/20260201-000000/lib/schedules.ts
Normal file
119
apps/cron/20260201-000000/lib/schedules.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
export type Schedule =
|
||||||
|
| "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"
|
||||||
|
| "week" | "day" | "midnight" | "noon" | "hour"
|
||||||
|
| "30 minutes" | "15 minutes" | "5 minutes" | "1 minute"
|
||||||
|
| "30minutes" | "15minutes" | "5minutes" | "1minute"
|
||||||
|
| 30 | 15 | 5 | 1
|
||||||
|
| (string & {}) // time strings like "7am", "7:30pm", "14:00"
|
||||||
|
|
||||||
|
export type CronJob = {
|
||||||
|
id: string // "appname:filename"
|
||||||
|
app: string
|
||||||
|
name: string // filename without .ts
|
||||||
|
file: string // full path
|
||||||
|
schedule: Schedule
|
||||||
|
cronExpr: string
|
||||||
|
state: 'idle' | 'running' | 'disabled'
|
||||||
|
lastRun?: number
|
||||||
|
lastDuration?: number
|
||||||
|
lastExitCode?: number
|
||||||
|
lastError?: string
|
||||||
|
lastOutput?: string
|
||||||
|
nextRun?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvalidJob = {
|
||||||
|
id: string
|
||||||
|
app: string
|
||||||
|
name: string
|
||||||
|
file: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCHEDULES = [
|
||||||
|
'1 minute',
|
||||||
|
'5 minutes',
|
||||||
|
'15 minutes',
|
||||||
|
'30 minutes',
|
||||||
|
'hour',
|
||||||
|
'noon',
|
||||||
|
'midnight',
|
||||||
|
'day',
|
||||||
|
'week',
|
||||||
|
'sunday',
|
||||||
|
'monday',
|
||||||
|
'tuesday',
|
||||||
|
'wednesday',
|
||||||
|
'thursday',
|
||||||
|
'friday',
|
||||||
|
'saturday',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const SCHEDULE_MAP: Record<string, string> = {
|
||||||
|
sunday: '0 0 * * 0',
|
||||||
|
monday: '0 0 * * 1',
|
||||||
|
tuesday: '0 0 * * 2',
|
||||||
|
wednesday: '0 0 * * 3',
|
||||||
|
thursday: '0 0 * * 4',
|
||||||
|
friday: '0 0 * * 5',
|
||||||
|
saturday: '0 0 * * 6',
|
||||||
|
week: '0 0 * * 0',
|
||||||
|
day: '0 0 * * *',
|
||||||
|
midnight: '0 0 * * *',
|
||||||
|
noon: '0 12 * * *',
|
||||||
|
hour: '0 * * * *',
|
||||||
|
'30 minutes': '0,30 * * * *',
|
||||||
|
'15 minutes': '0,15,30,45 * * * *',
|
||||||
|
'5 minutes': '*/5 * * * *',
|
||||||
|
'1 minute': '* * * * *',
|
||||||
|
'30minutes': '0,30 * * * *',
|
||||||
|
'15minutes': '0,15,30,45 * * * *',
|
||||||
|
'5minutes': '*/5 * * * *',
|
||||||
|
'1minute': '* * * * *',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidSchedule(value: unknown): value is Schedule {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return [1, 5, 15, 30].includes(value)
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value in SCHEDULE_MAP || parseTime(value) !== null
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTime(s: string): { hour: number, minute: number } | null {
|
||||||
|
// 12h: "7am", "7pm", "7:30am", "7:30pm", "12am", "12:00pm"
|
||||||
|
const m12 = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i)
|
||||||
|
if (m12) {
|
||||||
|
let hour = parseInt(m12[1]!)
|
||||||
|
const minute = m12[2] ? parseInt(m12[2]) : 0
|
||||||
|
const period = m12[3]!.toLowerCase()
|
||||||
|
if (hour < 1 || hour > 12 || minute > 59) return null
|
||||||
|
if (period === 'am' && hour === 12) hour = 0
|
||||||
|
else if (period === 'pm' && hour !== 12) hour += 12
|
||||||
|
return { hour, minute }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24h: "14:00", "0:00", "23:59"
|
||||||
|
const m24 = s.match(/^(\d{1,2}):(\d{2})$/)
|
||||||
|
if (m24) {
|
||||||
|
const hour = parseInt(m24[1]!)
|
||||||
|
const minute = parseInt(m24[2]!)
|
||||||
|
if (hour > 23 || minute > 59) return null
|
||||||
|
return { hour, minute }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCronExpr(schedule: Schedule): string {
|
||||||
|
if (typeof schedule === 'number') {
|
||||||
|
return SCHEDULE_MAP[`${schedule}minutes`]!
|
||||||
|
}
|
||||||
|
if (schedule in SCHEDULE_MAP) {
|
||||||
|
return SCHEDULE_MAP[schedule]!
|
||||||
|
}
|
||||||
|
const time = parseTime(schedule)!
|
||||||
|
return `${time.minute} ${time.hour} * * *`
|
||||||
|
}
|
||||||
39
apps/cron/20260201-000000/lib/state.ts
Normal file
39
apps/cron/20260201-000000/lib/state.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { CronJob, InvalidJob } from './schedules'
|
||||||
|
|
||||||
|
const jobs = new Map<string, CronJob>()
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
let invalidJobs: InvalidJob[] = []
|
||||||
|
|
||||||
|
export function setJobs(newJobs: CronJob[]) {
|
||||||
|
jobs.clear()
|
||||||
|
for (const job of newJobs) {
|
||||||
|
jobs.set(job.id, job)
|
||||||
|
}
|
||||||
|
broadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setInvalidJobs(newInvalid: InvalidJob[]) {
|
||||||
|
invalidJobs = newInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(id: string): CronJob | undefined {
|
||||||
|
return jobs.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllJobs(): CronJob[] {
|
||||||
|
return Array.from(jobs.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInvalidJobs(): InvalidJob[] {
|
||||||
|
return invalidJobs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcast() {
|
||||||
|
listeners.forEach(cb => cb())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onChange(cb: () => void): () => void {
|
||||||
|
listeners.add(cb)
|
||||||
|
return () => listeners.delete(cb)
|
||||||
|
}
|
||||||
22
apps/cron/20260201-000000/package.json
Normal file
22
apps/cron/20260201-000000/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "cron",
|
||||||
|
"private": true,
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"tool": true,
|
||||||
|
"icon": "⏰"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "^0.0.1",
|
||||||
|
"@because/hype": "^0.0.2",
|
||||||
|
"@because/toes": "^0.0.8",
|
||||||
|
"croner": "^9.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/cron/20260201-000000/tsconfig.json
Normal file
13
apps/cron/20260201-000000/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["bun"],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/env/20260130-000000/.npmrc
vendored
Normal file
1
apps/env/20260130-000000/.npmrc
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
47
apps/env/20260130-000000/bun.lock
vendored
Normal file
47
apps/env/20260130-000000/bun.lock
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "env",
|
||||||
|
"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.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=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"@because/toes/@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
471
apps/env/20260130-000000/index.tsx
vendored
Normal file
471
apps/env/20260130-000000/index.tsx
vendored
Normal file
|
|
@ -0,0 +1,471 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
|
const TOES_DIR = process.env.TOES_DIR ?? join(process.env.HOME!, '.toes')
|
||||||
|
const ENV_DIR = join(TOES_DIR, 'env')
|
||||||
|
const GLOBAL_ENV_PATH = join(ENV_DIR, '_global.env')
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
const Badge = define('Badge', {
|
||||||
|
base: 'span',
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
backgroundColor: theme('colors-bgSubtle'),
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
fontWeight: 'normal',
|
||||||
|
marginLeft: '8px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Button = define('Button', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: theme('colors-bgHover'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const DangerButton = define('DangerButton', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: theme('colors-error'),
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptyState = define('EmptyState', {
|
||||||
|
padding: '30px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
backgroundColor: theme('colors-bgSubtle'),
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const EnvActions = define('EnvActions', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const EnvItem = define('EnvItem', {
|
||||||
|
padding: '12px 15px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '10px',
|
||||||
|
states: {
|
||||||
|
':last-child': {
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const EnvKey = define('EnvKey', {
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const EnvList = define('EnvList', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
const EnvValue = define('EnvValue', {
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '14px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ErrorBox = define('ErrorBox', {
|
||||||
|
color: theme('colors-error'),
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
margin: '20px 0',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Form = define('Form', {
|
||||||
|
base: 'form',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
marginTop: '15px',
|
||||||
|
padding: '15px',
|
||||||
|
backgroundColor: theme('colors-bgSubtle'),
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Hint = define('Hint', {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
marginTop: '10px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Input = define('Input', {
|
||||||
|
base: 'input',
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
backgroundColor: theme('colors-bg'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
states: {
|
||||||
|
':focus': {
|
||||||
|
outline: 'none',
|
||||||
|
borderColor: theme('colors-primary'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Tab = define('Tab', {
|
||||||
|
base: 'a',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderBottom: '2px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TabActive = define('TabActive', {
|
||||||
|
base: 'a',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderBottom: `2px solid ${theme('colors-primary')}`,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'default',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TabBar = define('TabBar', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
marginBottom: '15px',
|
||||||
|
})
|
||||||
|
|
||||||
|
interface EnvVar {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
title: string
|
||||||
|
children: Child
|
||||||
|
}
|
||||||
|
|
||||||
|
const appEnvPath = (appName: string) =>
|
||||||
|
join(ENV_DIR, `${appName}.env`)
|
||||||
|
|
||||||
|
function Layout({ title, children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
|
<Container>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: clientScript }} />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEnvDir() {
|
||||||
|
if (!existsSync(ENV_DIR)) {
|
||||||
|
mkdirSync(ENV_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvFile(path: string): EnvVar[] {
|
||||||
|
if (!existsSync(path)) return []
|
||||||
|
|
||||||
|
const content = readFileSync(path, 'utf-8')
|
||||||
|
const vars: EnvVar[] = []
|
||||||
|
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue
|
||||||
|
|
||||||
|
const eqIndex = trimmed.indexOf('=')
|
||||||
|
if (eqIndex === -1) continue
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, eqIndex).trim()
|
||||||
|
let value = trimmed.slice(eqIndex + 1).trim()
|
||||||
|
|
||||||
|
// Remove surrounding quotes if present
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key) vars.push({ key, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return vars
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEnvFile(path: string, vars: EnvVar[]) {
|
||||||
|
ensureEnvDir()
|
||||||
|
const content = vars.map(v => `${v.key}=${v.value}`).join('\n') + (vars.length ? '\n' : '')
|
||||||
|
writeFileSync(path, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientScript = `
|
||||||
|
document.querySelectorAll('[data-reveal]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const valueEl = btn.closest('[data-env-item]').querySelector('[data-value]');
|
||||||
|
const hidden = valueEl.dataset.hidden;
|
||||||
|
if (hidden) {
|
||||||
|
valueEl.textContent = hidden;
|
||||||
|
valueEl.dataset.hidden = '';
|
||||||
|
valueEl.style.whiteSpace = 'pre-wrap';
|
||||||
|
valueEl.style.wordBreak = 'break-all';
|
||||||
|
btn.textContent = 'Hide';
|
||||||
|
} else {
|
||||||
|
valueEl.dataset.hidden = valueEl.textContent;
|
||||||
|
valueEl.textContent = '••••••••';
|
||||||
|
valueEl.style.whiteSpace = 'nowrap';
|
||||||
|
valueEl.style.wordBreak = '';
|
||||||
|
btn.textContent = 'Reveal';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Environment Variables">
|
||||||
|
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = c.req.query('tab') === 'global' ? 'global' : 'app'
|
||||||
|
const appUrl = `/?app=${appName}`
|
||||||
|
const globalUrl = `/?app=${appName}&tab=global`
|
||||||
|
|
||||||
|
if (tab === 'global') {
|
||||||
|
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title={`Env - Global`}>
|
||||||
|
<TabBar>
|
||||||
|
<Tab href={appUrl}>App</Tab>
|
||||||
|
<TabActive href={globalUrl}>Global</TabActive>
|
||||||
|
</TabBar>
|
||||||
|
{globalVars.length === 0 ? (
|
||||||
|
<EmptyState>No global environment variables</EmptyState>
|
||||||
|
) : (
|
||||||
|
<EnvList>
|
||||||
|
{globalVars.map(v => (
|
||||||
|
<EnvItem data-env-item>
|
||||||
|
<EnvKey>{v.key}</EnvKey>
|
||||||
|
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
|
||||||
|
<EnvActions>
|
||||||
|
<Button data-reveal>Reveal</Button>
|
||||||
|
<form method="post" action={`/delete-global?app=${appName}&key=${v.key}`} style="margin:0">
|
||||||
|
<DangerButton type="submit">Delete</DangerButton>
|
||||||
|
</form>
|
||||||
|
</EnvActions>
|
||||||
|
</EnvItem>
|
||||||
|
))}
|
||||||
|
</EnvList>
|
||||||
|
)}
|
||||||
|
<Form method="POST" action={`/set-global?app=${appName}`}>
|
||||||
|
<Input type="text" name="key" placeholder="KEY" required />
|
||||||
|
<Input type="text" name="value" placeholder="value" required />
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
</Form>
|
||||||
|
<Hint>Global vars are available to all apps. Changes take effect on next app restart.</Hint>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appVars = parseEnvFile(appEnvPath(appName))
|
||||||
|
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
|
||||||
|
const globalKeys = new Set(globalVars.map(v => v.key))
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title={`Env - ${appName}`}>
|
||||||
|
<TabBar>
|
||||||
|
<TabActive href={appUrl}>App</TabActive>
|
||||||
|
<Tab href={globalUrl}>Global</Tab>
|
||||||
|
</TabBar>
|
||||||
|
{appVars.length === 0 && globalKeys.size === 0 ? (
|
||||||
|
<EmptyState>No environment variables</EmptyState>
|
||||||
|
) : (
|
||||||
|
<EnvList>
|
||||||
|
{appVars.map(v => (
|
||||||
|
<EnvItem data-env-item>
|
||||||
|
<EnvKey>
|
||||||
|
{v.key}
|
||||||
|
{globalKeys.has(v.key) && <Badge>overrides global</Badge>}
|
||||||
|
</EnvKey>
|
||||||
|
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
|
||||||
|
<EnvActions>
|
||||||
|
<Button data-reveal>Reveal</Button>
|
||||||
|
<form method="post" action={`/delete?app=${appName}&key=${v.key}`} style="margin:0">
|
||||||
|
<DangerButton type="submit">Delete</DangerButton>
|
||||||
|
</form>
|
||||||
|
</EnvActions>
|
||||||
|
</EnvItem>
|
||||||
|
))}
|
||||||
|
{globalVars.filter(v => !appVars.some(a => a.key === v.key)).map(v => (
|
||||||
|
<EnvItem data-env-item>
|
||||||
|
<EnvKey>
|
||||||
|
{v.key}
|
||||||
|
<Badge>global</Badge>
|
||||||
|
</EnvKey>
|
||||||
|
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
|
||||||
|
<EnvActions>
|
||||||
|
<Button data-reveal>Reveal</Button>
|
||||||
|
</EnvActions>
|
||||||
|
</EnvItem>
|
||||||
|
))}
|
||||||
|
</EnvList>
|
||||||
|
)}
|
||||||
|
<Form method="POST" action={`/set?app=${appName}`}>
|
||||||
|
<Input type="text" name="key" placeholder="KEY" required />
|
||||||
|
<Input type="text" name="value" placeholder="value" required />
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
</Form>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/set', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
if (!appName) return c.text('Missing app', 400)
|
||||||
|
|
||||||
|
const body = await c.req.parseBody()
|
||||||
|
const key = String(body.key).trim().toUpperCase()
|
||||||
|
const value = String(body.value)
|
||||||
|
|
||||||
|
if (!key) return c.text('Missing key', 400)
|
||||||
|
|
||||||
|
const path = appEnvPath(appName)
|
||||||
|
const vars = parseEnvFile(path)
|
||||||
|
const existing = vars.findIndex(v => v.key === key)
|
||||||
|
|
||||||
|
if (existing >= 0) {
|
||||||
|
vars[existing]!.value = value
|
||||||
|
} else {
|
||||||
|
vars.push({ key, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
writeEnvFile(path, vars)
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/delete', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
const key = c.req.query('key')
|
||||||
|
if (!appName || !key) return c.text('Missing app or key', 400)
|
||||||
|
|
||||||
|
const path = appEnvPath(appName)
|
||||||
|
const vars = parseEnvFile(path).filter(v => v.key !== key)
|
||||||
|
writeEnvFile(path, vars)
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/set-global', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
if (!appName) return c.text('Missing app', 400)
|
||||||
|
|
||||||
|
const body = await c.req.parseBody()
|
||||||
|
const key = String(body.key).trim().toUpperCase()
|
||||||
|
const value = String(body.value)
|
||||||
|
|
||||||
|
if (!key) return c.text('Missing key', 400)
|
||||||
|
|
||||||
|
const vars = parseEnvFile(GLOBAL_ENV_PATH)
|
||||||
|
const existing = vars.findIndex(v => v.key === key)
|
||||||
|
|
||||||
|
if (existing >= 0) {
|
||||||
|
vars[existing]!.value = value
|
||||||
|
} else {
|
||||||
|
vars.push({ key, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
writeEnvFile(GLOBAL_ENV_PATH, vars)
|
||||||
|
return c.redirect(`/?app=${appName}&tab=global`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/delete-global', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
const key = c.req.query('key')
|
||||||
|
if (!appName || !key) return c.text('Missing app or key', 400)
|
||||||
|
|
||||||
|
const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key)
|
||||||
|
writeEnvFile(GLOBAL_ENV_PATH, vars)
|
||||||
|
return c.redirect(`/?app=${appName}&tab=global`)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "todo",
|
"name": "env",
|
||||||
"module": "index.tsx",
|
"module": "index.tsx",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
"dev": "bun run --hot index.tsx"
|
"dev": "bun run --hot index.tsx"
|
||||||
},
|
},
|
||||||
"toes": {
|
"toes": {
|
||||||
"tool": true,
|
"tool": ".env",
|
||||||
"icon": "🖥️"
|
"icon": "🔑"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/hype": "*",
|
|
||||||
"@because/forge": "*",
|
"@because/forge": "*",
|
||||||
"@because/howl": "*"
|
"@because/hype": "*",
|
||||||
|
"@because/toes": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
hi
|
|
||||||
1
apps/metrics/20260130-000000/.npmrc
Normal file
1
apps/metrics/20260130-000000/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
45
apps/metrics/20260130-000000/bun.lock
Normal file
45
apps/metrics/20260130-000000/bun.lock
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "stats",
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "^0.0.1",
|
||||||
|
"@because/hype": "^0.0.2",
|
||||||
|
"@because/toes": "^0.0.5",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||||
|
|
||||||
|
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||||
|
|
||||||
|
"@because/toes": ["@because/toes@0.0.5", "https://npm.nose.space/@because/toes/-/toes-0.0.5.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-YM1VuR1sym7m7pFcaiqnjg6eJUyhJYUH2ROBb+xi+HEXajq46ZL8KDyyCtz7WiHTfrbxcEWGjqyj20a7UppcJg=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
1024
apps/metrics/20260130-000000/index.tsx
Normal file
1024
apps/metrics/20260130-000000/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -1,24 +1,26 @@
|
||||||
{
|
{
|
||||||
"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": "📊"
|
||||||
|
},
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -18,12 +18,6 @@
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"$*": ["src/server/*"],
|
|
||||||
"#*": ["src/client/*"],
|
|
||||||
"@*": ["src/shared/*"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 +0,0 @@
|
||||||
export { default } from './src/server'
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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,8 +0,0 @@
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
|
|
||||||
const app = new Hype({ layout: false })
|
|
||||||
|
|
||||||
// custom routes go here
|
|
||||||
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
1
apps/truisms/20260130-000000/.npmrc
Normal file
1
apps/truisms/20260130-000000/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
|
|
@ -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 @@
|
||||||
20260130-000000
|
|
||||||
1
apps/versions/20260130-000000/.npmrc
Normal file
1
apps/versions/20260130-000000/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
|
|
@ -3,26 +3,26 @@
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "todo",
|
"name": "versions",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "*",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/howl": "*",
|
"@because/hype": "^0.0.2",
|
||||||
"@because/hype": "*",
|
"@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/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.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.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.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=="],
|
||||||
|
|
||||||
|
|
@ -30,12 +30,14 @@
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"hono": ["hono@4.11.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=="],
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
"lucide-static": ["lucide-static@0.555.0", "https://npm.nose.space/lucide-static/-/lucide-static-0.555.0.tgz", {}, "sha512-FMMaYYsEYsUA6xlEzIMoKEV3oGnxIIvAN+AtLmYXvlTJptJTveJjVBQwvtA/zZLrD6KLEu89G95dQYlhivw5jQ=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
177
apps/versions/20260130-000000/index.tsx
Normal file
177
apps/versions/20260130-000000/index.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
|
import { readdir, readlink, stat } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const VersionList = define('VersionList', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: '20px 0',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
const VersionItem = define('VersionItem', {
|
||||||
|
padding: '12px 15px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
states: {
|
||||||
|
':last-child': {
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: theme('colors-bgHover'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const VersionLink = define('VersionLink', {
|
||||||
|
base: 'a',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: theme('colors-link'),
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: '15px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Badge = define('Badge', {
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
color: theme('colors-statusRunning'),
|
||||||
|
fontWeight: 'bold',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ErrorBox = define('ErrorBox', {
|
||||||
|
color: theme('colors-error'),
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
margin: '20px 0',
|
||||||
|
})
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
title: string
|
||||||
|
children: Child
|
||||||
|
}
|
||||||
|
|
||||||
|
function Layout({ title, children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ToolScript />
|
||||||
|
<Container>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function getVersions(appPath: string): Promise<{ name: string; isCurrent: boolean }[]> {
|
||||||
|
const entries = await readdir(appPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
let currentTarget = ''
|
||||||
|
try {
|
||||||
|
currentTarget = await readlink(join(appPath, 'current'))
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
|
||||||
|
.map(e => ({ name: e.name, isCurrent: e.name === currentTarget }))
|
||||||
|
.sort((a, b) => b.name.localeCompare(a.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: string): string {
|
||||||
|
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}:${ts.slice(13, 15)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Versions">
|
||||||
|
<ErrorBox>Please specify an app name with ?app=<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">
|
||||||
|
<ErrorBox>No versions found</ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Versions">
|
||||||
|
<VersionList>
|
||||||
|
{versions.map(v => (
|
||||||
|
<VersionItem>
|
||||||
|
<VersionLink
|
||||||
|
href={`${TOES_URL}/tool/code?app=${appName}&version=${v.name}`}
|
||||||
|
>
|
||||||
|
{formatTimestamp(v.name)}
|
||||||
|
</VersionLink>
|
||||||
|
{v.isCurrent && <Badge>current</Badge>}
|
||||||
|
</VersionItem>
|
||||||
|
))}
|
||||||
|
</VersionList>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "basic",
|
"name": "versions",
|
||||||
"module": "src/index.ts",
|
"module": "index.tsx",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -8,14 +8,19 @@
|
||||||
"start": "bun toes",
|
"start": "bun toes",
|
||||||
"dev": "bun run --hot index.tsx"
|
"dev": "bun run --hot index.tsx"
|
||||||
},
|
},
|
||||||
|
"toes": {
|
||||||
|
"tool": true,
|
||||||
|
"icon": "📦"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.1"
|
"@because/hype": "^0.0.2",
|
||||||
|
"@because/toes": "^0.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
25
bun.lock
25
bun.lock
|
|
@ -3,11 +3,12 @@
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "toes",
|
"name": "@because/toes",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.1",
|
"@because/hype": "^0.0.2",
|
||||||
"commander": "^14.0.2",
|
"@because/sneaker": "^0.0.3",
|
||||||
|
"commander": "^14.0.3",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
},
|
},
|
||||||
|
|
@ -16,33 +17,37 @@
|
||||||
"@types/diff": "^8.0.0",
|
"@types/diff": "^8.0.0",
|
||||||
},
|
},
|
||||||
"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=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
"@because/sneaker": ["@because/sneaker@0.0.3", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.3.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-4cG8w/tYPGbDtLw89k1PiASJKfWUdd1NXv+GKad2d7Ckw3FpZ+dnN2+gR2ihs81dqAkNaZomo+9RznBju2WaOw=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
|
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
"@types/node": ["@types/node@25.2.3", "https://npm.nose.space/@types/node/-/node-25.2.3.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
docs/APPS.md
Normal file
86
docs/APPS.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Apps
|
||||||
|
|
||||||
|
An app is an HTTP server that runs on its assigned port.
|
||||||
|
|
||||||
|
## minimum requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/<name>/
|
||||||
|
<timestamp>/ # YYYYMMDD-HHMMSS
|
||||||
|
package.json
|
||||||
|
index.tsx
|
||||||
|
current -> <timestamp> # symlink to active version
|
||||||
|
```
|
||||||
|
|
||||||
|
**package.json** must have `scripts.toes`:
|
||||||
|
|
||||||
|
```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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**index.tsx** must export `app.defaults`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype()
|
||||||
|
app.get('/', c => c.html(<h1>Hello</h1>))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
## environment
|
||||||
|
|
||||||
|
- `PORT` - your assigned port (3001-3100)
|
||||||
|
- `APPS_DIR` - path to `/apps` directory
|
||||||
|
- `DATA_DIR` - per-app data directory (`toes/<app-name>/`)
|
||||||
|
|
||||||
|
## health checks
|
||||||
|
|
||||||
|
Toes hits `GET /` every 30 seconds. Return 2xx or get restarted.
|
||||||
|
|
||||||
|
3 failures = restart with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s).
|
||||||
|
|
||||||
|
## lifecycle
|
||||||
|
|
||||||
|
`invalid` -> `stopped` -> `starting` -> `running` -> `stopping`
|
||||||
|
|
||||||
|
Apps auto-restart on crash. `bun install` runs before every start.
|
||||||
|
|
||||||
|
## cli
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes new my-app # create from template
|
||||||
|
toes list # show apps
|
||||||
|
toes start my-app # start
|
||||||
|
toes stop my-app # stop
|
||||||
|
toes restart my-app # restart
|
||||||
|
toes logs -f my-app # tail logs
|
||||||
|
toes open my-app # open in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## making a new app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes new my-app --template=spa
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ssr` - server-side rendered (default)
|
||||||
|
- `spa` - single page app (w/ hono/jsx)
|
||||||
|
- `bare` - minimal
|
||||||
|
|
||||||
81
docs/CRON.md
Normal file
81
docs/CRON.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Cron
|
||||||
|
|
||||||
|
A cron job is a TypeScript file that runs on a schedule.
|
||||||
|
|
||||||
|
The cron tool discovers jobs from all apps and runs them automatically.
|
||||||
|
|
||||||
|
## creating a cron job
|
||||||
|
|
||||||
|
Add a file to `cron/` in any app:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/my-app/current/cron/daily-cleanup.ts
|
||||||
|
export const schedule = "day"
|
||||||
|
|
||||||
|
export default async function() {
|
||||||
|
console.log("Running cleanup...")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. The cron tool picks it 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 that day |
|
||||||
|
|
||||||
|
Alternate forms work too: `"30minutes"`, `30`, `"30 minutes"`.
|
||||||
|
|
||||||
|
## environment
|
||||||
|
|
||||||
|
Jobs run with the app's working directory and inherit all env vars.
|
||||||
|
|
||||||
|
- `APPS_DIR` - path to `/apps` directory
|
||||||
|
|
||||||
|
## ui
|
||||||
|
|
||||||
|
The cron tool shows:
|
||||||
|
- Job name (`app/job`)
|
||||||
|
- Schedule
|
||||||
|
- Last run time
|
||||||
|
- Next run time
|
||||||
|
- Run Now button
|
||||||
|
|
||||||
|
Click **New Job** to create one from the UI.
|
||||||
|
|
||||||
|
## manual runs
|
||||||
|
|
||||||
|
Hit the **Run Now** button or POST to the tool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/run/my-app/daily-cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
## job state
|
||||||
|
|
||||||
|
Jobs track:
|
||||||
|
- `idle` / `running` / `disabled`
|
||||||
|
- Last run timestamp
|
||||||
|
- Last duration
|
||||||
|
- Last exit code
|
||||||
|
- Last error (if any)
|
||||||
|
- Next scheduled run
|
||||||
|
|
||||||
|
## discovery
|
||||||
|
|
||||||
|
The cron tool:
|
||||||
|
1. Scans `APPS_DIR/*/current/cron/*.ts`
|
||||||
|
2. Imports each file to read `schedule`
|
||||||
|
3. Validates the schedule
|
||||||
|
4. Registers with croner
|
||||||
|
|
||||||
|
Re-scans every 60 seconds to pick up new/changed/removed jobs.
|
||||||
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
|
||||||
848
docs/GUIDE.md
Normal file
848
docs/GUIDE.md
Normal file
|
|
@ -0,0 +1,848 @@
|
||||||
|
# Toes User Guide
|
||||||
|
|
||||||
|
Toes is a personal web appliance that runs multiple web apps on your home network. Plug it in, turn it on, and forget about the cloud.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Creating an App](#creating-an-app)
|
||||||
|
- [App Templates](#app-templates)
|
||||||
|
- [App Structure](#app-structure)
|
||||||
|
- [The Bare Minimum](#the-bare-minimum)
|
||||||
|
- [Using Hype](#using-hype)
|
||||||
|
- [Using Forge](#using-forge)
|
||||||
|
- [Creating a Tool](#creating-a-tool)
|
||||||
|
- [What's a Tool?](#whats-a-tool)
|
||||||
|
- [Tool Setup](#tool-setup)
|
||||||
|
- [Theme Tokens](#theme-tokens)
|
||||||
|
- [Accessing App Data](#accessing-app-data)
|
||||||
|
- [CLI Reference](#cli-reference)
|
||||||
|
- [App Management](#app-management)
|
||||||
|
- [Lifecycle](#lifecycle)
|
||||||
|
- [Syncing Code](#syncing-code)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
|
- [Versioning](#versioning)
|
||||||
|
- [Cron Jobs](#cron-jobs-1)
|
||||||
|
- [Metrics](#metrics)
|
||||||
|
- [Sharing](#sharing)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Environment Variables](#environment-variables-1)
|
||||||
|
- [Health Checks](#health-checks)
|
||||||
|
- [App Lifecycle](#app-lifecycle)
|
||||||
|
- [Cron Jobs](#cron-jobs)
|
||||||
|
- [Data Persistence](#data-persistence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the CLI
|
||||||
|
curl -fsSL http://toes.local/install | bash
|
||||||
|
|
||||||
|
# Create a new app
|
||||||
|
toes new my-app
|
||||||
|
|
||||||
|
# Enter the directory, install deps, and develop locally
|
||||||
|
cd my-app
|
||||||
|
bun install
|
||||||
|
bun dev
|
||||||
|
|
||||||
|
# Push to the server
|
||||||
|
toes push
|
||||||
|
|
||||||
|
# Open in browser
|
||||||
|
toes open
|
||||||
|
```
|
||||||
|
|
||||||
|
Your app is now running at `http://my-app.toes.local`.
|
||||||
|
|
||||||
|
> **Tip:** Add `.toes` to your `.gitignore`. This file tracks local sync state and shouldn't be committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating an App
|
||||||
|
|
||||||
|
### App Templates
|
||||||
|
|
||||||
|
Toes ships with three templates. Pick one when creating an app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes new my-app # SSR (default)
|
||||||
|
toes new my-app --bare # Minimal
|
||||||
|
toes new my-app --spa # Single-page app
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSR** — Server-side rendered with a pages directory. Best for most apps. Uses Hype's built-in layout and page routing.
|
||||||
|
|
||||||
|
**Bare** — Just an `index.tsx` with a single route. Good when you want to start from scratch.
|
||||||
|
|
||||||
|
**SPA** — Client-side rendering with `hono/jsx/dom`. Hype serves the HTML shell and static files; the browser handles routing and rendering.
|
||||||
|
|
||||||
|
### App Structure
|
||||||
|
|
||||||
|
A generated SSR app looks like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
.npmrc # Points to the private registry
|
||||||
|
.toesignore # Files to exclude from sync (like .gitignore)
|
||||||
|
package.json # Must have scripts.toes
|
||||||
|
tsconfig.json # TypeScript config
|
||||||
|
index.tsx # Entry point (re-exports from src/server)
|
||||||
|
src/
|
||||||
|
server/
|
||||||
|
index.tsx # Hype app with routes
|
||||||
|
pages/
|
||||||
|
index.tsx # Page components
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Bare Minimum
|
||||||
|
|
||||||
|
Every app needs three things:
|
||||||
|
|
||||||
|
1. **`package.json`** with a `scripts.toes` entry
|
||||||
|
2. **`index.tsx`** that exports `app.defaults`
|
||||||
|
3. **A `GET /ok` route** that returns 200 (health check)
|
||||||
|
|
||||||
|
**package.json:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"icon": "🎨"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/forge": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `scripts.toes` field is how Toes discovers your app. The `toes.icon` field sets the emoji shown in the dashboard.
|
||||||
|
|
||||||
|
**.npmrc:**
|
||||||
|
|
||||||
|
```
|
||||||
|
registry=https://npm.nose.space
|
||||||
|
```
|
||||||
|
|
||||||
|
Required for installing `@because/*` packages.
|
||||||
|
|
||||||
|
**index.tsx:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype()
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<h1>Hello World</h1>))
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. Push to the server and it runs.
|
||||||
|
|
||||||
|
### Using Hype
|
||||||
|
|
||||||
|
Hype wraps [Hono](https://hono.dev). Everything you know from Hono works here. Hype adds a few extras:
|
||||||
|
|
||||||
|
**Basic routing:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype()
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<h1>Home</h1>))
|
||||||
|
app.get('/about', c => c.html(<h1>About</h1>))
|
||||||
|
app.post('/api/items', async c => {
|
||||||
|
const body = await c.req.json()
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sub-routers:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const api = Hype.router()
|
||||||
|
api.get('/items', c => c.json([]))
|
||||||
|
api.post('/items', async c => {
|
||||||
|
const body = await c.req.json()
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.route('/api', api) // mounts at /api/items
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server-Sent Events:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
app.sse('/stream', (send, c) => {
|
||||||
|
send({ hello: 'world' })
|
||||||
|
const interval = setInterval(() => send({ time: Date.now() }), 1000)
|
||||||
|
return () => clearInterval(interval) // cleanup on disconnect
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constructor options:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const app = new Hype({
|
||||||
|
layout: true, // Wraps pages in an HTML layout (default: true)
|
||||||
|
prettyHTML: true, // Pretty-print HTML output (default: true)
|
||||||
|
logging: true, // Log requests to stdout (default: true)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Forge
|
||||||
|
|
||||||
|
Forge is a CSS-in-JS library that creates styled JSX components. Define a component once, use it everywhere.
|
||||||
|
|
||||||
|
**Basic usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
|
||||||
|
const Box = define('Box', {
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: '6px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
})
|
||||||
|
// <Box>content</Box> renders <div class="Box">content</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Numbers auto-convert to `px` (except `flex`, `opacity`, `zIndex`, `fontWeight`).
|
||||||
|
|
||||||
|
**Set the HTML element:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Button = define('Button', { base: 'button', padding: '8px 16px' })
|
||||||
|
const Link = define('Link', { base: 'a', textDecoration: 'none' })
|
||||||
|
const Input = define('Input', { base: 'input', padding: 8, border: '1px solid #ccc' })
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pseudo-classes (`states`):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Item = define('Item', {
|
||||||
|
padding: 12,
|
||||||
|
states: {
|
||||||
|
':hover': { backgroundColor: '#eee' },
|
||||||
|
':last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nested selectors:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const List = define('List', {
|
||||||
|
selectors: {
|
||||||
|
'& > li:last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Button = define('Button', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '8px 16px',
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: { backgroundColor: '#2563eb', color: 'white' },
|
||||||
|
danger: { backgroundColor: '#dc2626', color: 'white' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// <Button variant="primary">Save</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Serving CSS:**
|
||||||
|
|
||||||
|
Forge generates CSS at runtime. Serve it from a route:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { stylesToCSS } from '@because/forge'
|
||||||
|
|
||||||
|
app.get('/styles.css', c =>
|
||||||
|
c.text(stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then link it in your HTML:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a Tool
|
||||||
|
|
||||||
|
### What's a Tool?
|
||||||
|
|
||||||
|
A tool is an app that appears as a tab inside the Toes dashboard instead of in the sidebar. Tools render in an iframe and receive the currently selected app as a `?app=` query parameter. Good for things like a code editor, log viewer, env manager, or cron scheduler.
|
||||||
|
|
||||||
|
From the server's perspective, a tool is identical to an app — same lifecycle, same health checks, same port allocation. The only differences are in `package.json` and how you render.
|
||||||
|
|
||||||
|
### Tool Setup
|
||||||
|
|
||||||
|
A tool needs three extra things compared to a regular app:
|
||||||
|
|
||||||
|
1. Set `"tool": true` in `package.json`
|
||||||
|
2. Include `<ToolScript />` in the HTML body
|
||||||
|
3. Prepend `baseStyles` to CSS output
|
||||||
|
|
||||||
|
**package.json:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-tool",
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"icon": "🔧",
|
||||||
|
"tool": true
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/toes": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `"tool"` to `true` for a tab labeled with the app name, or to a string for a custom label (e.g., `"tool": ".env"`).
|
||||||
|
|
||||||
|
**index.tsx:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
paddingTop: 0,
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function Layout({ title, children }: { title: string; children: Child }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ToolScript />
|
||||||
|
<Container>{children}</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
app.get('/styles.css', c =>
|
||||||
|
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(<Layout title="My Tool"><p>No app selected</p></Layout>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="My Tool">
|
||||||
|
<h2>{appName}</h2>
|
||||||
|
<p>Tool content for {appName}</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
- `<ToolScript />` handles dark/light mode syncing and iframe height communication with the dashboard.
|
||||||
|
- `baseStyles` sets the body background to match the dashboard theme.
|
||||||
|
- `prettyHTML: false` is recommended for tools since their output is inside an iframe.
|
||||||
|
- The `?app=` query parameter tells you which app the user has selected in the sidebar.
|
||||||
|
|
||||||
|
### Theme Tokens
|
||||||
|
|
||||||
|
Tools should use theme tokens to match the dashboard's look. Import `theme` from `@because/toes/tools`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { theme } from '@because/toes/tools'
|
||||||
|
|
||||||
|
const Card = define('Card', {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
padding: theme('spacing-lg'),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Available tokens:
|
||||||
|
|
||||||
|
| Token | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `colors-bg` | Page background |
|
||||||
|
| `colors-bgSubtle` | Subtle background |
|
||||||
|
| `colors-bgElement` | Element background (cards, inputs) |
|
||||||
|
| `colors-bgHover` | Hover background |
|
||||||
|
| `colors-text` | Primary text |
|
||||||
|
| `colors-textMuted` | Secondary text |
|
||||||
|
| `colors-textFaint` | Tertiary/disabled text |
|
||||||
|
| `colors-border` | Borders |
|
||||||
|
| `colors-link` | Link text |
|
||||||
|
| `colors-primary` | Primary action color |
|
||||||
|
| `colors-primaryText` | Text on primary color |
|
||||||
|
| `colors-error` | Error color |
|
||||||
|
| `colors-dangerBorder` | Danger state border |
|
||||||
|
| `colors-dangerText` | Danger text |
|
||||||
|
| `colors-success` | Success color |
|
||||||
|
| `colors-successBg` | Success background |
|
||||||
|
| `colors-statusRunning` | Running indicator |
|
||||||
|
| `colors-statusStopped` | Stopped indicator |
|
||||||
|
| `fonts-sans` | Sans-serif font stack |
|
||||||
|
| `fonts-mono` | Monospace font stack |
|
||||||
|
| `spacing-xs` | 4px |
|
||||||
|
| `spacing-sm` | 8px |
|
||||||
|
| `spacing-md` | 12px |
|
||||||
|
| `spacing-lg` | 16px |
|
||||||
|
| `spacing-xl` | 24px |
|
||||||
|
| `radius-md` | 6px |
|
||||||
|
|
||||||
|
### Accessing App Data
|
||||||
|
|
||||||
|
**Reading app files:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
app.get('/', c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
if (!appName) return c.html(<p>No app selected</p>)
|
||||||
|
|
||||||
|
const appPath = join(APPS_DIR, appName, 'current')
|
||||||
|
// Read files from appPath...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Always go through the `current` symlink — never access version directories directly.
|
||||||
|
|
||||||
|
**Calling the Toes API:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
|
||||||
|
// List all apps
|
||||||
|
const apps = await fetch(`${TOES_URL}/api/apps`).then(r => r.json())
|
||||||
|
|
||||||
|
// Get a specific app
|
||||||
|
const app = await fetch(`${TOES_URL}/api/apps/${name}`).then(r => r.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linking between tools:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="/tool/code?app=my-app&file=index.tsx">Edit in Code</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool URLs go through `/tool/:name` which redirects to the tool's subdomain with query params preserved.
|
||||||
|
|
||||||
|
**Listening to lifecycle events:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { on } from '@because/toes/tools'
|
||||||
|
|
||||||
|
const unsub = on('app:start', event => {
|
||||||
|
console.log(`${event.app} started at ${event.time}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Event types: 'app:start', 'app:stop', 'app:create', 'app:delete', 'app:activate'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
The CLI connects to your Toes server over HTTP. By default it connects to `http://toes.local`. Set `TOES_URL` to point elsewhere, or set `DEV=1` to use `http://localhost:3000`.
|
||||||
|
|
||||||
|
Most commands accept an optional app name. If omitted, the CLI uses the current directory's `package.json` name.
|
||||||
|
|
||||||
|
### App Management
|
||||||
|
|
||||||
|
**`toes list`** — List all apps and their status.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes list # Show apps and tools
|
||||||
|
toes list --apps # Apps only (exclude tools)
|
||||||
|
toes list --tools # Tools only
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes new [name]`** — Create a new app from a template.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes new my-app # SSR template (default)
|
||||||
|
toes new my-app --bare # Minimal template
|
||||||
|
toes new my-app --spa # SPA template
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates the app locally, then pushes it to the server. If run without a name, scaffolds the current directory.
|
||||||
|
|
||||||
|
**`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime).
|
||||||
|
|
||||||
|
**`toes get <name>`** — Download an app from the server to your local machine.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes get my-app # Creates ./my-app/ with all files
|
||||||
|
cd my-app
|
||||||
|
bun install
|
||||||
|
bun dev # Develop locally
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes open [name]`** — Open a running app in your browser.
|
||||||
|
|
||||||
|
**`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation.
|
||||||
|
|
||||||
|
**`toes rm [name]`** — Permanently delete an app from the server. Requires typing a confirmation.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
**`toes start [name]`** — Start a stopped app.
|
||||||
|
|
||||||
|
**`toes stop [name]`** — Stop a running app.
|
||||||
|
|
||||||
|
**`toes restart [name]`** — Stop and start an app.
|
||||||
|
|
||||||
|
**`toes logs [name]`** — View logs for an app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes logs my-app # Today's logs
|
||||||
|
toes logs my-app -f # Follow (tail) logs in real-time
|
||||||
|
toes logs my-app -d 2026-01-15 # Logs from a specific date
|
||||||
|
toes logs my-app -s 2d # Logs from the last 2 days
|
||||||
|
toes logs my-app -g error # Filter logs by pattern
|
||||||
|
toes logs my-app -f -g error # Follow and filter
|
||||||
|
```
|
||||||
|
|
||||||
|
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
|
||||||
|
|
||||||
|
### Syncing Code
|
||||||
|
|
||||||
|
Toes uses a manifest-based sync protocol. Each file is tracked by SHA-256 hash. The server stores versioned snapshots with timestamps.
|
||||||
|
|
||||||
|
**`toes push`** — Push local changes to the server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes push # Push changes (fails if server changed)
|
||||||
|
toes push --force # Overwrite server changes
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a new version on the server, uploads changed files, deletes removed files, then activates the new version. The app auto-restarts.
|
||||||
|
|
||||||
|
**`toes pull`** — Pull changes from the server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes pull # Pull changes (fails if you have local changes)
|
||||||
|
toes pull --force # Overwrite local changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes status`** — Show what would be pushed or pulled.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes status
|
||||||
|
# Changes to push:
|
||||||
|
# * index.tsx
|
||||||
|
# + new-file.ts
|
||||||
|
# - removed-file.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes diff`** — Show a line-by-line diff of changed files.
|
||||||
|
|
||||||
|
**`toes sync`** — Watch for changes and sync bidirectionally in real-time. Useful during development when editing on the server.
|
||||||
|
|
||||||
|
**`toes clean`** — Remove local files that don't exist on the server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes clean # Interactive confirmation
|
||||||
|
toes clean --force # No confirmation
|
||||||
|
toes clean --dry-run # Show what would be removed
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes stash`** — Stash local changes (like `git stash`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes stash # Save local changes
|
||||||
|
toes stash pop # Restore stashed changes
|
||||||
|
toes stash list # List all stashes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
**`toes env [name]`** — List environment variables for an app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env my-app # List app vars
|
||||||
|
toes env -g # List global vars
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes env set [name] <KEY> [value]`** — Set a variable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env set my-app API_KEY sk-123 # Set for an app
|
||||||
|
toes env set my-app API_KEY=sk-123 # KEY=value format also works
|
||||||
|
toes env set -g API_KEY sk-123 # Set globally (shared by all apps)
|
||||||
|
```
|
||||||
|
|
||||||
|
Setting a variable automatically restarts the app.
|
||||||
|
|
||||||
|
**`toes env rm [name] <KEY>`** — Remove a variable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes env rm my-app API_KEY # Remove from an app
|
||||||
|
toes env rm -g API_KEY # Remove global var
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
Every push creates a timestamped version. The server keeps the last 5 versions.
|
||||||
|
|
||||||
|
**`toes versions [name]`** — List deployed versions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes versions my-app
|
||||||
|
# Versions for my-app:
|
||||||
|
#
|
||||||
|
# → 20260219-143022 2/19/2026, 2:30:22 PM (current)
|
||||||
|
# 20260218-091500 2/18/2026, 9:15:00 AM
|
||||||
|
# 20260217-160845 2/17/2026, 4:08:45 PM
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes history [name]`** — Show file changes between versions.
|
||||||
|
|
||||||
|
**`toes rollback [name]`** — Rollback to a previous version.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes rollback my-app # Interactive version picker
|
||||||
|
toes rollback my-app -v 20260218-091500 # Rollback to specific version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Jobs
|
||||||
|
|
||||||
|
Cron commands talk to the cron tool running on your Toes server.
|
||||||
|
|
||||||
|
**`toes cron [app]`** — List all cron jobs, or jobs for a specific app.
|
||||||
|
|
||||||
|
**`toes cron status <app:name>`** — Show details for a specific job.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes cron status my-app:backup
|
||||||
|
# my-app:backup ok
|
||||||
|
#
|
||||||
|
# Schedule: day
|
||||||
|
# State: idle
|
||||||
|
# Last run: 2h ago
|
||||||
|
# Duration: 3s
|
||||||
|
# Exit code: 0
|
||||||
|
# Next run: in 22h
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes cron run <app:name>`** — Trigger a job immediately.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes cron run my-app:backup
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes cron log [target]`** — View cron logs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes cron log # All cron logs
|
||||||
|
toes cron log my-app # Cron logs for an app
|
||||||
|
toes cron log my-app:backup # Logs for a specific job
|
||||||
|
toes cron log -f # Follow logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
**`toes metrics [name]`** — Show CPU, memory, and disk usage.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes metrics # All apps
|
||||||
|
toes metrics my-app # Single app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sharing
|
||||||
|
|
||||||
|
**`toes share [name]`** — Create a public tunnel to share an app over the internet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes share my-app
|
||||||
|
# ↗ Sharing my-app... https://abc123.trycloudflare.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**`toes unshare [name]`** — Stop sharing an app.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
**`toes config`** — Show the current server URL and sync state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Toes injects these variables into every app process automatically:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `PORT` | Assigned port (3001-3100). Your app must listen on this port. |
|
||||||
|
| `APPS_DIR` | Path to the apps directory on the server. |
|
||||||
|
| `DATA_DIR` | Per-app data directory for persistent storage. |
|
||||||
|
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local:3000`). |
|
||||||
|
| `TOES_DIR` | Path to the Toes config directory. |
|
||||||
|
|
||||||
|
You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set per-app
|
||||||
|
toes env set my-app OPENAI_API_KEY sk-123
|
||||||
|
|
||||||
|
# Set globally (shared by all apps)
|
||||||
|
toes env set -g DATABASE_URL postgres://localhost/mydb
|
||||||
|
```
|
||||||
|
|
||||||
|
Access them in your app:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const apiKey = process.env.OPENAI_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health Checks
|
||||||
|
|
||||||
|
Toes checks `GET /ok` on every app every 30 seconds. Your app must return a 2xx response.
|
||||||
|
|
||||||
|
Three consecutive failures trigger an automatic restart with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s). After 5 restart failures, the app is marked as errored and restart is disabled.
|
||||||
|
|
||||||
|
The simplest health check:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Lifecycle
|
||||||
|
|
||||||
|
Apps move through these states:
|
||||||
|
|
||||||
|
```
|
||||||
|
invalid → stopped → starting → running → stopping → stopped
|
||||||
|
↓
|
||||||
|
error
|
||||||
|
```
|
||||||
|
|
||||||
|
- **invalid** — Missing `package.json` or `scripts.toes`. Fix the config and start manually.
|
||||||
|
- **stopped** — Not running. Start with `toes start` or the dashboard.
|
||||||
|
- **starting** — Process spawned, waiting for `/ok` to return 200. Times out after 30 seconds.
|
||||||
|
- **running** — Healthy and serving requests.
|
||||||
|
- **stopping** — SIGTERM sent, waiting for process to exit. Escalates to SIGKILL after 10 seconds.
|
||||||
|
- **error** — Crashed too many times. Start manually to retry.
|
||||||
|
|
||||||
|
On startup, `bun install` runs automatically before the app's `scripts.toes` command.
|
||||||
|
|
||||||
|
Apps are accessed via subdomain: `http://my-app.toes.local` or `http://my-app.localhost`. The Toes server proxies requests to the app's assigned port.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cron Jobs
|
||||||
|
|
||||||
|
Place TypeScript files in a `cron/` directory inside your app:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// cron/daily-cleanup.ts
|
||||||
|
export const schedule = "day"
|
||||||
|
|
||||||
|
export default async function() {
|
||||||
|
console.log("Running daily cleanup")
|
||||||
|
// Your job logic here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The cron tool auto-discovers jobs by scanning `cron/*.ts` in all apps. New jobs are picked up within 60 seconds.
|
||||||
|
|
||||||
|
### Schedules
|
||||||
|
|
||||||
|
| Value | When |
|
||||||
|
|-------|------|
|
||||||
|
| `1 minute` | Every minute |
|
||||||
|
| `5 minutes` | Every 5 minutes |
|
||||||
|
| `15 minutes` | Every 15 minutes |
|
||||||
|
| `30 minutes` | Every 30 minutes |
|
||||||
|
| `hour` | Top of every hour |
|
||||||
|
| `noon` | 12:00 daily |
|
||||||
|
| `midnight` / `day` | 00:00 daily |
|
||||||
|
| `week` / `sunday` | 00:00 Sunday |
|
||||||
|
| `monday` - `saturday` | 00:00 on that day |
|
||||||
|
|
||||||
|
Jobs inherit the app's working directory and all environment variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
Use the filesystem for data storage. The `DATA_DIR` environment variable points to a per-app directory that persists across deployments and restarts:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { join } from 'path'
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR!
|
||||||
|
|
||||||
|
function loadData(): MyData {
|
||||||
|
const path = join(DATA_DIR, 'data.json')
|
||||||
|
if (!existsSync(path)) return { items: [] }
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveData(data: MyData) {
|
||||||
|
writeFileSync(join(DATA_DIR, 'data.json'), JSON.stringify(data, null, 2))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`DATA_DIR` is separate from your app's code directory, so pushes and rollbacks won't affect stored data.
|
||||||
149
docs/TAILSCALE.md
Normal file
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.
|
||||||
86
docs/TOOLS.md
Normal file
86
docs/TOOLS.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Tools
|
||||||
|
|
||||||
|
A tool is an app that appears as a tab in the dashboard instead of in the sidebar.
|
||||||
|
|
||||||
|
Tools know which app is selected and render in an iframe over the content area.
|
||||||
|
|
||||||
|
## making an app a tool
|
||||||
|
|
||||||
|
Add `toes.tool` to package.json:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"toes": {
|
||||||
|
"tool": true,
|
||||||
|
"icon": "🔧"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## getting the selected app
|
||||||
|
|
||||||
|
Query param `?app=<name>` tells you which app the user selected:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
app.get('/', c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(<p>No app selected</p>)
|
||||||
|
}
|
||||||
|
// do something with appName
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## environment
|
||||||
|
|
||||||
|
- `PORT` - your assigned port
|
||||||
|
- `APPS_DIR` - path to `/apps` directory
|
||||||
|
- `DATA_DIR` - per-app data directory (`toes/<tool-name>/`)
|
||||||
|
- `TOES_URL` - base URL of the Toes server
|
||||||
|
- `TOES_DIR` - path to the toes config directory
|
||||||
|
|
||||||
|
## accessing app files
|
||||||
|
|
||||||
|
Always go through the `current` symlink:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
const appPath = join(APPS_DIR, appName, 'current')
|
||||||
|
```
|
||||||
|
|
||||||
|
Not `APPS_DIR/appName` directly.
|
||||||
|
|
||||||
|
## linking to tools
|
||||||
|
|
||||||
|
Use `/tool/:name` URLs to link directly to tools with params:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="/tool/code?app=my-app&version=20260130-000000">
|
||||||
|
View in Code
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
## resize iframe
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'resize-iframe',
|
||||||
|
height: 500
|
||||||
|
}, '*')
|
||||||
|
```
|
||||||
|
|
||||||
|
## iframe behavior
|
||||||
|
|
||||||
|
- iframes are cached per tool+app combination
|
||||||
|
- Never recreated once loaded
|
||||||
|
- State persists across tab switches
|
||||||
|
|
||||||
|
## cli
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes list --tools # list tools only
|
||||||
|
toes list --all # list apps and tools
|
||||||
|
```
|
||||||
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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
123
install/install.sh
Normal file
123
install/install.sh
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
##
|
||||||
|
# toes installer
|
||||||
|
# Usage: curl -sSL https://toes.dev/install | bash
|
||||||
|
# Must be run as the 'toes' user.
|
||||||
|
|
||||||
|
DEST=~/toes
|
||||||
|
REPO="https://git.nose.space/defunkt/toes"
|
||||||
|
|
||||||
|
quiet() { "$@" > /dev/null 2>&1; }
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " ╔══════════════════════════════════╗"
|
||||||
|
echo " ║ 🐾 toes - personal web appliance ║"
|
||||||
|
echo " ╚══════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Must be running as toes
|
||||||
|
if [ "$(whoami)" != "toes" ]; then
|
||||||
|
echo "ERROR: This script must be run as the 'toes' user."
|
||||||
|
echo "Create the user during Raspberry Pi OS setup."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Must have passwordless sudo (can't prompt when piped from curl)
|
||||||
|
if ! sudo -n true 2>/dev/null; then
|
||||||
|
echo "ERROR: This script requires passwordless sudo."
|
||||||
|
echo "On Raspberry Pi OS, the default user has this already."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- System packages --
|
||||||
|
|
||||||
|
echo ">> Updating system packages"
|
||||||
|
quiet sudo apt-get update
|
||||||
|
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
|
||||||
|
|
||||||
|
echo ">> Setting fish as default shell"
|
||||||
|
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
|
||||||
|
quiet sudo chsh -s /usr/bin/fish toes
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Bun --
|
||||||
|
|
||||||
|
BUN_REAL="$HOME/.bun/bin/bun"
|
||||||
|
BUN_SYMLINK="/usr/local/bin/bun"
|
||||||
|
|
||||||
|
if [ ! -x "$BUN_REAL" ]; then
|
||||||
|
echo ">> Installing bun"
|
||||||
|
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
|
||||||
|
if [ ! -x "$BUN_REAL" ]; then
|
||||||
|
echo "ERROR: bun installation failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$BUN_SYMLINK" ]; then
|
||||||
|
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> Setting CAP_NET_BIND_SERVICE on bun"
|
||||||
|
sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
|
||||||
|
|
||||||
|
# -- Clone --
|
||||||
|
|
||||||
|
if [ ! -d "$DEST" ]; then
|
||||||
|
echo ">> Cloning toes"
|
||||||
|
git clone "$REPO" "$DEST"
|
||||||
|
else
|
||||||
|
echo ">> Updating toes"
|
||||||
|
cd "$DEST" && git pull origin main
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Directories --
|
||||||
|
|
||||||
|
mkdir -p ~/data ~/apps
|
||||||
|
|
||||||
|
# -- Dependencies & build --
|
||||||
|
|
||||||
|
echo ">> Installing dependencies"
|
||||||
|
cd "$DEST" && bun install
|
||||||
|
|
||||||
|
echo ">> Building client"
|
||||||
|
cd "$DEST" && bun run build
|
||||||
|
|
||||||
|
# -- Bundled apps --
|
||||||
|
|
||||||
|
echo ">> Installing bundled apps"
|
||||||
|
BUNDLED_APPS="clock code cron env stats versions"
|
||||||
|
for app in $BUNDLED_APPS; do
|
||||||
|
if [ -d "$DEST/apps/$app" ]; then
|
||||||
|
if [ -d ~/apps/"$app" ]; then
|
||||||
|
echo " $app (exists, skipping)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo " $app"
|
||||||
|
cp -r "$DEST/apps/$app" ~/apps/
|
||||||
|
version_dir=$(ls -1 ~/apps/"$app" | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
|
||||||
|
if [ -n "$version_dir" ]; then
|
||||||
|
ln -sfn "$version_dir" ~/apps/"$app"/current
|
||||||
|
(cd ~/apps/"$app"/current && bun install --frozen-lockfile) > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- Systemd --
|
||||||
|
|
||||||
|
echo ">> Installing toes service"
|
||||||
|
sudo install -m 644 -o root -g root "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable toes
|
||||||
|
|
||||||
|
echo ">> Starting toes"
|
||||||
|
sudo systemctl restart toes
|
||||||
|
|
||||||
|
# -- Done --
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " toes is installed and running!"
|
||||||
|
echo " Visit: http://$(hostname).local"
|
||||||
|
echo ""
|
||||||
16
install/package.json
Normal file
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
install/server.ts
Normal file
17
install/server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { resolve } from "path"
|
||||||
|
|
||||||
|
const script = await Bun.file(resolve(import.meta.dir, "install.sh")).text()
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
port: parseInt(process.env.PORT || "3000"),
|
||||||
|
fetch(req) {
|
||||||
|
if (new URL(req.url).pathname === "/install") {
|
||||||
|
return new Response(script, {
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return new Response("404 Not Found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)
|
||||||
43
install/tsconfig.json
Normal file
43
install/tsconfig.json
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"exclude": ["apps", "templates"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": [
|
||||||
|
"ESNext",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": [
|
||||||
|
"./src/server/*"
|
||||||
|
],
|
||||||
|
"@*": [
|
||||||
|
"./src/shared/*"
|
||||||
|
],
|
||||||
|
"%*": [
|
||||||
|
"./src/lib/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
package.json
23
package.json
|
|
@ -1,21 +1,33 @@
|
||||||
{
|
{
|
||||||
"name": "@because/toes",
|
"name": "@because/toes",
|
||||||
"version": "0.0.3",
|
"version": "0.0.8",
|
||||||
"description": "personal web appliance - turn it on and forget about the cloud",
|
"description": "personal web appliance - turn it on and forget about the cloud",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./tools": "./src/tools/index.ts"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toes": "src/cli/index.ts"
|
"toes": "src/cli/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"check": "bunx tsc --noEmit",
|
||||||
"build": "./scripts/build.sh",
|
"build": "./scripts/build.sh",
|
||||||
"cli:build": "bun run scripts/build.ts",
|
"cli:build": "bun run scripts/build.ts",
|
||||||
"cli:build:all": "bun run scripts/build.ts --all",
|
"cli:build:all": "bun run scripts/build.ts --all",
|
||||||
"cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin",
|
"cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin",
|
||||||
|
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes",
|
||||||
"cli:uninstall": "sudo rm /usr/local/bin",
|
"cli:uninstall": "sudo rm /usr/local/bin",
|
||||||
"deploy": "./scripts/deploy.sh",
|
"deploy": "./scripts/deploy.sh",
|
||||||
"dev": "bun run --hot src/server/index.tsx",
|
"debug": "DEBUG=1 bun run dev",
|
||||||
|
"dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
|
||||||
|
"remote:deploy": "./scripts/deploy.sh",
|
||||||
"remote:install": "./scripts/remote-install.sh",
|
"remote:install": "./scripts/remote-install.sh",
|
||||||
|
"remote:logs": "./scripts/remote-logs.sh",
|
||||||
"remote:restart": "./scripts/remote-restart.sh",
|
"remote:restart": "./scripts/remote-restart.sh",
|
||||||
"remote:start": "./scripts/remote-start.sh",
|
"remote:start": "./scripts/remote-start.sh",
|
||||||
"remote:stop": "./scripts/remote-stop.sh",
|
"remote:stop": "./scripts/remote-stop.sh",
|
||||||
|
|
@ -27,12 +39,13 @@
|
||||||
"@types/diff": "^8.0.0"
|
"@types/diff": "^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.1",
|
"@because/hype": "^0.0.2",
|
||||||
"commander": "^14.0.2",
|
"@because/sneaker": "^0.0.3",
|
||||||
|
"commander": "^14.0.3",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
// Builds the self-contained CLI executable
|
||||||
// Usage: bun scripts/build.ts [--all | --target=<name>]
|
// Usage: bun scripts/build.ts [--all | --target=<name>]
|
||||||
// No flags: builds for current platform (dist/toes)
|
// No flags: builds for current platform (dist/toes)
|
||||||
// --all: builds for all targets (macos-arm64, macos-x64, linux-arm64, linux-x64)
|
// --all: builds for all targets (macos-arm64, macos-x64, linux-arm64, linux-x64)
|
||||||
|
|
@ -43,7 +44,7 @@ async function buildTarget(target: BuildTarget) {
|
||||||
ENTRY_POINT,
|
ENTRY_POINT,
|
||||||
'--compile',
|
'--compile',
|
||||||
'--target',
|
'--target',
|
||||||
'bun',
|
`bun-${target.os}-${target.arch}`,
|
||||||
'--minify',
|
'--minify',
|
||||||
'--sourcemap=external',
|
'--sourcemap=external',
|
||||||
'--outfile',
|
'--outfile',
|
||||||
|
|
@ -51,11 +52,6 @@ async function buildTarget(target: BuildTarget) {
|
||||||
], {
|
], {
|
||||||
stdout: 'inherit',
|
stdout: 'inherit',
|
||||||
stderr: 'inherit',
|
stderr: 'inherit',
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_TARGET_OS: target.os,
|
|
||||||
BUN_TARGET_ARCH: target.arch,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const exitCode = await proc.exited
|
const exitCode = await proc.exited
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
|
|
||||||
# It isn't enough to modify this yet.
|
# It isn't enough to modify this yet.
|
||||||
# You also need to manually update the toes.service file.
|
# You also need to manually update the toes.service file.
|
||||||
HOST="${HOST:-toes@toes.local}"
|
TOES_USER="${TOES_USER:-toes}"
|
||||||
URL="${URL:-http://toes.local}"
|
HOST="${HOST:-toes.local}"
|
||||||
DEST="${DEST:-~/.toes}"
|
SSH_HOST="$TOES_USER@$HOST"
|
||||||
APPS_DIR="${APPS_DIR:-~/apps}"
|
URL="${URL:-http://$HOST}"
|
||||||
|
DEST="${DEST:-$HOME/toes}"
|
||||||
|
DATA_DIR="${DATA_DIR:-$HOME/data}"
|
||||||
|
APPS_DIR="${APPS_DIR:-$HOME/apps}"
|
||||||
|
|
||||||
|
mkdir -p "$DEST" "$DATA_DIR" "$APPS_DIR"
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,35 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
# Load config
|
# Load config
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
# Make sure we're up-to-date
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo "=> You have unsaved (git) changes"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
||||||
# SSH to target and update
|
# SSH to target: pull, build, sync apps, restart
|
||||||
ssh "$HOST" "cd $DEST && git pull origin main && bun run build && sudo systemctl restart toes.service"
|
ssh "$SSH_HOST" bash <<'SCRIPT'
|
||||||
|
set -e
|
||||||
|
|
||||||
echo "=> Deployed to $HOST"
|
DEST="${DEST:-$HOME/toes}"
|
||||||
|
APPS_DIR="${APPS_DIR:-$HOME/apps}"
|
||||||
|
|
||||||
|
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
|
||||||
|
|
||||||
|
echo "=> Syncing default apps..."
|
||||||
|
for app_dir in "$DEST"/apps/*/; do
|
||||||
|
app=$(basename "$app_dir")
|
||||||
|
for version_dir in "$app_dir"*/; do
|
||||||
|
[ -d "$version_dir" ] || continue
|
||||||
|
version=$(basename "$version_dir")
|
||||||
|
[ -f "$version_dir/package.json" ] || continue
|
||||||
|
target="$APPS_DIR/$app/$version"
|
||||||
|
mkdir -p "$target"
|
||||||
|
cp -a "$version_dir"/. "$target"/
|
||||||
|
rm -f "$APPS_DIR/$app/current"
|
||||||
|
echo " $app/$version"
|
||||||
|
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
sudo systemctl restart toes.service
|
||||||
|
SCRIPT
|
||||||
|
|
||||||
|
echo "=> Deployed to $SSH_HOST"
|
||||||
echo "=> Visit $URL"
|
echo "=> Visit $URL"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@ echo ">> Updating system libraries"
|
||||||
quiet sudo apt-get update
|
quiet sudo apt-get update
|
||||||
quiet sudo apt-get install -y libcap2-bin
|
quiet sudo apt-get install -y libcap2-bin
|
||||||
quiet sudo apt-get install -y avahi-utils
|
quiet sudo apt-get install -y avahi-utils
|
||||||
|
quiet sudo apt-get install -y fish
|
||||||
|
|
||||||
|
echo ">> Setting fish as default shell for toes user"
|
||||||
|
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
|
||||||
|
quiet sudo chsh -s /usr/bin/fish toes
|
||||||
|
echo "Default shell changed to fish"
|
||||||
|
else
|
||||||
|
echo "fish already set as default shell"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ">> Ensuring bun is available in /usr/local/bin"
|
echo ">> Ensuring bun is available in /usr/local/bin"
|
||||||
if [ ! -x "$BUN_SYMLINK" ]; then
|
if [ ! -x "$BUN_SYMLINK" ]; then
|
||||||
|
|
@ -28,7 +37,11 @@ if [ ! -x "$BUN_SYMLINK" ]; then
|
||||||
else
|
else
|
||||||
echo ">> Installing bun at $BUN_REAL"
|
echo ">> Installing bun at $BUN_REAL"
|
||||||
quiet sudo apt install unzip
|
quiet sudo apt install unzip
|
||||||
quiet curl -fsSL https://bun.sh/install | bash
|
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
|
||||||
|
if [ ! -x "$BUN_REAL" ]; then
|
||||||
|
echo "ERROR: bun installation failed - $BUN_REAL not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
||||||
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
||||||
fi
|
fi
|
||||||
|
|
@ -40,9 +53,30 @@ echo ">> Setting CAP_NET_BIND_SERVICE on $BUN_REAL"
|
||||||
quiet sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
|
quiet sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
|
||||||
quiet /usr/sbin/getcap "$BUN_REAL" || true
|
quiet /usr/sbin/getcap "$BUN_REAL" || true
|
||||||
|
|
||||||
echo ">> Creating apps directory"
|
echo ">> Creating data and apps directories"
|
||||||
|
mkdir -p ~/data
|
||||||
mkdir -p ~/apps
|
mkdir -p ~/apps
|
||||||
|
|
||||||
|
echo ">> Installing bundled apps"
|
||||||
|
BUNDLED_APPS="clock code cron env stats versions"
|
||||||
|
for app in $BUNDLED_APPS; do
|
||||||
|
if [ -d "apps/$app" ]; then
|
||||||
|
echo " Installing $app..."
|
||||||
|
# Copy app to ~/apps
|
||||||
|
cp -r "apps/$app" ~/apps/
|
||||||
|
# Find the version directory and create current symlink
|
||||||
|
version_dir=$(ls -1 ~/apps/$app | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
|
||||||
|
if [ -n "$version_dir" ]; then
|
||||||
|
ln -sfn "$version_dir" ~/apps/$app/current
|
||||||
|
# Install dependencies
|
||||||
|
(cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ">> Installing dependencies"
|
||||||
|
bun install
|
||||||
|
|
||||||
echo ">> Building client bundle"
|
echo ">> Building client bundle"
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
|
|
@ -59,13 +93,32 @@ echo ">> Starting (or restarting) $SERVICE_NAME"
|
||||||
quiet sudo systemctl restart "$SERVICE_NAME"
|
quiet sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
echo ">> Enabling kiosk mode"
|
echo ">> Enabling kiosk mode"
|
||||||
quiet mkdir -p ~/.config/labwc
|
sudo raspi-config nonint do_boot_behaviour B4
|
||||||
|
|
||||||
|
# labwc (older RPi OS / manual installs)
|
||||||
|
mkdir -p ~/.config/labwc
|
||||||
cat > ~/.config/labwc/autostart <<'EOF'
|
cat > ~/.config/labwc/autostart <<'EOF'
|
||||||
chromium-browser --noerrdialogs --disable-infobars --kiosk http://localhost
|
chromium --noerrdialogs --disable-infobars --kiosk http://localhost
|
||||||
EOF
|
EOF
|
||||||
|
# Wayfire (RPi OS Bookworm default)
|
||||||
|
WAYFIRE_CONFIG="$HOME/.config/wayfire.ini"
|
||||||
|
if [ -f "$WAYFIRE_CONFIG" ]; then
|
||||||
|
# Remove existing chromium autostart if present
|
||||||
|
sed -i '/^chromium = /d' "$WAYFIRE_CONFIG"
|
||||||
|
# Add to existing [autostart] section or create it
|
||||||
|
if grep -q '^\[autostart\]' "$WAYFIRE_CONFIG"; then
|
||||||
|
sed -i '/^\[autostart\]/a chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost' "$WAYFIRE_CONFIG"
|
||||||
|
else
|
||||||
|
cat >> "$WAYFIRE_CONFIG" <<'EOF'
|
||||||
|
|
||||||
|
[autostart]
|
||||||
|
chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo ">> Done! Rebooting in 5 seconds..."
|
echo ">> Done! Rebooting in 5 seconds..."
|
||||||
quiet systemctl status "$SERVICE_NAME" --no-pager -l
|
quiet systemctl status "$SERVICE_NAME" --no-pager -l || true
|
||||||
sleep 5
|
sleep 5
|
||||||
quiet sudo nohup reboot >/dev/null 2>&1 &
|
quiet sudo nohup reboot >/dev/null 2>&1 &
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
# Run remote install on the target
|
# Run remote install on the target
|
||||||
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
ssh "$SSH_HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
||||||
|
|
|
||||||
9
scripts/remote-logs.sh
Executable file
9
scripts/remote-logs.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
|
ssh "$SSH_HOST" "journalctl -u toes -n 100"
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl restart toes.service"
|
ssh "$SSH_HOST" "sudo systemctl restart toes.service"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl start toes.service"
|
ssh "$SSH_HOST" "sudo systemctl start toes.service"
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
source "$ROOT_DIR/scripts/config.sh"
|
source "$ROOT_DIR/scripts/config.sh"
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl stop toes.service"
|
ssh "$SSH_HOST" "sudo systemctl stop toes.service"
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=toes
|
User=toes
|
||||||
WorkingDirectory=/home/toes/.toes/
|
WorkingDirectory=/home/toes/toes/
|
||||||
Environment=PORT=80
|
Environment=PORT=80
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
|
Environment=DATA_DIR=/home/toes/data
|
||||||
Environment=APPS_DIR=/home/toes/apps/
|
Environment=APPS_DIR=/home/toes/apps/
|
||||||
ExecStart=/home/toes/.bun/bin/bun start
|
ExecStart=/home/toes/.bun/bin/bun start
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
|
||||||
227
src/cli/commands/cron.ts
Normal file
227
src/cli/commands/cron.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import type { LogLine } from '@types'
|
||||||
|
import color from 'kleur'
|
||||||
|
import { get, handleError, makeUrl, post } from '../http'
|
||||||
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
|
interface CronJobSummary {
|
||||||
|
app: string
|
||||||
|
name: string
|
||||||
|
schedule: string
|
||||||
|
state: string
|
||||||
|
status: string
|
||||||
|
lastRun?: number
|
||||||
|
lastDuration?: number
|
||||||
|
lastExitCode?: number
|
||||||
|
nextRun?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CronJobDetail extends CronJobSummary {
|
||||||
|
lastError?: string
|
||||||
|
lastOutput?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(ts?: number): string {
|
||||||
|
if (!ts) return '-'
|
||||||
|
const diff = Date.now() - ts
|
||||||
|
if (diff < 0) {
|
||||||
|
const mins = Math.round(-diff / 60000)
|
||||||
|
if (mins < 60) return `in ${mins}m`
|
||||||
|
const hours = Math.round(mins / 60)
|
||||||
|
if (hours < 24) return `in ${hours}h`
|
||||||
|
return `in ${Math.round(hours / 24)}d`
|
||||||
|
}
|
||||||
|
const mins = Math.round(diff / 60000)
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hours = Math.round(mins / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
return `${Math.round(hours / 24)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms?: number): string {
|
||||||
|
if (!ms) return '-'
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
if (ms < 60000) return `${Math.round(ms / 1000)}s`
|
||||||
|
return `${Math.round(ms / 60000)}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(str: string, len: number, right = false): string {
|
||||||
|
if (right) return str.padStart(len)
|
||||||
|
return str.padEnd(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string): (s: string) => string {
|
||||||
|
if (status === 'running') return color.green
|
||||||
|
if (status === 'ok') return color.green
|
||||||
|
if (status === 'idle') return color.gray
|
||||||
|
return color.red
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJobArg(arg: string): { app: string; name: string } | undefined {
|
||||||
|
const parts = arg.split(':')
|
||||||
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||||
|
console.error(`Invalid job format: ${arg}`)
|
||||||
|
console.error('Use app:name format (e.g., myapp:backup)')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return { app: parts[0]!, name: parts[1]! }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cronList(app?: string) {
|
||||||
|
const appName = app ? resolveAppName(app) : undefined
|
||||||
|
if (app && !appName) return
|
||||||
|
|
||||||
|
const url = appName
|
||||||
|
? `/api/tools/cron/api/jobs?app=${appName}`
|
||||||
|
: '/api/tools/cron/api/jobs'
|
||||||
|
|
||||||
|
const jobs = await get<CronJobSummary[]>(url)
|
||||||
|
if (!jobs || jobs.length === 0) {
|
||||||
|
console.log('No cron jobs found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobWidth = Math.max(3, ...jobs.map(j => `${j.app}:${j.name}`.length))
|
||||||
|
const schedWidth = Math.max(8, ...jobs.map(j => String(j.schedule).length))
|
||||||
|
const statusWidth = Math.max(6, ...jobs.map(j => j.status.length))
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
color.gray(
|
||||||
|
`${pad('JOB', jobWidth)} ${pad('SCHEDULE', schedWidth)} ${pad('STATUS', statusWidth)} ${pad('LAST RUN', 10)} ${pad('NEXT RUN', 10)}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const j of jobs) {
|
||||||
|
const id = `${j.app}:${j.name}`
|
||||||
|
const colorFn = statusColor(j.status)
|
||||||
|
console.log(
|
||||||
|
`${pad(id, jobWidth)} ${pad(String(j.schedule), schedWidth)} ${colorFn(pad(j.status, statusWidth))} ${pad(formatRelative(j.lastRun), 10)} ${pad(formatRelative(j.nextRun), 10)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cronStatus(arg: string) {
|
||||||
|
const parsed = parseJobArg(arg)
|
||||||
|
if (!parsed) return
|
||||||
|
|
||||||
|
const job = await get<CronJobDetail>(`/api/tools/cron/api/jobs/${parsed.app}/${parsed.name}`)
|
||||||
|
if (!job) return
|
||||||
|
|
||||||
|
const colorFn = statusColor(job.status)
|
||||||
|
|
||||||
|
console.log(`${color.bold(`${job.app}:${job.name}`)} ${colorFn(job.status)}`)
|
||||||
|
console.log()
|
||||||
|
console.log(` Schedule: ${job.schedule}`)
|
||||||
|
console.log(` State: ${job.state}`)
|
||||||
|
console.log(` Last run: ${formatRelative(job.lastRun)}`)
|
||||||
|
console.log(` Duration: ${formatDuration(job.lastDuration)}`)
|
||||||
|
if (job.lastExitCode !== undefined) {
|
||||||
|
console.log(` Exit code: ${job.lastExitCode === 0 ? color.green('0') : color.red(String(job.lastExitCode))}`)
|
||||||
|
}
|
||||||
|
console.log(` Next run: ${formatRelative(job.nextRun)}`)
|
||||||
|
|
||||||
|
if (job.lastError) {
|
||||||
|
console.log()
|
||||||
|
console.log(color.red('Error:'))
|
||||||
|
console.log(job.lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.lastOutput) {
|
||||||
|
console.log()
|
||||||
|
console.log(color.gray('Output:'))
|
||||||
|
console.log(job.lastOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cronLog(arg?: string, options?: { follow?: boolean }) {
|
||||||
|
// No arg: show the cron tool's own logs
|
||||||
|
// "myapp": show myapp's logs filtered to [cron entries
|
||||||
|
// "myapp:backup": show myapp's logs filtered to [cron:backup]
|
||||||
|
const follow = options?.follow ?? false
|
||||||
|
|
||||||
|
if (!arg) {
|
||||||
|
// Show cron tool's own logs
|
||||||
|
if (follow) {
|
||||||
|
await tailCronLogs('cron')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const logs = await get<LogLine[]>('/api/apps/cron/logs')
|
||||||
|
if (!logs || logs.length === 0) {
|
||||||
|
console.log('No cron logs yet')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const line of logs) printCronLog(line)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse arg — could be "myapp" or "myapp:backup"
|
||||||
|
const colon = arg.indexOf(':')
|
||||||
|
const appName = colon >= 0 ? arg.slice(0, colon) : arg
|
||||||
|
const jobName = colon >= 0 ? arg.slice(colon + 1) : undefined
|
||||||
|
const grepPrefix = jobName ? `[cron:${jobName}]` : '[cron'
|
||||||
|
|
||||||
|
const resolved = resolveAppName(appName)
|
||||||
|
if (!resolved) return
|
||||||
|
|
||||||
|
if (follow) {
|
||||||
|
await tailCronLogs(resolved, grepPrefix)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await get<LogLine[]>(`/api/apps/${resolved}/logs`)
|
||||||
|
if (!logs || logs.length === 0) {
|
||||||
|
console.log('No cron logs yet')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const line of logs) {
|
||||||
|
if (line.text.includes(grepPrefix)) printCronLog(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cronRun(arg: string) {
|
||||||
|
const parsed = parseJobArg(arg)
|
||||||
|
if (!parsed) return
|
||||||
|
|
||||||
|
const result = await post<{ ok: boolean; message: string; error?: string }>(
|
||||||
|
`/api/tools/cron/api/jobs/${parsed.app}/${parsed.name}/run`
|
||||||
|
)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
console.log(color.green(result.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
const printCronLog = (line: LogLine) =>
|
||||||
|
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
|
||||||
|
|
||||||
|
async function tailCronLogs(app: string, grep?: string) {
|
||||||
|
try {
|
||||||
|
const url = makeUrl(`/api/apps/${app}/logs/stream`)
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`App not found: ${app}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.body) return
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n\n')
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = JSON.parse(line.slice(6)) as LogLine
|
||||||
|
if (!grep || data.text.includes(grep)) printCronLog(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/cli/commands/env.ts
Normal file
153
src/cli/commands/env.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import color from 'kleur'
|
||||||
|
import { del, get, handleError, post } from '../http'
|
||||||
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
|
interface EnvVar {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeyValue(keyOrKeyValue: string, valueArg?: string): { key: string, value: string } | null {
|
||||||
|
if (valueArg !== undefined) {
|
||||||
|
const key = keyOrKeyValue.trim()
|
||||||
|
if (!key) { console.error('Key cannot be empty'); return null }
|
||||||
|
return { key, value: valueArg }
|
||||||
|
}
|
||||||
|
const eqIndex = keyOrKeyValue.indexOf('=')
|
||||||
|
if (eqIndex === -1) {
|
||||||
|
console.error('Invalid format. Use: KEY value or KEY=value')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const key = keyOrKeyValue.slice(0, eqIndex).trim()
|
||||||
|
if (!key) { console.error('Key cannot be empty'); return null }
|
||||||
|
return { key, value: keyOrKeyValue.slice(eqIndex + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function globalEnvSet(keyOrKeyValue: string, valueArg?: string) {
|
||||||
|
const parsed = parseKeyValue(keyOrKeyValue, valueArg)
|
||||||
|
if (!parsed) return
|
||||||
|
const { key, value } = parsed
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await post<{ ok: boolean, error?: string }>('/api/env', { key, value })
|
||||||
|
if (result?.ok) {
|
||||||
|
console.log(color.green(`Set global ${color.bold(key.toUpperCase())}`))
|
||||||
|
} else {
|
||||||
|
console.error(result?.error ?? 'Failed to set variable')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function envList(name: string | undefined, opts: { global?: boolean }) {
|
||||||
|
if (opts.global) {
|
||||||
|
const vars = await get<EnvVar[]>('/api/env')
|
||||||
|
console.log(color.bold().cyan('Global Environment Variables'))
|
||||||
|
console.log()
|
||||||
|
if (!vars || vars.length === 0) {
|
||||||
|
console.log(color.gray(' No global environment variables set'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const v of vars) {
|
||||||
|
console.log(` ${color.bold(v.key)}=${color.gray(v.value)}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = resolveAppName(name)
|
||||||
|
if (!appName) return
|
||||||
|
|
||||||
|
const [vars, globalVars] = await Promise.all([
|
||||||
|
get<EnvVar[]>(`/api/apps/${appName}/env`),
|
||||||
|
get<EnvVar[]>('/api/env'),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!vars) {
|
||||||
|
console.error(`App not found: ${appName}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(color.bold().cyan(`Environment Variables for ${appName}`))
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
const appKeys = new Set(vars.map(v => v.key))
|
||||||
|
|
||||||
|
if (vars.length === 0 && (!globalVars || globalVars.length === 0)) {
|
||||||
|
console.log(color.gray(' No environment variables set'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of vars) {
|
||||||
|
console.log(` ${color.bold(v.key)}=${color.gray(v.value)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalVars && globalVars.length > 0) {
|
||||||
|
const inherited = globalVars.filter(v => !appKeys.has(v.key))
|
||||||
|
if (inherited.length > 0) {
|
||||||
|
if (vars.length > 0) console.log()
|
||||||
|
console.log(color.gray(' Inherited from global:'))
|
||||||
|
for (const v of inherited) {
|
||||||
|
console.log(` ${color.bold(v.key)}=${color.gray(v.value)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function envSet(name: string | undefined, keyOrKeyValue: string, valueArg: string | undefined, opts: { global?: boolean }) {
|
||||||
|
// With --global, args shift: name becomes key, key becomes value
|
||||||
|
if (opts.global) {
|
||||||
|
const actualKey = name ?? keyOrKeyValue
|
||||||
|
const actualValue = name ? keyOrKeyValue : valueArg
|
||||||
|
return globalEnvSet(actualKey, actualValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseKeyValue(keyOrKeyValue, valueArg)
|
||||||
|
if (!parsed) return
|
||||||
|
const { key, value } = parsed
|
||||||
|
|
||||||
|
const appName = resolveAppName(name)
|
||||||
|
if (!appName) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await post<{ ok: boolean, error?: string }>(`/api/apps/${appName}/env`, { key, value })
|
||||||
|
if (result?.ok) {
|
||||||
|
console.log(color.green(`Set ${color.bold(key.toUpperCase())} for ${appName}`))
|
||||||
|
console.log(color.gray('App restarted to apply changes'))
|
||||||
|
} else {
|
||||||
|
console.error(result?.error ?? 'Failed to set variable')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function envRm(name: string | undefined, key: string, opts: { global?: boolean }) {
|
||||||
|
// With --global, args shift: name becomes key
|
||||||
|
if (opts.global) {
|
||||||
|
const actualKey = name ?? key
|
||||||
|
if (!actualKey) {
|
||||||
|
console.error('Key is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ok = await del(`/api/env/${actualKey.toUpperCase()}`)
|
||||||
|
if (ok) {
|
||||||
|
console.log(color.green(`Removed global ${color.bold(actualKey.toUpperCase())}`))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
console.error('Key is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = resolveAppName(name)
|
||||||
|
if (!appName) return
|
||||||
|
|
||||||
|
const ok = await del(`/api/apps/${appName}/env/${key.toUpperCase()}`)
|
||||||
|
if (ok) {
|
||||||
|
console.log(color.green(`Removed ${color.bold(key.toUpperCase())} from ${appName}`))
|
||||||
|
console.log(color.gray('App restarted to apply changes'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
export { cronList, cronLog, cronRun, cronStatus } from './cron'
|
||||||
|
export { envList, envRm, envSet } from './env'
|
||||||
export { logApp } from './logs'
|
export { logApp } from './logs'
|
||||||
export {
|
export {
|
||||||
configShow,
|
configShow,
|
||||||
|
|
@ -8,7 +10,10 @@ export {
|
||||||
renameApp,
|
renameApp,
|
||||||
restartApp,
|
restartApp,
|
||||||
rmApp,
|
rmApp,
|
||||||
|
shareApp,
|
||||||
startApp,
|
startApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
|
unshareApp,
|
||||||
} from './manage'
|
} from './manage'
|
||||||
export { diffApp, getApp, pullApp, pushApp, statusApp, syncApp } from './sync'
|
export { metricsApp } from './metrics'
|
||||||
|
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,107 @@ import type { LogLine } from '@types'
|
||||||
import { get, handleError, makeUrl } from '../http'
|
import { get, handleError, makeUrl } from '../http'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
export const printLog = (line: LogLine) =>
|
interface LogOptions {
|
||||||
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
|
date?: string
|
||||||
|
follow?: boolean
|
||||||
|
grep?: string
|
||||||
|
since?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function logApp(arg: string | undefined, options: { follow?: boolean }) {
|
const formatDate = (date: Date) =>
|
||||||
|
date.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const matchesGrep = (text: string, pattern: string) =>
|
||||||
|
text.toLowerCase().includes(pattern.toLowerCase())
|
||||||
|
|
||||||
|
const parseDuration = (duration: string): number | null => {
|
||||||
|
const match = duration.match(/^(\d+)([hdwm])$/)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const value = parseInt(match[1]!, 10)
|
||||||
|
const unit = match[2]!
|
||||||
|
|
||||||
|
const ms = {
|
||||||
|
h: 60 * 60 * 1000,
|
||||||
|
d: 24 * 60 * 60 * 1000,
|
||||||
|
w: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
m: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
return value * ms[unit as keyof typeof ms]
|
||||||
|
}
|
||||||
|
|
||||||
|
const printDiskLog = (line: string, grep?: string) => {
|
||||||
|
if (grep && !matchesGrep(line, grep)) return
|
||||||
|
console.log(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const printLog = (line: LogLine, grep?: string) => {
|
||||||
|
if (grep && !matchesGrep(line.text, grep)) return
|
||||||
|
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logApp(arg: string | undefined, options: LogOptions) {
|
||||||
const name = resolveAppName(arg)
|
const name = resolveAppName(arg)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
||||||
if (options.follow) {
|
if (options.follow) {
|
||||||
await tailLogs(name)
|
await tailLogs(name, options.grep)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle --since option
|
||||||
|
if (options.since) {
|
||||||
|
const ms = parseDuration(options.since)
|
||||||
|
if (!ms) {
|
||||||
|
console.error('Invalid duration. Use format like: 1h, 2d, 1w, 1m')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = await get<string[]>(`/api/apps/${name}/logs/dates`)
|
||||||
|
if (!dates) {
|
||||||
|
console.error(`App not found: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = new Date(Date.now() - ms)
|
||||||
|
const cutoffDate = formatDate(cutoff)
|
||||||
|
|
||||||
|
// Filter dates that are >= cutoff
|
||||||
|
const relevantDates = dates.filter(d => d >= cutoffDate).reverse()
|
||||||
|
if (relevantDates.length === 0) {
|
||||||
|
console.log('No logs in the specified time range')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const date of relevantDates) {
|
||||||
|
const lines = await get<string[]>(`/api/apps/${name}/logs?date=${date}`)
|
||||||
|
if (!lines) continue
|
||||||
|
for (const line of lines) {
|
||||||
|
printDiskLog(line, options.grep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle --date option
|
||||||
|
if (options.date) {
|
||||||
|
const lines = await get<string[]>(`/api/apps/${name}/logs?date=${options.date}`)
|
||||||
|
if (!lines) {
|
||||||
|
console.error(`App not found: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lines.length === 0) {
|
||||||
|
console.log('No logs for this date')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const line of lines) {
|
||||||
|
printDiskLog(line, options.grep)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: show today's in-memory logs
|
||||||
const logs: LogLine[] | undefined = await get(`/api/apps/${name}/logs`)
|
const logs: LogLine[] | undefined = await get(`/api/apps/${name}/logs`)
|
||||||
if (!logs) {
|
if (!logs) {
|
||||||
console.error(`App not found: ${name}`)
|
console.error(`App not found: ${name}`)
|
||||||
|
|
@ -24,11 +113,11 @@ export async function logApp(arg: string | undefined, options: { follow?: boolea
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const line of logs) {
|
for (const line of logs) {
|
||||||
printLog(line)
|
printLog(line, options.grep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tailLogs(name: string) {
|
export async function tailLogs(name: string, grep?: string) {
|
||||||
try {
|
try {
|
||||||
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url)
|
||||||
|
|
@ -53,7 +142,7 @@ export async function tailLogs(name: string) {
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
const data = JSON.parse(line.slice(6)) as LogLine
|
const data = JSON.parse(line.slice(6)) as LogLine
|
||||||
printLog(data)
|
printLog(data, grep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,47 @@
|
||||||
import type { App } from '@types'
|
import type { App } from '@types'
|
||||||
import { generateTemplates, type TemplateType } from '%templates'
|
import { generateTemplates, type TemplateType } from '%templates'
|
||||||
|
import { readSyncState } from '%sync'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
|
import { buildAppUrl } from '@urls'
|
||||||
import { del, get, getManifest, HOST, post } from '../http'
|
import { del, get, getManifest, HOST, post } from '../http'
|
||||||
import { confirm, prompt } from '../prompts'
|
import { confirm, prompt } from '../prompts'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
import { pushApp } from './sync'
|
import { pushApp } from './sync'
|
||||||
|
|
||||||
export const STATE_ICONS: Record<string, string> = {
|
export const STATE_ICONS: Record<string, string> = {
|
||||||
|
error: color.red('●'),
|
||||||
running: color.green('●'),
|
running: color.green('●'),
|
||||||
starting: color.yellow('◎'),
|
starting: color.yellow('◎'),
|
||||||
stopped: color.gray('◯'),
|
stopped: color.gray('◯'),
|
||||||
invalid: color.red('◌'),
|
invalid: color.red('◌'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
async function waitForState(name: string, target: string, timeout: number): Promise<string | undefined> {
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
await sleep(500)
|
||||||
|
const app: App | undefined = await get(`/api/apps/${name}`)
|
||||||
|
if (!app) return undefined
|
||||||
|
if (app.state === target) return target
|
||||||
|
// Terminal failure states — stop polling
|
||||||
|
if (target === 'running' && (app.state === 'stopped' || app.state === 'invalid' || app.state === 'error')) return app.state
|
||||||
|
if (target === 'stopped' && (app.state === 'invalid' || app.state === 'error')) return app.state
|
||||||
|
}
|
||||||
|
// Timed out — return last known state
|
||||||
|
const app: App | undefined = await get(`/api/apps/${name}`)
|
||||||
|
return app?.state
|
||||||
|
}
|
||||||
|
|
||||||
export async function configShow() {
|
export async function configShow() {
|
||||||
console.log(`Host: ${color.bold(HOST)}`)
|
console.log(`Host: ${color.bold(HOST)}`)
|
||||||
|
|
||||||
const source = process.env.TOES_URL
|
const syncState = readSyncState(process.cwd())
|
||||||
? 'TOES_URL'
|
if (syncState) {
|
||||||
: process.env.TOES_HOST
|
console.log(`Version: ${color.bold(syncState.version)}`)
|
||||||
? 'TOES_HOST' + (process.env.PORT ? ' + PORT' : '')
|
|
||||||
: process.env.NODE_ENV === 'production'
|
|
||||||
? 'default (production)'
|
|
||||||
: 'default (development)'
|
|
||||||
|
|
||||||
console.log(`Source: ${color.gray(source)}`)
|
|
||||||
|
|
||||||
if (process.env.TOES_URL) {
|
|
||||||
console.log(` TOES_URL=${process.env.TOES_URL}`)
|
|
||||||
}
|
|
||||||
if (process.env.TOES_HOST) {
|
|
||||||
console.log(` TOES_HOST=${process.env.TOES_HOST}`)
|
|
||||||
}
|
|
||||||
if (process.env.PORT) {
|
|
||||||
console.log(` PORT=${process.env.PORT}`)
|
|
||||||
}
|
|
||||||
if (process.env.NODE_ENV) {
|
|
||||||
console.log(` NODE_ENV=${process.env.NODE_ENV}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,11 +56,19 @@ export async function infoApp(arg?: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = STATE_ICONS[app.state] ?? '◯'
|
const icon = STATE_ICONS[app.state] ?? '◯'
|
||||||
console.log(`${icon} ${color.bold(app.name)}`)
|
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
|
||||||
console.log(` State: ${app.state}`)
|
console.log(` State: ${app.state}`)
|
||||||
|
if (app.state === 'running') {
|
||||||
|
console.log(` URL: ${buildAppUrl(app.name, HOST)}`)
|
||||||
|
}
|
||||||
if (app.port) {
|
if (app.port) {
|
||||||
console.log(` Port: ${app.port}`)
|
console.log(` Port: ${app.port}`)
|
||||||
console.log(` URL: http://localhost:${app.port}`)
|
}
|
||||||
|
if (app.tunnelUrl) {
|
||||||
|
console.log(` Tunnel: ${app.tunnelUrl}`)
|
||||||
|
}
|
||||||
|
if (app.pid) {
|
||||||
|
console.log(` PID: ${app.pid}`)
|
||||||
}
|
}
|
||||||
if (app.started) {
|
if (app.started) {
|
||||||
const uptime = Date.now() - app.started
|
const uptime = Date.now() - app.started
|
||||||
|
|
@ -73,13 +84,47 @@ export async function infoApp(arg?: string) {
|
||||||
if (app.error) console.log(` Error: ${color.red(app.error)}`)
|
if (app.error) console.log(` Error: ${color.red(app.error)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listApps() {
|
interface ListAppsOptions {
|
||||||
const apps: App[] | undefined = await get('/api/apps')
|
apps?: boolean
|
||||||
if (!apps) return
|
tools?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listApps(options: ListAppsOptions) {
|
||||||
|
const allApps: App[] | undefined = await get('/api/apps')
|
||||||
|
if (!allApps) return
|
||||||
|
|
||||||
|
if (options.apps || options.tools) {
|
||||||
|
const filtered = allApps.filter((app) => {
|
||||||
|
if (options.tools) return app.tool
|
||||||
|
return !app.tool
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const app of filtered) {
|
||||||
|
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const apps = allApps.filter((app) => !app.tool)
|
||||||
|
const tools = allApps.filter((app) => app.tool)
|
||||||
|
|
||||||
|
if (tools.length === 0) {
|
||||||
|
// No tools, just list apps without header/indent
|
||||||
for (const app of apps) {
|
for (const app of apps) {
|
||||||
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (apps.length > 0) {
|
||||||
|
console.log('apps:')
|
||||||
|
for (const app of apps) {
|
||||||
|
console.log(` ${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
console.log('tools:')
|
||||||
|
for (const tool of tools) {
|
||||||
|
console.log(` ${STATE_ICONS[tool.state] ?? '◯'} ${tool.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NewAppOptions {
|
interface NewAppOptions {
|
||||||
|
|
@ -97,12 +142,26 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
|
||||||
if (options.bare) template = 'bare'
|
if (options.bare) template = 'bare'
|
||||||
else if (options.spa) template = 'spa'
|
else if (options.spa) template = 'spa'
|
||||||
|
|
||||||
|
const pkgPath = join(appPath, 'package.json')
|
||||||
|
|
||||||
|
// If package.json exists, ensure it has scripts.toes and bail
|
||||||
|
if (existsSync(pkgPath)) {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||||
|
if (!pkg.scripts?.toes) {
|
||||||
|
pkg.scripts = pkg.scripts ?? {}
|
||||||
|
pkg.scripts.toes = 'bun start'
|
||||||
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
console.log(color.green('✓ Added scripts.toes to package.json'))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (name && existsSync(appPath)) {
|
if (name && existsSync(appPath)) {
|
||||||
console.error(`Directory already exists: ${name}`)
|
console.error(`Directory already exists: ${name}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesToCheck = ['index.tsx', 'package.json', 'tsconfig.json']
|
const filesToCheck = ['index.tsx', 'tsconfig.json']
|
||||||
const existing = filesToCheck.filter((f) => existsSync(join(appPath, f)))
|
const existing = filesToCheck.filter((f) => existsSync(join(appPath, f)))
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
console.error(`Files already exist: ${existing.join(', ')}`)
|
console.error(`Files already exist: ${existing.join(', ')}`)
|
||||||
|
|
@ -151,11 +210,34 @@ export async function openApp(arg?: string) {
|
||||||
console.error(`App is not running: ${name}`)
|
console.error(`App is not running: ${name}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const url = `http://localhost:${app.port}`
|
const url = buildAppUrl(app.name, HOST)
|
||||||
console.log(`Opening ${url}`)
|
console.log(`Opening ${url}`)
|
||||||
Bun.spawn(['open', url])
|
Bun.spawn(['open', url])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function shareApp(arg?: string) {
|
||||||
|
const name = resolveAppName(arg)
|
||||||
|
if (!name) return
|
||||||
|
const result = await post<{ ok: boolean, error?: string }>(`/api/apps/${name}/tunnel`)
|
||||||
|
if (!result) return
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error(color.red(result.error ?? 'Failed to share'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process.stdout.write(`${color.cyan('↗')} Sharing ${color.bold(name)}...`)
|
||||||
|
// Poll until tunnelUrl appears
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < 15000) {
|
||||||
|
await sleep(500)
|
||||||
|
const app: App | undefined = await get(`/api/apps/${name}`)
|
||||||
|
if (app?.tunnelUrl) {
|
||||||
|
console.log(` ${color.cyan(app.tunnelUrl)}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` ${color.yellow('enabled (URL pending)')}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function renameApp(arg: string | undefined, newName: string) {
|
export async function renameApp(arg: string | undefined, newName: string) {
|
||||||
const name = resolveAppName(arg)
|
const name = resolveAppName(arg)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
@ -190,7 +272,15 @@ export async function renameApp(arg: string | undefined, newName: string) {
|
||||||
export async function restartApp(arg?: string) {
|
export async function restartApp(arg?: string) {
|
||||||
const name = resolveAppName(arg)
|
const name = resolveAppName(arg)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
await post(`/api/apps/${name}/restart`)
|
const result = await post(`/api/apps/${name}/restart`)
|
||||||
|
if (!result) return
|
||||||
|
process.stdout.write(`${color.yellow('↻')} Restarting ${color.bold(name)}...`)
|
||||||
|
const state = await waitForState(name, 'running', 15000)
|
||||||
|
if (state === 'running') {
|
||||||
|
console.log(` ${color.green('running')}`)
|
||||||
|
} else {
|
||||||
|
console.log(` ${color.red(state ?? 'unknown')}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rmApp(arg?: string) {
|
export async function rmApp(arg?: string) {
|
||||||
|
|
@ -222,11 +312,36 @@ export async function rmApp(arg?: string) {
|
||||||
export async function startApp(arg?: string) {
|
export async function startApp(arg?: string) {
|
||||||
const name = resolveAppName(arg)
|
const name = resolveAppName(arg)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
await post(`/api/apps/${name}/start`)
|
const result = await post(`/api/apps/${name}/start`)
|
||||||
|
if (!result) return
|
||||||
|
process.stdout.write(`${color.green('▶')} Starting ${color.bold(name)}...`)
|
||||||
|
const state = await waitForState(name, 'running', 15000)
|
||||||
|
if (state === 'running') {
|
||||||
|
console.log(` ${color.green('running')}`)
|
||||||
|
} else {
|
||||||
|
console.log(` ${color.red(state ?? 'unknown')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unshareApp(arg?: string) {
|
||||||
|
const name = resolveAppName(arg)
|
||||||
|
if (!name) return
|
||||||
|
const result = await del(`/api/apps/${name}/tunnel`)
|
||||||
|
if (!result) return
|
||||||
|
console.log(`${color.gray('↗')} Unshared ${color.bold(name)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopApp(arg?: string) {
|
export async function stopApp(arg?: string) {
|
||||||
const name = resolveAppName(arg)
|
const name = resolveAppName(arg)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
await post(`/api/apps/${name}/stop`)
|
const result = await post(`/api/apps/${name}/stop`)
|
||||||
|
if (!result) return
|
||||||
|
process.stdout.write(`${color.red('■')} Stopping ${color.bold(name)}...`)
|
||||||
|
const state = await waitForState(name, 'stopped', 10000)
|
||||||
|
if (state === 'stopped') {
|
||||||
|
console.log(` ${color.gray('stopped')}`)
|
||||||
|
} else {
|
||||||
|
console.log(` ${color.yellow(state ?? 'unknown')}`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
115
src/cli/commands/metrics.ts
Normal file
115
src/cli/commands/metrics.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import color from 'kleur'
|
||||||
|
import { get } from '../http'
|
||||||
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
|
interface AppMetrics {
|
||||||
|
name: string
|
||||||
|
state: string
|
||||||
|
port?: number
|
||||||
|
pid?: number
|
||||||
|
cpu?: number
|
||||||
|
memory?: number
|
||||||
|
rss?: number
|
||||||
|
dataSize?: number
|
||||||
|
tool?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes?: number): string {
|
||||||
|
if (bytes === undefined) return '-'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRss(kb?: number): string {
|
||||||
|
if (kb === undefined) return '-'
|
||||||
|
if (kb < 1024) return `${kb} KB`
|
||||||
|
if (kb < 1024 * 1024) return `${(kb / 1024).toFixed(1)} MB`
|
||||||
|
return `${(kb / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value?: number): string {
|
||||||
|
if (value === undefined) return '-'
|
||||||
|
return `${value.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(str: string, len: number, right = false): string {
|
||||||
|
if (right) return str.padStart(len)
|
||||||
|
return str.padEnd(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function metricsApp(arg?: string) {
|
||||||
|
// If arg is provided, show metrics for that app only
|
||||||
|
if (arg) {
|
||||||
|
const name = resolveAppName(arg)
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
const metrics: AppMetrics | undefined = await get(`/api/tools/metrics/api/metrics/${name}`)
|
||||||
|
if (!metrics) {
|
||||||
|
console.error(`App not found: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${color.bold(metrics.name)} ${metrics.tool ? color.gray('[tool]') : ''}`)
|
||||||
|
console.log(` State: ${metrics.state}`)
|
||||||
|
if (metrics.pid) console.log(` PID: ${metrics.pid}`)
|
||||||
|
if (metrics.port) console.log(` Port: ${metrics.port}`)
|
||||||
|
if (metrics.cpu !== undefined) console.log(` CPU: ${formatPercent(metrics.cpu)}`)
|
||||||
|
if (metrics.memory !== undefined) console.log(` Memory: ${formatPercent(metrics.memory)}`)
|
||||||
|
if (metrics.rss !== undefined) console.log(` RSS: ${formatRss(metrics.rss)}`)
|
||||||
|
console.log(` Data: ${formatBytes(metrics.dataSize)}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show metrics for all apps
|
||||||
|
const metrics: AppMetrics[] | undefined = await get('/api/tools/metrics/api/metrics')
|
||||||
|
if (!metrics || metrics.length === 0) {
|
||||||
|
console.log('No apps found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: running first, then by name
|
||||||
|
metrics.sort((a, b) => {
|
||||||
|
if (a.state === 'running' && b.state !== 'running') return -1
|
||||||
|
if (a.state !== 'running' && b.state === 'running') return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate column widths
|
||||||
|
const nameWidth = Math.max(4, ...metrics.map(s => s.name.length + (s.tool ? 7 : 0)))
|
||||||
|
const stateWidth = Math.max(5, ...metrics.map(s => s.state.length))
|
||||||
|
|
||||||
|
// Header
|
||||||
|
console.log(
|
||||||
|
color.gray(
|
||||||
|
`${pad('NAME', nameWidth)} ${pad('STATE', stateWidth)} ${pad('PID', 7, true)} ${pad('CPU', 7, true)} ${pad('MEM', 7, true)} ${pad('RSS', 10, true)} ${pad('DATA', 10, true)}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rows
|
||||||
|
for (const s of metrics) {
|
||||||
|
const name = s.tool ? `${s.name} ${color.gray('[tool]')}` : s.name
|
||||||
|
const stateColor = s.state === 'running' ? color.green : s.state === 'invalid' ? color.red : color.gray
|
||||||
|
const state = stateColor(s.state)
|
||||||
|
|
||||||
|
const pid = s.pid ? String(s.pid) : '-'
|
||||||
|
const cpu = formatPercent(s.cpu)
|
||||||
|
const mem = formatPercent(s.memory)
|
||||||
|
const rss = formatRss(s.rss)
|
||||||
|
const data = formatBytes(s.dataSize)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${pad(name, nameWidth)} ${pad(state, stateWidth)} ${pad(pid, 7, true)} ${pad(cpu, 7, true)} ${pad(mem, 7, true)} ${pad(rss, 10, true)} ${pad(data, 10, true)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const running = metrics.filter(s => s.state === 'running')
|
||||||
|
const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
|
||||||
|
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
|
||||||
|
const totalData = metrics.reduce((sum, s) => sum + (s.dataSize ?? 0), 0)
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
console.log(color.gray(`${running.length} running, ${formatPercent(totalCpu)} CPU, ${formatRss(totalRss)} RSS, ${formatBytes(totalData)} data`))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,17 +1,21 @@
|
||||||
import type { Manifest } from '@types'
|
import type { Manifest } from '@types'
|
||||||
|
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
||||||
|
|
||||||
function getDefaultHost(): string {
|
const normalizeUrl = (url: string) =>
|
||||||
if (process.env.NODE_ENV === 'production') {
|
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
|
||||||
return `http://toes.local:${process.env.PORT ?? 80}`
|
|
||||||
}
|
|
||||||
return `http://localhost:${process.env.PORT ?? 3000}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPort = process.env.NODE_ENV === 'production' ? 80 : 3000
|
function tryParseError(text: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text)
|
||||||
|
return json.error
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const HOST = process.env.TOES_URL
|
export const HOST = process.env.TOES_URL
|
||||||
?? (process.env.TOES_HOST ? `http://${process.env.TOES_HOST}:${process.env.PORT ?? defaultPort}` : undefined)
|
? normalizeUrl(process.env.TOES_URL)
|
||||||
?? getDefaultHost()
|
: DEFAULT_HOST
|
||||||
|
|
||||||
export function makeUrl(path: string): string {
|
export function makeUrl(path: string): string {
|
||||||
return `${HOST}${path}`
|
return `${HOST}${path}`
|
||||||
|
|
@ -20,7 +24,11 @@ export function makeUrl(path: string): string {
|
||||||
export function handleError(error: unknown): void {
|
export function handleError(error: unknown): void {
|
||||||
if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') {
|
if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') {
|
||||||
console.error(`🐾 Can't connect to toes server at ${HOST}`)
|
console.error(`🐾 Can't connect to toes server at ${HOST}`)
|
||||||
console.error(` Set TOES_URL or TOES_HOST to connect to a different host`)
|
console.error(` Set TOES_URL to connect to a different host`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(`Error: ${error.message}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|
@ -29,19 +37,25 @@ export function handleError(error: unknown): void {
|
||||||
export async function get<T>(url: string): Promise<T | undefined> {
|
export async function get<T>(url: string): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(url))
|
const res = await fetch(makeUrl(url))
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
return await res.json()
|
return await res.json()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> {
|
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
||||||
if (res.status === 404) return { exists: false }
|
if (res.status === 404) return { exists: false }
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
return { exists: true, manifest: await res.json() }
|
const data = await res.json()
|
||||||
|
const { version, ...manifest } = data
|
||||||
|
return { exists: true, manifest, version }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
return null
|
return null
|
||||||
|
|
@ -55,7 +69,11 @@ export async function post<T, B = unknown>(url: string, body?: B): Promise<T | u
|
||||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
return await res.json()
|
return await res.json()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
|
|
@ -68,7 +86,11 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: body as BodyInit,
|
body: body as BodyInit,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
|
|
@ -80,7 +102,11 @@ export async function download(url: string): Promise<Buffer | undefined> {
|
||||||
try {
|
try {
|
||||||
const fullUrl = makeUrl(url)
|
const fullUrl = makeUrl(url)
|
||||||
const res = await fetch(fullUrl)
|
const res = await fetch(fullUrl)
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
return Buffer.from(await res.arrayBuffer())
|
return Buffer.from(await res.arrayBuffer())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
|
|
@ -92,7 +118,11 @@ export async function del(url: string): Promise<boolean> {
|
||||||
const res = await fetch(makeUrl(url), {
|
const res = await fetch(makeUrl(url), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
|
|
|
||||||
35
src/cli/pager.ts
Normal file
35
src/cli/pager.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
export async function withPager(fn: () => Promise<void> | void): Promise<void> {
|
||||||
|
if (!process.stdout.isTTY) {
|
||||||
|
await fn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
const originalLog = console.log
|
||||||
|
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
lines.push(args.map(a => typeof a === 'string' ? a : String(a)).join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
} finally {
|
||||||
|
console.log = originalLog
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0) return
|
||||||
|
|
||||||
|
const text = lines.join('\n') + '\n'
|
||||||
|
const rows = process.stdout.rows || 24
|
||||||
|
const lineCount = text.split('\n').length - 1
|
||||||
|
|
||||||
|
if (lineCount > rows) {
|
||||||
|
Bun.spawnSync(['less', '-R'], {
|
||||||
|
stdin: Buffer.from(text),
|
||||||
|
stdout: 'inherit',
|
||||||
|
stderr: 'inherit',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
process.stdout.write(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
304
src/cli/setup.ts
304
src/cli/setup.ts
|
|
@ -1,11 +1,22 @@
|
||||||
import { program } from 'commander'
|
import { program } from 'commander'
|
||||||
import { readFileSync } from 'fs'
|
|
||||||
|
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
|
|
||||||
|
import pkg from '../../package.json'
|
||||||
|
import { withPager } from './pager'
|
||||||
import {
|
import {
|
||||||
|
cleanApp,
|
||||||
configShow,
|
configShow,
|
||||||
|
cronList,
|
||||||
|
cronLog,
|
||||||
|
cronRun,
|
||||||
|
cronStatus,
|
||||||
diffApp,
|
diffApp,
|
||||||
|
envList,
|
||||||
|
envRm,
|
||||||
|
envSet,
|
||||||
getApp,
|
getApp,
|
||||||
|
historyApp,
|
||||||
infoApp,
|
infoApp,
|
||||||
listApps,
|
listApps,
|
||||||
logApp,
|
logApp,
|
||||||
|
|
@ -16,28 +27,34 @@ import {
|
||||||
renameApp,
|
renameApp,
|
||||||
restartApp,
|
restartApp,
|
||||||
rmApp,
|
rmApp,
|
||||||
|
rollbackApp,
|
||||||
|
stashApp,
|
||||||
|
stashListApp,
|
||||||
|
stashPopApp,
|
||||||
startApp,
|
startApp,
|
||||||
|
metricsApp,
|
||||||
|
shareApp,
|
||||||
statusApp,
|
statusApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
syncApp,
|
syncApp,
|
||||||
|
unshareApp,
|
||||||
|
versionsApp,
|
||||||
} from './commands'
|
} from './commands'
|
||||||
|
|
||||||
program
|
program
|
||||||
.name('toes')
|
.name('toes')
|
||||||
.version('v0.0.3', '-v, --version')
|
.version(`v${pkg.version}`, '-v, --version')
|
||||||
.addHelpText('beforeAll', (ctx) => {
|
.addHelpText('beforeAll', (ctx) => {
|
||||||
if (ctx.command === program) {
|
if (ctx.command === program) {
|
||||||
return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n')
|
return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
.addHelpCommand(false)
|
||||||
.configureOutput({
|
.configureOutput({
|
||||||
writeOut: (str) => {
|
writeOut: (str) => {
|
||||||
const colored = str
|
const colored = str
|
||||||
.replace(/^(Usage:)/gm, color.yellow('$1'))
|
.replace(/^([A-Z][\w ]*:)/gm, color.yellow('$1'))
|
||||||
.replace(/^(Commands:)/gm, color.yellow('$1'))
|
|
||||||
.replace(/^(Options:)/gm, color.yellow('$1'))
|
|
||||||
.replace(/^(Arguments:)/gm, color.yellow('$1'))
|
|
||||||
process.stdout.write(colored)
|
process.stdout.write(colored)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -46,67 +63,26 @@ program
|
||||||
.command('version', { hidden: true })
|
.command('version', { hidden: true })
|
||||||
.action(() => console.log(program.version()))
|
.action(() => console.log(program.version()))
|
||||||
|
|
||||||
|
// Apps
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('config')
|
.command('list')
|
||||||
.description('Show current host configuration')
|
.helpGroup('Apps:')
|
||||||
.action(configShow)
|
.description('List all apps')
|
||||||
|
.option('-t, --tools', 'show only tools')
|
||||||
|
.option('-a, --apps', 'show only apps (exclude tools)')
|
||||||
|
.action(listApps)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('info')
|
.command('info')
|
||||||
|
.helpGroup('Apps:')
|
||||||
.description('Show info for an app')
|
.description('Show info for an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(infoApp)
|
.action(infoApp)
|
||||||
|
|
||||||
program
|
|
||||||
.command('list')
|
|
||||||
.description('List all apps')
|
|
||||||
.action(listApps)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('start')
|
|
||||||
.description('Start an app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(startApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('stop')
|
|
||||||
.description('Stop an app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(stopApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('restart')
|
|
||||||
.description('Restart an app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(restartApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('logs')
|
|
||||||
.description('Show logs for an app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.option('-f, --follow', 'follow log output')
|
|
||||||
.action(logApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('log', { hidden: true })
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.option('-f, --follow', 'follow log output')
|
|
||||||
.action(logApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('open')
|
|
||||||
.description('Open an app in browser')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(openApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('get')
|
|
||||||
.description('Download an app from server')
|
|
||||||
.argument('<name>', 'app name')
|
|
||||||
.action(getApp)
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('new')
|
.command('new')
|
||||||
|
.helpGroup('Apps:')
|
||||||
.description('Create a new toes app')
|
.description('Create a new toes app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.option('--ssr', 'SSR template with pages directory (default)')
|
.option('--ssr', 'SSR template with pages directory (default)')
|
||||||
|
|
@ -114,44 +90,236 @@ program
|
||||||
.option('--spa', 'single-page app with client-side rendering')
|
.option('--spa', 'single-page app with client-side rendering')
|
||||||
.action(newApp)
|
.action(newApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('get')
|
||||||
|
.helpGroup('Apps:')
|
||||||
|
.description('Download an app from server')
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.action(getApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('open')
|
||||||
|
.helpGroup('Apps:')
|
||||||
|
.description('Open an app in browser')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(openApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('rename')
|
||||||
|
.helpGroup('Apps:')
|
||||||
|
.description('Rename an app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.argument('<new-name>', 'new app name')
|
||||||
|
.action(renameApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('rm')
|
||||||
|
.helpGroup('Apps:')
|
||||||
|
.description('Remove an app from the server')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(rmApp)
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('start')
|
||||||
|
.helpGroup('Lifecycle:')
|
||||||
|
.description('Start an app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(startApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('stop')
|
||||||
|
.helpGroup('Lifecycle:')
|
||||||
|
.description('Stop an app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(stopApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('restart')
|
||||||
|
.helpGroup('Lifecycle:')
|
||||||
|
.description('Restart an app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(restartApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('share')
|
||||||
|
.helpGroup('Lifecycle:')
|
||||||
|
.description('Share an app via public tunnel')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(shareApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('unshare')
|
||||||
|
.helpGroup('Lifecycle:')
|
||||||
|
.description('Stop sharing an app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(unshareApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('logs')
|
||||||
|
.helpGroup('Lifecycle:')
|
||||||
|
.description('Show logs for an app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.option('-f, --follow', 'follow log output')
|
||||||
|
.option('-d, --date <date>', 'show logs from a specific date (YYYY-MM-DD)')
|
||||||
|
.option('-s, --since <duration>', 'show logs since duration (e.g., 1h, 2d)')
|
||||||
|
.option('-g, --grep <pattern>', 'filter logs by pattern')
|
||||||
|
.action(logApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('log', { hidden: true })
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.option('-f, --follow', 'follow log output')
|
||||||
|
.option('-d, --date <date>', 'show logs from a specific date (YYYY-MM-DD)')
|
||||||
|
.option('-s, --since <duration>', 'show logs since duration (e.g., 1h, 2d)')
|
||||||
|
.option('-g, --grep <pattern>', 'filter logs by pattern')
|
||||||
|
.action(logApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('metrics')
|
||||||
|
.helpGroup('Lifecycle:')
|
||||||
|
.description('Show CPU, memory, and disk metrics for apps')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(metricsApp)
|
||||||
|
|
||||||
|
const cron = program
|
||||||
|
.command('cron')
|
||||||
|
.helpGroup('Lifecycle:')
|
||||||
|
.description('Manage cron jobs')
|
||||||
|
.argument('[app]', 'app name (list jobs for specific app)')
|
||||||
|
.action(cronList)
|
||||||
|
|
||||||
|
cron
|
||||||
|
.command('log')
|
||||||
|
.description('Show cron job logs')
|
||||||
|
.argument('[target]', 'app name or job (app:name)')
|
||||||
|
.option('-f, --follow', 'follow log output')
|
||||||
|
.action(cronLog)
|
||||||
|
|
||||||
|
cron
|
||||||
|
.command('status')
|
||||||
|
.description('Show detailed status for a job')
|
||||||
|
.argument('<job>', 'job identifier (app:name)')
|
||||||
|
.action(cronStatus)
|
||||||
|
|
||||||
|
cron
|
||||||
|
.command('run')
|
||||||
|
.description('Run a job immediately')
|
||||||
|
.argument('<job>', 'job identifier (app:name)')
|
||||||
|
.action(cronRun)
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('push')
|
.command('push')
|
||||||
|
.helpGroup('Sync:')
|
||||||
.description('Push local changes to server')
|
.description('Push local changes to server')
|
||||||
|
.option('-f, --force', 'overwrite remote changes')
|
||||||
.action(pushApp)
|
.action(pushApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('pull')
|
.command('pull')
|
||||||
|
.helpGroup('Sync:')
|
||||||
.description('Pull changes from server')
|
.description('Pull changes from server')
|
||||||
.option('-f, --force', 'overwrite local changes')
|
.option('-f, --force', 'overwrite local changes')
|
||||||
.action(pullApp)
|
.action(pullApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('status')
|
.command('status')
|
||||||
|
.helpGroup('Sync:')
|
||||||
.description('Show what would be pushed/pulled')
|
.description('Show what would be pushed/pulled')
|
||||||
.action(statusApp)
|
.action(statusApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('diff')
|
.command('diff')
|
||||||
|
.helpGroup('Sync:')
|
||||||
.description('Show diff of changed files')
|
.description('Show diff of changed files')
|
||||||
.action(diffApp)
|
.action(() => withPager(diffApp))
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('sync')
|
.command('sync')
|
||||||
|
.helpGroup('Sync:')
|
||||||
.description('Watch and sync changes bidirectionally')
|
.description('Watch and sync changes bidirectionally')
|
||||||
.option('-r, --rollback', 'rollback to checkpoint before sync started')
|
|
||||||
.action(syncApp)
|
.action(syncApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('rm')
|
.command('clean')
|
||||||
.description('Remove an app from the server')
|
.helpGroup('Sync:')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.description('Remove local files not on server')
|
||||||
.action(rmApp)
|
.option('-f, --force', 'skip confirmation')
|
||||||
|
.option('-n, --dry-run', 'show what would be removed')
|
||||||
|
.action(cleanApp)
|
||||||
|
|
||||||
|
const stash = program
|
||||||
|
.command('stash')
|
||||||
|
.helpGroup('Sync:')
|
||||||
|
.description('Stash local changes')
|
||||||
|
.action(stashApp)
|
||||||
|
|
||||||
|
stash
|
||||||
|
.command('pop')
|
||||||
|
.description('Restore stashed changes')
|
||||||
|
.action(stashPopApp)
|
||||||
|
|
||||||
|
stash
|
||||||
|
.command('list')
|
||||||
|
.description('List all stashes')
|
||||||
|
.action(stashListApp)
|
||||||
|
|
||||||
|
// Config
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('rename')
|
.command('config')
|
||||||
.description('Rename an app')
|
.helpGroup('Config:')
|
||||||
|
.description('Show current host configuration')
|
||||||
|
.action(configShow)
|
||||||
|
|
||||||
|
const env = program
|
||||||
|
.command('env')
|
||||||
|
.helpGroup('Config:')
|
||||||
|
.description('Manage environment variables')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.argument('<new-name>', 'new app name')
|
.option('-g, --global', 'manage global variables shared by all apps')
|
||||||
.action(renameApp)
|
.action(envList)
|
||||||
|
|
||||||
|
env
|
||||||
|
.command('set')
|
||||||
|
.description('Set an environment variable')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.argument('<key>', 'variable name')
|
||||||
|
.argument('[value]', 'variable value (or use KEY=value format)')
|
||||||
|
.option('-g, --global', 'set a global variable shared by all apps')
|
||||||
|
.action(envSet)
|
||||||
|
|
||||||
|
env
|
||||||
|
.command('rm')
|
||||||
|
.description('Remove an environment variable')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.argument('<key>', 'variable name to remove')
|
||||||
|
.option('-g, --global', 'remove a global variable')
|
||||||
|
.action(envRm)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('versions')
|
||||||
|
.helpGroup('Config:')
|
||||||
|
.description('List deployed versions')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(versionsApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('history')
|
||||||
|
.helpGroup('Config:')
|
||||||
|
.description('Show file changes between versions')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(historyApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('rollback')
|
||||||
|
.helpGroup('Config:')
|
||||||
|
.description('Rollback to a previous version')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
||||||
|
.action((name, options) => rollbackApp(name, options.version))
|
||||||
|
|
||||||
export { program }
|
export { program }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,27 @@
|
||||||
|
export const getLogDates = (name: string): Promise<string[]> =>
|
||||||
|
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
|
||||||
|
|
||||||
|
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||||
|
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
||||||
|
|
||||||
|
export const getWifiConfig = (): Promise<{ network: string, password: string }> =>
|
||||||
|
fetch('/api/system/wifi').then(r => r.json())
|
||||||
|
|
||||||
|
export const saveWifiConfig = (config: { network: string, password: string }) =>
|
||||||
|
fetch('/api/system/wifi', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}).then(r => r.json())
|
||||||
|
|
||||||
|
export const shareApp = (name: string) =>
|
||||||
|
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
||||||
|
|
||||||
|
export const unshareApp = (name: string) =>
|
||||||
|
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||||
|
|
||||||
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||||
|
|
||||||
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
||||||
|
|
||||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import { define } from '@because/forge'
|
import { define } from '@because/forge'
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { restartApp, startApp, stopApp } from '../api'
|
import { buildAppUrl } from '../../shared/urls'
|
||||||
|
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
|
||||||
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||||
import { apps, selectedTab } from '../state'
|
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
|
||||||
import {
|
import {
|
||||||
ActionBar,
|
ActionBar,
|
||||||
Button,
|
Button,
|
||||||
ClickableAppName,
|
ClickableAppName,
|
||||||
|
HamburgerButton,
|
||||||
|
HamburgerLine,
|
||||||
HeaderActions,
|
HeaderActions,
|
||||||
InfoLabel,
|
InfoLabel,
|
||||||
InfoRow,
|
InfoRow,
|
||||||
InfoValue,
|
InfoValue,
|
||||||
Link,
|
Link,
|
||||||
LogLine,
|
|
||||||
LogsContainer,
|
|
||||||
LogTime,
|
|
||||||
Main,
|
Main,
|
||||||
MainContent,
|
MainContent,
|
||||||
MainHeader,
|
MainHeader,
|
||||||
|
|
@ -26,8 +26,9 @@ import {
|
||||||
TabContent,
|
TabContent,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
import { openEmojiPicker } from './emoji-picker'
|
import { openEmojiPicker } from './emoji-picker'
|
||||||
import { theme } from '../themes'
|
import { LogsSection } from './LogsSection'
|
||||||
import { Nav } from './Nav'
|
import { Nav } from './Nav'
|
||||||
|
import { theme } from '../themes'
|
||||||
|
|
||||||
const OpenEmojiPicker = define('OpenEmojiPicker', {
|
const OpenEmojiPicker = define('OpenEmojiPicker', {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|
@ -46,16 +47,30 @@ const OpenEmojiPicker = define('OpenEmojiPicker', {
|
||||||
export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
// Find all tools
|
// Find all tools
|
||||||
const tools = apps.filter(a => a.tool)
|
const tools = apps.filter(a => a.tool)
|
||||||
|
const selectedTab = getSelectedTab(app.name)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Main>
|
<Main>
|
||||||
<MainHeader>
|
<MainHeader>
|
||||||
<MainTitle>
|
<MainTitle>
|
||||||
|
{isNarrow && (
|
||||||
|
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
</HamburgerButton>
|
||||||
|
)}
|
||||||
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
|
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
|
||||||
|
|
||||||
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
||||||
</MainTitle>
|
</MainTitle>
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
|
{!app.tool && (
|
||||||
|
app.tunnelUrl
|
||||||
|
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
|
||||||
|
: app.tunnelEnabled
|
||||||
|
? <Button disabled>Sharing...</Button>
|
||||||
|
: <Button disabled={app.state !== 'running'} onClick={() => { shareApp(app.name) }}>Share</Button>
|
||||||
|
)}
|
||||||
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
|
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
|
||||||
</HeaderActions>
|
</HeaderActions>
|
||||||
</MainHeader>
|
</MainHeader>
|
||||||
|
|
@ -72,16 +87,24 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
{stateLabels[app.state]}
|
{stateLabels[app.state]}
|
||||||
</InfoValue>
|
</InfoValue>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
{app.state === 'running' && app.port && (
|
{app.state === 'running' && (
|
||||||
<InfoRow>
|
<InfoRow>
|
||||||
<InfoLabel>URL</InfoLabel>
|
<InfoLabel>URL</InfoLabel>
|
||||||
<InfoValue>
|
<InfoValue>
|
||||||
<Link href={`http://localhost:${app.port}`} target="_blank">
|
<Link href={buildAppUrl(app.name, location.origin)} target="_blank">
|
||||||
http://localhost:{app.port}
|
{buildAppUrl(app.name, location.origin)}
|
||||||
</Link>
|
</Link>
|
||||||
</InfoValue>
|
</InfoValue>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
)}
|
)}
|
||||||
|
{app.tunnelUrl && (
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>Tunnel</InfoLabel>
|
||||||
|
<InfoValue>
|
||||||
|
<Link href={app.tunnelUrl} target="_blank">{app.tunnelUrl}</Link>
|
||||||
|
</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
)}
|
||||||
{app.state === 'running' && app.port && (
|
{app.state === 'running' && app.port && (
|
||||||
<InfoRow>
|
<InfoRow>
|
||||||
<InfoLabel>Port</InfoLabel>
|
<InfoLabel>Port</InfoLabel>
|
||||||
|
|
@ -90,6 +113,14 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
</InfoValue>
|
</InfoValue>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
)}
|
)}
|
||||||
|
{app.state === 'running' && app.pid && (
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>PID</InfoLabel>
|
||||||
|
<InfoValue>
|
||||||
|
{app.pid}
|
||||||
|
</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
)}
|
||||||
{app.started && (
|
{app.started && (
|
||||||
<InfoRow>
|
<InfoRow>
|
||||||
<InfoLabel>Started</InfoLabel>
|
<InfoLabel>Started</InfoLabel>
|
||||||
|
|
@ -106,27 +137,10 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section>
|
<LogsSection app={app} />
|
||||||
<SectionTitle>Logs</SectionTitle>
|
|
||||||
<LogsContainer>
|
|
||||||
{app.logs?.length ? (
|
|
||||||
app.logs.map((line, i) => (
|
|
||||||
<LogLine key={i}>
|
|
||||||
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
|
|
||||||
<span>{line.text}</span>
|
|
||||||
</LogLine>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<LogLine>
|
|
||||||
<LogTime>--:--:--</LogTime>
|
|
||||||
<span style={{ color: theme('colors-textFaint') }}>No logs yet</span>
|
|
||||||
</LogLine>
|
|
||||||
)}
|
|
||||||
</LogsContainer>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<ActionBar>
|
<ActionBar>
|
||||||
{app.state === 'stopped' && (
|
{(app.state === 'stopped' || app.state === 'error') && (
|
||||||
<Button variant="primary" onClick={() => startApp(app.name)}>
|
<Button variant="primary" onClick={() => startApp(app.name)}>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -151,7 +165,6 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
return (
|
return (
|
||||||
<TabContent key={tool.name} active={isSelected}>
|
<TabContent key={tool.name} active={isSelected}>
|
||||||
<Section>
|
<Section>
|
||||||
<SectionTitle>{toolName}</SectionTitle>
|
|
||||||
{tool.state !== 'running' && (
|
{tool.state !== 'running' && (
|
||||||
<p style={{ color: theme('colors-textFaint') }}>
|
<p style={{ color: theme('colors-textFaint') }}>
|
||||||
Tool is {stateLabels[tool.state].toLowerCase()}
|
Tool is {stateLabels[tool.state].toLowerCase()}
|
||||||
|
|
|
||||||
82
src/client/components/AppSelector.tsx
Normal file
82
src/client/components/AppSelector.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import type { CSSProperties } from 'hono/jsx'
|
||||||
|
import {
|
||||||
|
apps,
|
||||||
|
selectedApp,
|
||||||
|
setSidebarSection,
|
||||||
|
sidebarSection,
|
||||||
|
} from '../state'
|
||||||
|
import {
|
||||||
|
AppItem,
|
||||||
|
AppList,
|
||||||
|
SectionSwitcher,
|
||||||
|
SectionTab,
|
||||||
|
StatusDot,
|
||||||
|
} from '../styles'
|
||||||
|
|
||||||
|
interface AppSelectorProps {
|
||||||
|
render: () => void
|
||||||
|
onSelect?: () => void
|
||||||
|
collapsed?: boolean
|
||||||
|
large?: boolean
|
||||||
|
switcherStyle?: CSSProperties
|
||||||
|
listStyle?: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
|
||||||
|
const switchSection = (section: 'apps' | 'tools') => {
|
||||||
|
setSidebarSection(section)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
const regularApps = apps.filter(app => !app.tool)
|
||||||
|
const toolApps = apps.filter(app => app.tool)
|
||||||
|
const activeApps = sidebarSection === 'apps' ? regularApps : toolApps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!collapsed && toolApps.length > 0 && (
|
||||||
|
<SectionSwitcher style={switcherStyle}>
|
||||||
|
<SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
|
||||||
|
Apps
|
||||||
|
</SectionTab>
|
||||||
|
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
|
||||||
|
Tools
|
||||||
|
</SectionTab>
|
||||||
|
</SectionSwitcher>
|
||||||
|
)}
|
||||||
|
<AppList style={listStyle}>
|
||||||
|
{collapsed && (
|
||||||
|
<AppItem
|
||||||
|
href="/"
|
||||||
|
selected={!selectedApp ? true : undefined}
|
||||||
|
style={{ justifyContent: 'center', padding: '10px 12px' }}
|
||||||
|
title="Toes"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 18 }}>🐾</span>
|
||||||
|
</AppItem>
|
||||||
|
)}
|
||||||
|
{activeApps.map(app => (
|
||||||
|
<AppItem
|
||||||
|
key={app.name}
|
||||||
|
href={`/app/${app.name}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
large={large || undefined}
|
||||||
|
selected={app.name === selectedApp ? true : undefined}
|
||||||
|
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
|
||||||
|
title={collapsed ? app.name : undefined}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<span style={{ fontSize: 18 }}>{app.icon}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
|
||||||
|
{app.name}
|
||||||
|
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AppItem>
|
||||||
|
))}
|
||||||
|
</AppList>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,60 @@
|
||||||
import { Styles } from '@because/forge'
|
import { Styles } from '@because/forge'
|
||||||
import { Modal } from './modal'
|
import { openNewAppModal } from '../modals'
|
||||||
import { apps, selectedApp } from '../state'
|
import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
|
||||||
import { EmptyState, Layout } from '../styles'
|
import {
|
||||||
|
HamburgerButton,
|
||||||
|
HamburgerLine,
|
||||||
|
Layout,
|
||||||
|
Main,
|
||||||
|
MainContent as MainContentContainer,
|
||||||
|
MainHeader,
|
||||||
|
MainTitle,
|
||||||
|
NewAppButton,
|
||||||
|
} from '../styles'
|
||||||
import { AppDetail } from './AppDetail'
|
import { AppDetail } from './AppDetail'
|
||||||
|
import { AppSelector } from './AppSelector'
|
||||||
|
import { DashboardLanding } from './DashboardLanding'
|
||||||
|
import { Modal } from './modal'
|
||||||
|
import { SettingsPage } from './SettingsPage'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
|
|
||||||
export function Dashboard({ render }: { render: () => void }) {
|
function MobileSidebar({ render }: { render: () => void }) {
|
||||||
const selected = apps.find(a => a.name === selectedApp)
|
return (
|
||||||
|
<Main>
|
||||||
|
<MainHeader>
|
||||||
|
<MainTitle>
|
||||||
|
<HamburgerButton onClick={() => { setMobileSidebar(false); render() }} title="Hide apps">
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
</HamburgerButton>
|
||||||
|
<a href="/" style={{ textDecoration: 'none', color: 'inherit' }}>🐾 Toes</a>
|
||||||
|
</MainTitle>
|
||||||
|
</MainHeader>
|
||||||
|
<MainContentContainer>
|
||||||
|
<AppSelector render={render} large />
|
||||||
|
<div style={{ padding: '12px 16px' }}>
|
||||||
|
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
||||||
|
</div>
|
||||||
|
</MainContentContainer>
|
||||||
|
</Main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainContent({ render }: { render: () => void }) {
|
||||||
|
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
|
||||||
|
const selected = apps.find(a => a.name === selectedApp)
|
||||||
|
if (selected) return <AppDetail app={selected} render={render} />
|
||||||
|
if (currentView === 'settings') return <SettingsPage render={render} />
|
||||||
|
return <DashboardLanding render={render} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard({ render }: { render: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Styles />
|
<Styles />
|
||||||
<Sidebar render={render} />
|
{!isNarrow && <Sidebar render={render} />}
|
||||||
{selected ? (
|
<MainContent render={render} />
|
||||||
<AppDetail app={selected} render={render} />
|
|
||||||
) : (
|
|
||||||
<EmptyState>Select an app to view details</EmptyState>
|
|
||||||
)}
|
|
||||||
<Modal />
|
<Modal />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
82
src/client/components/DashboardLanding.tsx
Normal file
82
src/client/components/DashboardLanding.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { useEffect } from 'hono/jsx'
|
||||||
|
import { navigate } from '../router'
|
||||||
|
import { dashboardTab, isNarrow, setMobileSidebar } from '../state'
|
||||||
|
import {
|
||||||
|
HamburgerButton,
|
||||||
|
HamburgerLine,
|
||||||
|
DashboardContainer,
|
||||||
|
DashboardHeader,
|
||||||
|
DashboardTitle,
|
||||||
|
SettingsGear,
|
||||||
|
Tab,
|
||||||
|
TabBar,
|
||||||
|
TabContent,
|
||||||
|
} from '../styles'
|
||||||
|
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
|
||||||
|
import { Urls } from './Urls'
|
||||||
|
import { Vitals, initVitals } from './Vitals'
|
||||||
|
|
||||||
|
export function DashboardLanding({ render }: { render: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
initUnifiedLogs()
|
||||||
|
initVitals()
|
||||||
|
if (dashboardTab === 'logs') scrollLogsToBottom()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const narrow = isNarrow || undefined
|
||||||
|
|
||||||
|
const openSettings = () => {
|
||||||
|
navigate('/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchTab = (tab: typeof dashboardTab) => {
|
||||||
|
navigate(tab === 'urls' ? '/' : `/${tab}`)
|
||||||
|
if (tab === 'logs') scrollLogsToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContainer narrow={narrow} relative>
|
||||||
|
<SettingsGear
|
||||||
|
onClick={openSettings}
|
||||||
|
title="Settings"
|
||||||
|
style={{ position: 'absolute', top: 16, right: 16 }}
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</SettingsGear>
|
||||||
|
{isNarrow && (
|
||||||
|
<HamburgerButton
|
||||||
|
onClick={() => { setMobileSidebar(true); render() }}
|
||||||
|
title="Show apps"
|
||||||
|
style={{ position: 'absolute', top: 16, left: 16 }}
|
||||||
|
>
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
<HamburgerLine />
|
||||||
|
</HamburgerButton>
|
||||||
|
)}
|
||||||
|
<DashboardHeader>
|
||||||
|
<DashboardTitle narrow={narrow}>
|
||||||
|
🐾 Toes
|
||||||
|
</DashboardTitle>
|
||||||
|
</DashboardHeader>
|
||||||
|
|
||||||
|
<TabBar centered>
|
||||||
|
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab>
|
||||||
|
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>Logs</Tab>
|
||||||
|
<Tab active={dashboardTab === 'metrics' || undefined} onClick={() => switchTab('metrics')}>Metrics</Tab>
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
<TabContent active={dashboardTab === 'urls' || undefined}>
|
||||||
|
<Urls render={render} />
|
||||||
|
</TabContent>
|
||||||
|
|
||||||
|
<TabContent active={dashboardTab === 'logs' || undefined}>
|
||||||
|
<UnifiedLogs />
|
||||||
|
</TabContent>
|
||||||
|
|
||||||
|
<TabContent active={dashboardTab === 'metrics' || undefined}>
|
||||||
|
<Vitals />
|
||||||
|
</TabContent>
|
||||||
|
</DashboardContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user