Compare commits
No commits in common. "main" and "dotenv" have entirely different histories.
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,9 +1,6 @@
|
||||||
.sandlot/
|
|
||||||
|
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
pub/client/index.js
|
pub/client/index.js
|
||||||
toes/
|
|
||||||
|
|
||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
|
|
@ -36,6 +33,3 @@ 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
|
|
||||||
|
|
|
||||||
241
CLAUDE.md
241
CLAUDE.md
|
|
@ -1,4 +1,6 @@
|
||||||
# Toes
|
# Toes - Claude Code Guide
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
|
@ -6,142 +8,151 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. Server scans `APPS_DIR` for directories with a `package.json` containing a `scripts.toes` entry
|
1. Host server scans `/apps` directory for valid apps
|
||||||
2. Each app is spawned as a child process with a unique port (3001-3100)
|
2. Valid app = has `package.json` with `scripts.toes` entry
|
||||||
3. Dashboard UI shows all apps with status, logs, and links via SSE
|
3. Each app spawned as child process with unique port (3001+)
|
||||||
4. CLI communicates with the server over HTTP
|
4. Dashboard UI shows all apps with current status, logs, and links
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
### Server (`src/server/`)
|
||||||
|
- `apps.ts` - **The heart**: app discovery, process management, health checks, auto-restart
|
||||||
|
- `api/apps.ts` - REST API for app lifecycle (start/stop/restart, logs, icons, rename)
|
||||||
|
- `api/sync.ts` - File sync protocol (manifest, push/pull, watch)
|
||||||
|
- `index.tsx` - Entry point (minimal, initializes Hype)
|
||||||
|
- `shell.tsx` - HTML shell for web UI
|
||||||
|
|
||||||
|
### Client (`src/client/`)
|
||||||
|
- `components/` - Dashboard, Sidebar, AppDetail, Nav
|
||||||
|
- `modals/` - NewApp, RenameApp, DeleteApp dialogs
|
||||||
|
- `styles/` - Forge CSS-in-JS (themes, buttons, forms, layout)
|
||||||
|
- `state.ts` - Client state management
|
||||||
|
- `api.ts` - API client
|
||||||
|
|
||||||
|
### CLI (`src/cli/`)
|
||||||
|
- `commands/manage.ts` - list, start, stop, restart, info, new, rename, delete, open
|
||||||
|
- `commands/sync.ts` - push, pull, sync
|
||||||
|
- `commands/logs.ts` - log viewing with tail support
|
||||||
|
|
||||||
|
### Shared (`src/shared/`)
|
||||||
|
- Code shared between frontend (browser) and backend (server)
|
||||||
|
- `types.ts` - App, AppState, Manifest interfaces
|
||||||
|
- IMPORTANT: Cannot use filesystem or Node APIs (runs in browser)
|
||||||
|
|
||||||
|
### Lib (`src/lib/`)
|
||||||
|
- Code shared between CLI and server (server-side only)
|
||||||
|
- `templates.ts` - Template generation for new apps
|
||||||
|
- Can use filesystem and Node APIs (never runs in browser)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script
|
||||||
|
- `TODO.txt` - Task list
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
Tools are special apps that appear as tabs in the dashboard rather than standalone entries in the sidebar. They integrate directly into the Toes UI and can interact with the currently selected app.
|
||||||
|
|
||||||
|
### Creating a Tool
|
||||||
|
|
||||||
|
Add `toes.tool` to your app's `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"toes": {
|
||||||
|
"icon": "🔧",
|
||||||
|
"tool": true
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### How Tools Work
|
||||||
|
|
||||||
|
- Tools run as regular apps (spawned process with unique port)
|
||||||
|
- Displayed as iframes overlaying the tab content area
|
||||||
|
- Receive `?app=<name>` query parameter for the currently selected app
|
||||||
|
- Iframes are cached per tool+app combination (never recreated once loaded)
|
||||||
|
- Tool state persists across tab switches
|
||||||
|
- **App paths**: When accessing app files, tools must use `APPS_DIR/<app>/current` (not just `APPS_DIR/<app>`) to resolve through the version symlink
|
||||||
|
|
||||||
|
### CLI Flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes list # Lists regular apps only (excludes tools)
|
||||||
|
toes list --tools # Lists tools only
|
||||||
|
toes list --all # Lists all apps including tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool vs App
|
||||||
|
|
||||||
|
| Aspect | Regular App | Tool |
|
||||||
|
|--------|-------------|------|
|
||||||
|
| `toes.tool` | absent/false | true |
|
||||||
|
| UI location | Sidebar | Tab bar |
|
||||||
|
| Rendering | New browser tab | Iframe in dashboard |
|
||||||
|
| Context | Standalone | Knows selected app via query param |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Bun** runtime (not Node)
|
- **Bun** runtime (not Node)
|
||||||
- **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
|
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype
|
||||||
- **Forge** (typed CSS-in-JS) from `@because/forge`
|
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/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 dev # Hot reload (deletes pub/client/index.js first)
|
bun run --hot src/server/index.tsx # Dev mode with hot reload
|
||||||
bun run start # Production
|
|
||||||
bun run check # Type check
|
|
||||||
bun run test # Tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## App Structure
|
||||||
|
|
||||||
```
|
```tsx
|
||||||
src/
|
// apps/example/index.tsx
|
||||||
server/ # HTTP server and process management ($)
|
import { Hype } from "@because/hype"
|
||||||
client/ # Browser-side dashboard
|
const app = new Hype()
|
||||||
shared/ # Types shared between server and client (@)
|
app.get("/", (c) => c.html(<h1>Content</h1>))
|
||||||
lib/ # Code shared between CLI and server (%)
|
export default app.defaults
|
||||||
cli/ # CLI tool
|
|
||||||
tools/ # @because/toes package exports
|
|
||||||
pages/ # Hype page routes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
|
## Conventions
|
||||||
|
|
||||||
### Server (`src/server/`)
|
- Apps get `PORT` env var from host
|
||||||
|
- Each app is isolated process with own dependencies
|
||||||
|
- No path-based routing - apps run on separate ports
|
||||||
|
- `DATA_DIR` env controls where apps are discovered
|
||||||
|
- Path aliases: `$` → server, `@` → shared, `%` → lib
|
||||||
|
|
||||||
- `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).
|
## Environment Variables
|
||||||
- `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).
|
|
||||||
|
|
||||||
### Client (`src/client/`)
|
Env vars are stored per-app in `TOES_DIR/env/`:
|
||||||
|
|
||||||
Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx` files directly.
|
```
|
||||||
|
${DATA_DIR}/toes/env/
|
||||||
|
clock.env # env vars for clock app
|
||||||
|
todo.env # env vars for todo app
|
||||||
|
```
|
||||||
|
|
||||||
- `index.tsx` -- Entry point. Initializes rendering, SSE connection, theme, tool iframes.
|
`TOES_DIR` defaults to `${DATA_DIR}/toes`. Apps cannot access this directory directly.
|
||||||
- `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/`)
|
## Current State
|
||||||
|
|
||||||
- `index.ts` -- Entry point (`#!/usr/bin/env bun`).
|
### Infrastructure (Complete)
|
||||||
- `setup.ts` -- Commander program definition with all commands.
|
- App discovery, spawn, watch, auto-restart with exponential backoff
|
||||||
- `commands/` -- Command implementations.
|
- Health checks every 30s (3 failures trigger restart)
|
||||||
- `http.ts` -- HTTP client for talking to the toes server.
|
- Port pool (3001-3100), sticky allocation per app
|
||||||
- `name.ts` -- App name resolution (argument or current directory).
|
- SSE streams for real-time app state and log updates
|
||||||
- `prompts.ts` -- Interactive prompts.
|
- File sync protocol with hash-based manifests
|
||||||
- `pager.ts` -- Pipe output through system pager.
|
|
||||||
|
|
||||||
CLI commands:
|
### CLI
|
||||||
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
|
- Full management: `toes list|start|stop|restart|info|new|rename|delete|open`
|
||||||
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`
|
- File sync: `toes push|pull|sync`
|
||||||
- **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash`
|
- Logs: `toes logs [-f] <app>`
|
||||||
- **Config**: `config`, `env`, `versions`, `history`, `rollback`
|
|
||||||
|
|
||||||
### Shared (`src/shared/`)
|
Check `TODO.txt` for planned features
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -212,7 +223,3 @@ 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.
|
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -4,28 +4,11 @@ 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.
|
||||||
|
|
||||||
## setup
|
## quickstart
|
||||||
|
|
||||||
Toes runs on a Raspberry Pi. You'll need:
|
1. Plug in and turn on your Toes computer.
|
||||||
|
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
|
||||||
- A Raspberry Pi running Raspberry Pi OS
|
3. Visit https://toes.local to get started!
|
||||||
- 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.
|
||||||
|
|
@ -40,8 +23,9 @@ 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 # connect to IP
|
TOES_URL=http://192.168.1.50:3000 toes list # full URL
|
||||||
TOES_URL=http://mypi.local toes list # connect to hostname
|
TOES_HOST=mypi.local toes list # hostname (port 80)
|
||||||
|
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`.
|
||||||
|
|
@ -50,7 +34,8 @@ 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.
|
||||||
- non-webapps
|
- HTTPS Tunnel for sharing your apps with the world.
|
||||||
|
- Charts and graphs in the webUI.
|
||||||
|
|
||||||
## february goal
|
## february goal
|
||||||
|
|
||||||
|
|
|
||||||
55
TODO.txt
Normal file
55
TODO.txt
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# toes
|
||||||
|
|
||||||
|
## server
|
||||||
|
|
||||||
|
[x] start toes server
|
||||||
|
[x] scans for apps/**/package.json, scripts.toes
|
||||||
|
[x] runs that for each, giving it a PORT
|
||||||
|
[x] has GET / page that shows all the running apps/status/port
|
||||||
|
[x] watch each app and restart it on update
|
||||||
|
[x] watches for and adds/removes apps
|
||||||
|
[ ] run on rpi on boot
|
||||||
|
[ ] found at http://toes.local
|
||||||
|
[ ] https?
|
||||||
|
[ ] apps are subdomains (verify if this works w/ chrome+safari)
|
||||||
|
[ ] if not: apps get ports but on the proper domain ^^
|
||||||
|
|
||||||
|
## apps
|
||||||
|
|
||||||
|
[x] truism
|
||||||
|
[x] big clock
|
||||||
|
[ ] shrimp repl(?)
|
||||||
|
[ ] dungeon party
|
||||||
|
|
||||||
|
## cli
|
||||||
|
|
||||||
|
[x] `toes --help`
|
||||||
|
[x] `toes --version`
|
||||||
|
[x] `toes list`
|
||||||
|
[x] `toes start <app>`
|
||||||
|
[x] `toes stop <app>`
|
||||||
|
[x] `toes restart <app>`
|
||||||
|
[x] `toes open <app>`
|
||||||
|
[x] `toes logs <app>`
|
||||||
|
[x] `toes logs -f <app>`
|
||||||
|
[x] `toes info <app>`
|
||||||
|
[x] `toes new`
|
||||||
|
[x] `toes pull`
|
||||||
|
[x] `toes push`
|
||||||
|
[x] `toes sync`
|
||||||
|
[x] `toes new --spa`
|
||||||
|
[x] `toes new --ssr`
|
||||||
|
[x] `toes new --bare`
|
||||||
|
[ ] needs to either check toes.local or take something like TOES_URL
|
||||||
|
|
||||||
|
## webui
|
||||||
|
|
||||||
|
[x] list projects
|
||||||
|
[x] start/stop/restart project
|
||||||
|
[x] create project
|
||||||
|
[x] todo.txt
|
||||||
|
[x] tools
|
||||||
|
[x] code browser
|
||||||
|
[x] versioned pushes
|
||||||
|
[x] version browser
|
||||||
|
[ ] ...
|
||||||
38
apps/basic/20260130-000000/bun.lock
Normal file
38
apps/basic/20260130-000000/bun.lock
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/basic/20260130-000000/index.tsx
Normal file
10
apps/basic/20260130-000000/index.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<h1>Hi there!</h1>))
|
||||||
|
|
||||||
|
const apps = () => {
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
21
apps/basic/20260130-000000/package.json
Normal file
21
apps/basic/20260130-000000/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "basic",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx",
|
||||||
|
"start": "bun toes",
|
||||||
|
"dev": "bun run --hot index.tsx"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "^0.0.1",
|
||||||
|
"@because/hype": "^0.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
|
|
@ -7,15 +8,21 @@
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "hono/jsx",
|
"jsxImportSource": "hono/jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false
|
||||||
1
apps/basic/current
Symbolic link
1
apps/basic/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-000000
|
||||||
|
|
@ -45,8 +45,6 @@ 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
apps/clock/current
Symbolic link
1
apps/clock/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-000000
|
||||||
|
|
@ -5,16 +5,16 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "code",
|
"name": "code",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "*",
|
||||||
"@because/howl": "^0.0.2",
|
"@because/howl": "*",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "*",
|
||||||
"@because/toes": "^0.0.5",
|
"@because/toes": "*",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -23,9 +23,9 @@
|
||||||
|
|
||||||
"@because/howl": ["@because/howl@0.0.2", "https://npm.nose.space/@because/howl/-/howl-0.0.2.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-Z4okzEa282LKkBk9DQwEUU6FT+PeThfQ6iQAY41LIEjs8B2kfXRZnbWLs7tgpwCfYORxb0RO4Hr7KiyEqnfTvQ=="],
|
"@because/howl": ["@because/howl@0.0.2", "https://npm.nose.space/@because/howl/-/howl-0.0.2.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-Z4okzEa282LKkBk9DQwEUU6FT+PeThfQ6iQAY41LIEjs8B2kfXRZnbWLs7tgpwCfYORxb0RO4Hr7KiyEqnfTvQ=="],
|
||||||
|
|
||||||
"@because/hype": ["@because/hype@0.0.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=="],
|
"@because/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,12 @@
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "*",
|
||||||
"@because/howl": "^0.0.2",
|
"@because/howl": "*",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "*",
|
||||||
"@because/toes": "^0.0.5"
|
"@because/toes": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
||||||
import { readdir, stat } from 'fs/promises'
|
import { readdir, stat } from 'fs/promises'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { join, extname, basename } from 'path'
|
import { join, extname, basename } from 'path'
|
||||||
|
|
@ -88,9 +88,6 @@ const CodeHeader = define('CodeHeader', {
|
||||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const ErrorBox = define('ErrorBox', {
|
const ErrorBox = define('ErrorBox', {
|
||||||
|
|
@ -193,46 +190,6 @@ const DownloadButton = define('DownloadButton', {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const EditButton = define('EditButton', {
|
|
||||||
base: 'button',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
padding: '6px 12px',
|
|
||||||
backgroundColor: theme('colors-bgElement'),
|
|
||||||
color: theme('colors-text'),
|
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
fontSize: '13px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
states: {
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: theme('colors-bgHover'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const EditLink = define('EditLink', {
|
|
||||||
base: 'a',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
padding: '6px 12px',
|
|
||||||
backgroundColor: theme('colors-bgElement'),
|
|
||||||
color: theme('colors-text'),
|
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
fontSize: '13px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textDecoration: 'none',
|
|
||||||
states: {
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: theme('colors-bgHover'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const FolderIcon = () => (
|
const FolderIcon = () => (
|
||||||
<FileIcon viewBox="0 0 24 24">
|
<FileIcon viewBox="0 0 24 24">
|
||||||
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||||
|
|
@ -249,7 +206,6 @@ interface LayoutProps {
|
||||||
title: string
|
title: string
|
||||||
children: Child
|
children: Child
|
||||||
highlight?: boolean
|
highlight?: boolean
|
||||||
editable?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileMemoryScript = `
|
const fileMemoryScript = `
|
||||||
|
|
@ -277,7 +233,7 @@ const fileMemoryScript = `
|
||||||
})();
|
})();
|
||||||
`
|
`
|
||||||
|
|
||||||
function Layout({ title, children, highlight, editable }: LayoutProps) {
|
function Layout({ title, children, highlight }: LayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -285,42 +241,26 @@ function Layout({ title, children, highlight, editable }: LayoutProps) {
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
{highlight && !editable && (
|
{highlight && (
|
||||||
<>
|
<>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)" />
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{editable && (
|
|
||||||
<>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" media="(prefers-color-scheme: light)" />
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" media="(prefers-color-scheme: dark)" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-tsx.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script dangerouslySetInnerHTML={{ __html: fileMemoryScript }} />
|
<script dangerouslySetInnerHTML={{ __html: fileMemoryScript }} />
|
||||||
<ToolScript />
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
<Container>
|
<Container>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
{highlight && !editable && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
|
{highlight && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/ok', c => c.text('ok'))
|
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
@ -344,26 +284,6 @@ app.get('/raw', async c => {
|
||||||
return new Response(file)
|
return new Response(file)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/save', async c => {
|
|
||||||
const appName = c.req.query('app')
|
|
||||||
const version = c.req.query('version') || 'current'
|
|
||||||
const filePath = c.req.query('file')
|
|
||||||
|
|
||||||
if (!appName || !filePath) {
|
|
||||||
return c.text('Missing app or file parameter', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = join(APPS_DIR, appName, version, filePath)
|
|
||||||
const content = await c.req.text()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Bun.write(fullPath, content)
|
|
||||||
return c.text('OK')
|
|
||||||
} catch (err) {
|
|
||||||
return c.text(`Failed to save: ${err}`, 500)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function listFiles(appPath: string, subPath: string = '') {
|
async function listFiles(appPath: string, subPath: string = '') {
|
||||||
const fullPath = join(appPath, subPath)
|
const fullPath = join(appPath, subPath)
|
||||||
const entries = await readdir(fullPath, { withFileTypes: true })
|
const entries = await readdir(fullPath, { withFileTypes: true })
|
||||||
|
|
@ -453,29 +373,6 @@ 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')
|
||||||
|
|
@ -581,91 +478,12 @@ app.get('/', async c => {
|
||||||
// Text file - show with syntax highlighting
|
// Text file - show with syntax highlighting
|
||||||
const content = readFileSync(fullPath, 'utf-8')
|
const content = readFileSync(fullPath, 'utf-8')
|
||||||
const language = getLanguage(filename)
|
const language = getLanguage(filename)
|
||||||
const prismLang = getPrismLanguage(filename)
|
|
||||||
const edit = c.req.query('edit') === '1'
|
|
||||||
|
|
||||||
if (edit) {
|
|
||||||
const editorScript = `
|
|
||||||
import { CodeJar } from 'https://cdn.jsdelivr.net/npm/codejar@4.2.0/dist/codejar.js';
|
|
||||||
|
|
||||||
const editor = document.getElementById('editor');
|
|
||||||
const saveBtn = document.getElementById('save-btn');
|
|
||||||
const status = document.getElementById('save-status');
|
|
||||||
let dirty = false;
|
|
||||||
|
|
||||||
const highlight = (el) => {
|
|
||||||
Prism.highlightElement(el);
|
|
||||||
};
|
|
||||||
|
|
||||||
const jar = CodeJar(editor, highlight, { tab: ' ', addClosing: false });
|
|
||||||
|
|
||||||
// Initial highlight
|
|
||||||
highlight(editor);
|
|
||||||
|
|
||||||
jar.onUpdate(() => {
|
|
||||||
if (!dirty) {
|
|
||||||
dirty = true;
|
|
||||||
saveBtn.textContent = 'Save *';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
saveBtn.onclick = async () => {
|
|
||||||
if (!dirty) return;
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
status.textContent = 'Saving...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
|
||||||
body: jar.toString()
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Save failed');
|
|
||||||
dirty = false;
|
|
||||||
saveBtn.textContent = 'Save';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
status.textContent = 'Saved!';
|
|
||||||
setTimeout(() => { status.textContent = ''; }, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
status.textContent = 'Error: ' + err.message;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
||||||
e.preventDefault();
|
|
||||||
saveBtn.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`
|
|
||||||
return c.html(
|
|
||||||
<Layout title={`${appName}/${filePath}`} editable>
|
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
|
||||||
<CodeBlock>
|
|
||||||
<CodeHeader>
|
|
||||||
<span>{filename}</span>
|
|
||||||
<div style="display:flex;align-items:center;gap:8px">
|
|
||||||
<span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span>
|
|
||||||
<EditButton id="save-btn">Save</EditButton>
|
|
||||||
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}`}>Done</EditLink>
|
|
||||||
</div>
|
|
||||||
</CodeHeader>
|
|
||||||
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
|
|
||||||
</CodeBlock>
|
|
||||||
<script type="module" dangerouslySetInnerHTML={{ __html: editorScript }} />
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`} highlight>
|
<Layout title={`${appName}/${filePath}`} highlight>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||||
<CodeBlock>
|
<CodeBlock>
|
||||||
<CodeHeader>
|
<CodeHeader>{filename}</CodeHeader>
|
||||||
<span>{filename}</span>
|
|
||||||
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
|
|
||||||
</CodeHeader>
|
|
||||||
<pre><code class={`language-${language}`}>{content}</code></pre>
|
<pre><code class={`language-${language}`}>{content}</code></pre>
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
1
apps/code/current
Symbolic link
1
apps/code/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-000000
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "cron",
|
|
||||||
"dependencies": {
|
|
||||||
"@because/forge": "^0.0.1",
|
|
||||||
"@because/hype": "^0.0.2",
|
|
||||||
"@because/toes": "^0.0.8",
|
|
||||||
"croner": "^9.1.0",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
|
||||||
|
|
||||||
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
|
||||||
|
|
||||||
"@because/sneaker": ["@because/sneaker@0.0.1", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.1.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-rN9hc13ofap+7SvfShJkTJQYBcViCiElyfb8FBMzP1SKIe8B71csZeLh+Ujye/5538ojWfM/5hRRPJ+Aa/0A+g=="],
|
|
||||||
|
|
||||||
"@because/toes": ["@because/toes@0.0.8", "https://npm.nose.space/@because/toes/-/toes-0.0.8.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.1", "commander": "^14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-ei4X+yX97dCRqAHSfsVnE4vAIAMkhG9v1WKW3whlo+BMm3TNdKuEv1o2PQpVfIChSGzO/05Y/YFbd/XdI7p/Kg=="],
|
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.2.2", "https://npm.nose.space/@types/node/-/node-25.2.2.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
|
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
|
||||||
|
|
||||||
"croner": ["croner@9.1.0", "https://npm.nose.space/croner/-/croner-9.1.0.tgz", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
|
|
||||||
|
|
||||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
|
||||||
|
|
||||||
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
||||||
|
|
||||||
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,701 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
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} * * *`
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"types": ["bun"],
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "hono/jsx"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
327
apps/env/20260130-000000/index.tsx
vendored
327
apps/env/20260130-000000/index.tsx
vendored
|
|
@ -7,38 +7,9 @@ import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
const TOES_DIR = process.env.TOES_DIR ?? join(process.env.HOME!, '.toes')
|
const TOES_DIR = process.env.TOES_DIR ?? join(process.env.HOME!, '.toes')
|
||||||
const ENV_DIR = join(TOES_DIR, 'env')
|
const ENV_DIR = join(TOES_DIR, 'env')
|
||||||
const GLOBAL_ENV_PATH = join(ENV_DIR, '_global.env')
|
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
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', {
|
const Container = define('Container', {
|
||||||
fontFamily: theme('fonts-sans'),
|
fontFamily: theme('fonts-sans'),
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
|
|
@ -48,34 +19,25 @@ const Container = define('Container', {
|
||||||
color: theme('colors-text'),
|
color: theme('colors-text'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const DangerButton = define('DangerButton', {
|
const Header = define('Header', {
|
||||||
base: 'button',
|
marginBottom: '20px',
|
||||||
padding: '6px 12px',
|
paddingBottom: '10px',
|
||||||
fontSize: '13px',
|
borderBottom: `2px solid ${theme('colors-border')}`,
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
border: 'none',
|
|
||||||
backgroundColor: theme('colors-error'),
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
states: {
|
|
||||||
':hover': {
|
|
||||||
opacity: 0.9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const EmptyState = define('EmptyState', {
|
const Title = define('Title', {
|
||||||
padding: '30px',
|
margin: 0,
|
||||||
textAlign: 'center',
|
fontSize: '24px',
|
||||||
color: theme('colors-textMuted'),
|
fontWeight: 'bold',
|
||||||
backgroundColor: theme('colors-bgSubtle'),
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const EnvActions = define('EnvActions', {
|
const EnvList = define('EnvList', {
|
||||||
display: 'flex',
|
listStyle: 'none',
|
||||||
gap: '8px',
|
padding: 0,
|
||||||
flexShrink: 0,
|
margin: 0,
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflow: 'hidden',
|
||||||
})
|
})
|
||||||
|
|
||||||
const EnvItem = define('EnvItem', {
|
const EnvItem = define('EnvItem', {
|
||||||
|
|
@ -99,15 +61,6 @@ const EnvKey = define('EnvKey', {
|
||||||
color: theme('colors-text'),
|
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', {
|
const EnvValue = define('EnvValue', {
|
||||||
fontFamily: theme('fonts-mono'),
|
fontFamily: theme('fonts-mono'),
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
|
@ -118,12 +71,42 @@ const EnvValue = define('EnvValue', {
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
})
|
})
|
||||||
|
|
||||||
const ErrorBox = define('ErrorBox', {
|
const EnvActions = define('EnvActions', {
|
||||||
color: theme('colors-error'),
|
display: 'flex',
|
||||||
padding: '20px',
|
gap: '8px',
|
||||||
backgroundColor: theme('colors-bgElement'),
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const Button = define('Button', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: theme('radius-md'),
|
||||||
margin: '20px 0',
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: theme('colors-bgHover'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DangerButton = define('DangerButton', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: theme('colors-error'),
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const Form = define('Form', {
|
const Form = define('Form', {
|
||||||
|
|
@ -136,12 +119,6 @@ const Form = define('Form', {
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: theme('radius-md'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Hint = define('Hint', {
|
|
||||||
fontSize: '12px',
|
|
||||||
color: theme('colors-textMuted'),
|
|
||||||
marginTop: '10px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const Input = define('Input', {
|
const Input = define('Input', {
|
||||||
base: 'input',
|
base: 'input',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
@ -160,54 +137,27 @@ const Input = define('Input', {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const Tab = define('Tab', {
|
const ErrorBox = define('ErrorBox', {
|
||||||
base: 'a',
|
color: theme('colors-error'),
|
||||||
padding: '8px 16px',
|
padding: '20px',
|
||||||
fontSize: '13px',
|
backgroundColor: theme('colors-bgElement'),
|
||||||
fontFamily: theme('fonts-sans'),
|
borderRadius: theme('radius-md'),
|
||||||
|
margin: '20px 0',
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptyState = define('EmptyState', {
|
||||||
|
padding: '30px',
|
||||||
|
textAlign: 'center',
|
||||||
color: theme('colors-textMuted'),
|
color: theme('colors-textMuted'),
|
||||||
textDecoration: 'none',
|
backgroundColor: theme('colors-bgSubtle'),
|
||||||
borderBottom: '2px solid transparent',
|
borderRadius: theme('radius-md'),
|
||||||
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 {
|
interface LayoutProps {
|
||||||
title: string
|
title: string
|
||||||
children: Child
|
children: Child
|
||||||
}
|
}
|
||||||
|
|
||||||
const appEnvPath = (appName: string) =>
|
|
||||||
join(ENV_DIR, `${appName}.env`)
|
|
||||||
|
|
||||||
function Layout({ title, children }: LayoutProps) {
|
function Layout({ title, children }: LayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -220,6 +170,9 @@ function Layout({ title, children }: LayoutProps) {
|
||||||
<body>
|
<body>
|
||||||
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
<Container>
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>Environment Variables</Title>
|
||||||
|
</Header>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
<script dangerouslySetInnerHTML={{ __html: clientScript }} />
|
<script dangerouslySetInnerHTML={{ __html: clientScript }} />
|
||||||
|
|
@ -228,6 +181,29 @@ function Layout({ title, children }: LayoutProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientScript = `
|
||||||
|
document.querySelectorAll('[data-reveal]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const valueEl = btn.closest('[data-env-item]').querySelector('[data-value]');
|
||||||
|
const hidden = valueEl.dataset.hidden;
|
||||||
|
if (hidden) {
|
||||||
|
valueEl.textContent = hidden;
|
||||||
|
valueEl.dataset.hidden = '';
|
||||||
|
btn.textContent = 'Hide';
|
||||||
|
} else {
|
||||||
|
valueEl.dataset.hidden = valueEl.textContent;
|
||||||
|
valueEl.textContent = '••••••••';
|
||||||
|
btn.textContent = 'Reveal';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`
|
||||||
|
|
||||||
|
interface EnvVar {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
function ensureEnvDir() {
|
function ensureEnvDir() {
|
||||||
if (!existsSync(ENV_DIR)) {
|
if (!existsSync(ENV_DIR)) {
|
||||||
mkdirSync(ENV_DIR, { recursive: true })
|
mkdirSync(ENV_DIR, { recursive: true })
|
||||||
|
|
@ -268,29 +244,9 @@ function writeEnvFile(path: string, vars: EnvVar[]) {
|
||||||
writeFileSync(path, content)
|
writeFileSync(path, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientScript = `
|
function appEnvPath(appName: string): string {
|
||||||
document.querySelectorAll('[data-reveal]').forEach(btn => {
|
return join(ENV_DIR, `${appName}.env`)
|
||||||
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, {
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
|
@ -307,88 +263,26 @@ app.get('/', async c => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 appVars = parseEnvFile(appEnvPath(appName))
|
||||||
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
|
|
||||||
const globalKeys = new Set(globalVars.map(v => v.key))
|
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`Env - ${appName}`}>
|
<Layout title={`Env - ${appName}`}>
|
||||||
<TabBar>
|
{appVars.length === 0 ? (
|
||||||
<TabActive href={appUrl}>App</TabActive>
|
|
||||||
<Tab href={globalUrl}>Global</Tab>
|
|
||||||
</TabBar>
|
|
||||||
{appVars.length === 0 && globalKeys.size === 0 ? (
|
|
||||||
<EmptyState>No environment variables</EmptyState>
|
<EmptyState>No environment variables</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<EnvList>
|
<EnvList>
|
||||||
{appVars.map(v => (
|
{appVars.map(v => (
|
||||||
<EnvItem data-env-item>
|
<EnvItem data-env-item>
|
||||||
<EnvKey>
|
<EnvKey>{v.key}</EnvKey>
|
||||||
{v.key}
|
|
||||||
{globalKeys.has(v.key) && <Badge>overrides global</Badge>}
|
|
||||||
</EnvKey>
|
|
||||||
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
|
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
|
||||||
<EnvActions>
|
<EnvActions>
|
||||||
<Button data-reveal>Reveal</Button>
|
<Button data-reveal>Reveal</Button>
|
||||||
<form method="post" action={`/delete?app=${appName}&key=${v.key}`} style="margin:0">
|
<form method="POST" action={`/delete?app=${appName}&key=${v.key}`} style="margin:0">
|
||||||
<DangerButton type="submit">Delete</DangerButton>
|
<DangerButton type="submit">Delete</DangerButton>
|
||||||
</form>
|
</form>
|
||||||
</EnvActions>
|
</EnvActions>
|
||||||
</EnvItem>
|
</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>
|
</EnvList>
|
||||||
)}
|
)}
|
||||||
<Form method="POST" action={`/set?app=${appName}`}>
|
<Form method="POST" action={`/set?app=${appName}`}>
|
||||||
|
|
@ -435,37 +329,4 @@ app.post('/delete', async c => {
|
||||||
return c.redirect(`/?app=${appName}`)
|
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
|
export default app.defaults
|
||||||
|
|
|
||||||
1
apps/env/current
vendored
Symbolic link
1
apps/env/current
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-000000
|
||||||
1
apps/file.txt
Normal file
1
apps/file.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
hi
|
||||||
File diff suppressed because it is too large
Load Diff
38
apps/profile/20260130-000000/bun.lock
Normal file
38
apps/profile/20260130-000000/bun.lock
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/profile/20260130-000000/index.tsx
Normal file
7
apps/profile/20260130-000000/index.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<h1>My Profile!!!</h1>))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
|
@ -1,26 +1,24 @@
|
||||||
{
|
{
|
||||||
"name": "metrics",
|
"name": "profile",
|
||||||
"module": "index.tsx",
|
"module": "src/index.ts",
|
||||||
"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.3"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.1"
|
||||||
"@because/toes": "^0.0.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,43 +1,30 @@
|
||||||
{
|
{
|
||||||
"exclude": ["apps", "templates"],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": [
|
"lib": ["ESNext"],
|
||||||
"ESNext",
|
|
||||||
"DOM"
|
|
||||||
],
|
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "hono/jsx",
|
"jsxImportSource": "hono/jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|
||||||
// Bundler mode
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
// Best practices
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"$*": [
|
|
||||||
"./src/server/*"
|
|
||||||
],
|
|
||||||
"@*": [
|
|
||||||
"./src/shared/*"
|
|
||||||
],
|
|
||||||
"%*": [
|
|
||||||
"./src/lib/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
apps/profile/current
Symbolic link
1
apps/profile/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-000000
|
||||||
1
apps/risk/20260130-000000/.npmrc
Normal file
1
apps/risk/20260130-000000/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
1
apps/risk/20260130-000000/package.json
Normal file
1
apps/risk/20260130-000000/package.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
1
apps/risk/current
Symbolic link
1
apps/risk/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-000000
|
||||||
1
apps/todo/20260130-181927/.npmrc
Normal file
1
apps/todo/20260130-181927/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
|
|
@ -3,30 +3,30 @@
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "stats",
|
"name": "todo",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "*",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "*",
|
||||||
"@because/toes": "^0.0.5",
|
"@because/toes": "*",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
"@because/forge": ["@because/forge@0.0.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/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=="],
|
"@because/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
"@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
"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=="],
|
||||||
|
|
||||||
1
apps/todo/20260130-181927/index.tsx
Normal file
1
apps/todo/20260130-181927/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './src/server'
|
||||||
26
apps/todo/20260130-181927/package.json
Normal file
26
apps/todo/20260130-181927/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "todo",
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx",
|
||||||
|
"start": "bun toes",
|
||||||
|
"dev": "bun run --hot index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"tool": "TODO",
|
||||||
|
"icon": "✅"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/toes": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/todo/20260130-181927/pub/img/bite1.png
Normal file
BIN
apps/todo/20260130-181927/pub/img/bite1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
apps/todo/20260130-181927/pub/img/bite2.png
Normal file
BIN
apps/todo/20260130-181927/pub/img/bite2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/todo/20260130-181927/pub/img/burger.png
Normal file
BIN
apps/todo/20260130-181927/pub/img/burger.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
36
apps/todo/20260130-181927/src/client/App.tsx
Normal file
36
apps/todo/20260130-181927/src/client/App.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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)
|
||||||
40
apps/todo/20260130-181927/src/css/main.css
Normal file
40
apps/todo/20260130-181927/src/css/main.css
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
31
apps/todo/20260130-181927/src/pages/index.tsx
Normal file
31
apps/todo/20260130-181927/src/pages/index.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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>
|
||||||
|
</>
|
||||||
363
apps/todo/20260130-181927/src/server/index.tsx
Normal file
363
apps/todo/20260130-181927/src/server/index.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
const Container = define('TodoContainer', {
|
||||||
|
fontFamily: theme('fonts-sans'),
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Header = define('Header', {
|
||||||
|
marginBottom: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = define('Title', {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
})
|
||||||
|
|
||||||
|
const AppName = define('AppName', {
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
fontSize: '14px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TodoList = define('TodoList', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const TodoSection = define('TodoSection', {
|
||||||
|
marginBottom: '24px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const SectionTitle = define('SectionTitle', {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
marginBottom: '12px',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const TodoItemStyle = define('TodoItem', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: '8px 0',
|
||||||
|
gap: '10px',
|
||||||
|
selectors: {
|
||||||
|
'& input[type="checkbox"]': {
|
||||||
|
marginTop: '3px',
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'& label': {
|
||||||
|
flex: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const doneClass = 'todo-done'
|
||||||
|
|
||||||
|
const Error = define('Error', {
|
||||||
|
color: theme('colors-error'),
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: theme('colors-bgElement'),
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
margin: '20px 0',
|
||||||
|
})
|
||||||
|
|
||||||
|
const successClass = 'msg-success'
|
||||||
|
const errorClass = 'msg-error'
|
||||||
|
|
||||||
|
const SaveButton = define('SaveButton', {
|
||||||
|
base: 'button',
|
||||||
|
backgroundColor: theme('colors-primary'),
|
||||||
|
color: theme('colors-primaryText'),
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
':disabled': {
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddForm = define('AddForm', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddInput = define('AddInput', {
|
||||||
|
base: 'input',
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
fontSize: '14px',
|
||||||
|
backgroundColor: theme('colors-bg'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
states: {
|
||||||
|
':focus': {
|
||||||
|
outline: 'none',
|
||||||
|
borderColor: theme('colors-primary'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const todoStyles = `
|
||||||
|
.${doneClass} {
|
||||||
|
color: ${theme('colors-done')};
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.${successClass} {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: ${theme('radius-md')};
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background-color: ${theme('colors-successBg')};
|
||||||
|
color: ${theme('colors-success')};
|
||||||
|
}
|
||||||
|
.${errorClass} {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: ${theme('radius-md')};
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background-color: ${theme('colors-bgElement')};
|
||||||
|
color: ${theme('colors-error')};
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface TodoEntry {
|
||||||
|
text: string
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedTodo {
|
||||||
|
title: string
|
||||||
|
items: TodoEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTodoFile(content: string): ParsedTodo {
|
||||||
|
const lines = content.split('\n')
|
||||||
|
let title = 'TODO'
|
||||||
|
const items: TodoEntry[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
|
||||||
|
if (trimmed.startsWith('# ')) {
|
||||||
|
title = trimmed.slice(2)
|
||||||
|
} else if (trimmed.startsWith('[x] ') || trimmed.startsWith('[X] ')) {
|
||||||
|
items.push({ text: trimmed.slice(4), done: true })
|
||||||
|
} else if (trimmed.startsWith('[ ] ')) {
|
||||||
|
items.push({ text: trimmed.slice(4), done: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, items }
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTodo(todo: ParsedTodo): string {
|
||||||
|
const lines = [`# ${todo.title}`]
|
||||||
|
for (const item of todo.items) {
|
||||||
|
lines.push(item.done ? `[x] ${item.text}` : `[ ] ${item.text}`)
|
||||||
|
}
|
||||||
|
return lines.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/styles.css', c => c.text(baseStyles + todoStyles + stylesToCSS(), 200, {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
const message = c.req.query('message')
|
||||||
|
const messageType = c.req.query('type') as 'success' | 'error' | undefined
|
||||||
|
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TODO</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>TODO</Title>
|
||||||
|
</Header>
|
||||||
|
<Error>Select an app to view its TODO list</Error>
|
||||||
|
</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
|
||||||
|
|
||||||
|
let todo: ParsedTodo
|
||||||
|
if (existsSync(todoPath)) {
|
||||||
|
const content = readFileSync(todoPath, 'utf-8')
|
||||||
|
todo = parseTodoFile(content)
|
||||||
|
} else {
|
||||||
|
todo = { title: `${appName} TODO`, items: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingItems = todo.items.filter(i => !i.done)
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{todo.title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<div>
|
||||||
|
<Title>{todo.title}</Title>
|
||||||
|
<AppName>{appName}/TODO.txt</AppName>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div class={messageType === 'success' ? successClass : errorClass}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action="/add" method="post">
|
||||||
|
<input type="hidden" name="app" value={appName} />
|
||||||
|
<AddForm>
|
||||||
|
<AddInput type="text" name="text" placeholder="Add a new todo..." required />
|
||||||
|
<SaveButton type="submit">Add</SaveButton>
|
||||||
|
</AddForm>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{todo.items.length > 0 && (
|
||||||
|
<TodoSection>
|
||||||
|
<SectionTitle>
|
||||||
|
{pendingItems.length === 0 ? 'All done!' : `Pending (${pendingItems.length})`}
|
||||||
|
</SectionTitle>
|
||||||
|
<TodoList>
|
||||||
|
{todo.items.map((item, i) => (
|
||||||
|
<TodoItemStyle key={i}>
|
||||||
|
<form action="/toggle" method="post" style={{ display: 'contents' }}>
|
||||||
|
<input type="hidden" name="app" value={appName} />
|
||||||
|
<input type="hidden" name="index" value={i.toString()} />
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`item-${i}`}
|
||||||
|
checked={item.done}
|
||||||
|
onchange="this.form.submit()"
|
||||||
|
/>
|
||||||
|
<label for={`item-${i}`} class={item.done ? doneClass : ''}>
|
||||||
|
{item.text}
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</TodoItemStyle>
|
||||||
|
))}
|
||||||
|
</TodoList>
|
||||||
|
</TodoSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{todo.items.length === 0 && (
|
||||||
|
<TodoSection>
|
||||||
|
<SectionTitle>No todos yet</SectionTitle>
|
||||||
|
<p style={{ color: theme('colors-textMuted') }}>Add your first todo above!</p>
|
||||||
|
</TodoSection>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/toggle', async c => {
|
||||||
|
const form = await c.req.formData()
|
||||||
|
const appName = form.get('app') as string
|
||||||
|
const index = parseInt(form.get('index') as string, 10)
|
||||||
|
|
||||||
|
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
|
||||||
|
|
||||||
|
let todo: ParsedTodo
|
||||||
|
if (existsSync(todoPath)) {
|
||||||
|
const content = readFileSync(todoPath, 'utf-8')
|
||||||
|
todo = parseTodoFile(content)
|
||||||
|
} else {
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= 0 && index < todo.items.length) {
|
||||||
|
todo.items[index].done = !todo.items[index].done
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(todoPath, serializeTodo(todo))
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
} catch {
|
||||||
|
return c.redirect(`/?app=${appName}&message=${encodeURIComponent('Failed to save')}&type=error`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/add', async c => {
|
||||||
|
const form = await c.req.formData()
|
||||||
|
const appName = form.get('app') as string
|
||||||
|
const text = (form.get('text') as string).trim()
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
|
||||||
|
|
||||||
|
let todo: ParsedTodo
|
||||||
|
if (existsSync(todoPath)) {
|
||||||
|
const content = readFileSync(todoPath, 'utf-8')
|
||||||
|
todo = parseTodoFile(content)
|
||||||
|
} else {
|
||||||
|
todo = { title: `${appName} TODO`, items: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
todo.items.push({ text, done: false })
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(todoPath, serializeTodo(todo))
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
} catch {
|
||||||
|
return c.redirect(`/?app=${appName}&message=${encodeURIComponent('Failed to add')}&type=error`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
0
apps/todo/20260130-181927/src/shared/types.ts
Normal file
0
apps/todo/20260130-181927/src/shared/types.ts
Normal file
29
apps/todo/20260130-181927/tsconfig.json
Normal file
29
apps/todo/20260130-181927/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": ["src/server/*"],
|
||||||
|
"#*": ["src/client/*"],
|
||||||
|
"@*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/todo/current
Symbolic link
1
apps/todo/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-181927
|
||||||
|
|
@ -46,8 +46,6 @@ 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
apps/truisms/current
Symbolic link
1
apps/truisms/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-000000
|
||||||
|
|
@ -5,24 +5,24 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "versions",
|
"name": "versions",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "*",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "*",
|
||||||
"@because/toes": "^0.0.5",
|
"@because/toes": "*",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
"@because/forge": ["@because/forge@0.0.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/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=="],
|
"@because/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
||||||
import { readdir, readlink, stat } from 'fs/promises'
|
import { readdir, readlink, stat } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import type { Child } from 'hono/jsx'
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
@ -19,6 +19,24 @@ const Container = define('Container', {
|
||||||
color: theme('colors-text'),
|
color: theme('colors-text'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const Header = define('Header', {
|
||||||
|
marginBottom: '20px',
|
||||||
|
paddingBottom: '10px',
|
||||||
|
borderBottom: `2px solid ${theme('colors-border')}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = define('Title', {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Subtitle = define('Subtitle', {
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
fontSize: '18px',
|
||||||
|
marginTop: '5px',
|
||||||
|
})
|
||||||
|
|
||||||
const VersionList = define('VersionList', {
|
const VersionList = define('VersionList', {
|
||||||
listStyle: 'none',
|
listStyle: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
|
@ -77,10 +95,11 @@ const ErrorBox = define('ErrorBox', {
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
title: string
|
title: string
|
||||||
|
subtitle?: string
|
||||||
children: Child
|
children: Child
|
||||||
}
|
}
|
||||||
|
|
||||||
function Layout({ title, children }: LayoutProps) {
|
function Layout({ title, subtitle, children }: LayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -90,8 +109,11 @@ function Layout({ title, children }: LayoutProps) {
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ToolScript />
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
<Container>
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>Versions</Title>
|
||||||
|
</Header>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -99,8 +121,6 @@ function Layout({ title, children }: LayoutProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/ok', c => c.text('ok'))
|
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
@ -150,14 +170,14 @@ app.get('/', async c => {
|
||||||
|
|
||||||
if (versions.length === 0) {
|
if (versions.length === 0) {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title="Versions">
|
<Layout title="Versions" subtitle={appName}>
|
||||||
<ErrorBox>No versions found</ErrorBox>
|
<ErrorBox>No versions found</ErrorBox>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title="Versions">
|
<Layout title="Versions" subtitle={appName}>
|
||||||
<VersionList>
|
<VersionList>
|
||||||
{versions.map(v => (
|
{versions.map(v => (
|
||||||
<VersionItem>
|
<VersionItem>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,11 @@
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "*",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "*",
|
||||||
"@because/toes": "^0.0.5"
|
"@because/toes": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
apps/versions/current
Symbolic link
1
apps/versions/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-000000
|
||||||
21
bun.lock
21
bun.lock
|
|
@ -3,12 +3,11 @@
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@because/toes",
|
"name": "toes",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.2",
|
||||||
"@because/sneaker": "^0.0.3",
|
"commander": "^14.0.2",
|
||||||
"commander": "^14.0.3",
|
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
},
|
},
|
||||||
|
|
@ -17,7 +16,7 @@
|
||||||
"@types/diff": "^8.0.0",
|
"@types/diff": "^8.0.0",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -26,28 +25,24 @@
|
||||||
|
|
||||||
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||||
|
|
||||||
"@because/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.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.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.2.3", "https://npm.nose.space/@types/node/-/node-25.2.3.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
"@types/node": ["@types/node@25.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.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
"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=="],
|
"commander": ["commander@14.0.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||||
|
|
||||||
"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.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
"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=="],
|
||||||
|
|
||||||
"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=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ export default app.defaults
|
||||||
|
|
||||||
- `PORT` - your assigned port (3001-3100)
|
- `PORT` - your assigned port (3001-3100)
|
||||||
- `APPS_DIR` - path to `/apps` directory
|
- `APPS_DIR` - path to `/apps` directory
|
||||||
- `DATA_DIR` - per-app data directory (`toes/<app-name>/`)
|
|
||||||
|
|
||||||
## health checks
|
## health checks
|
||||||
|
|
||||||
|
|
|
||||||
81
docs/CRON.md
81
docs/CRON.md
|
|
@ -1,81 +0,0 @@
|
||||||
# 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.
|
|
||||||
10
docs/ENV.md
10
docs/ENV.md
|
|
@ -39,13 +39,3 @@ DEBUG=true
|
||||||
```
|
```
|
||||||
|
|
||||||
Keys are uppercased automatically. Quotes around values are stripped.
|
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
848
docs/GUIDE.md
|
|
@ -1,848 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
# Tailscale
|
|
||||||
|
|
||||||
Connect your Toes appliance to your Tailscale network for secure access from anywhere.
|
|
||||||
|
|
||||||
Tailscale is pre-installed on the appliance but not configured. The user authenticates through the dashboard or CLI — no SSH required.
|
|
||||||
|
|
||||||
## how it works
|
|
||||||
|
|
||||||
1. User clicks "Connect to Tailscale" in the dashboard (or runs `toes tailscale connect`)
|
|
||||||
2. Toes runs `tailscale login` and captures the auth URL
|
|
||||||
3. Dashboard shows the URL and a QR code
|
|
||||||
4. User visits the URL and authenticates with Tailscale
|
|
||||||
5. Toes detects the connection, runs `tailscale serve --bg 80`
|
|
||||||
6. Appliance is now accessible at `https://<hostname>.<tailnet>.ts.net`
|
|
||||||
|
|
||||||
## dashboard
|
|
||||||
|
|
||||||
Settings area shows one of three states:
|
|
||||||
|
|
||||||
**Not connected:**
|
|
||||||
- "Connect to Tailscale" button
|
|
||||||
|
|
||||||
**Connecting:**
|
|
||||||
- Auth URL as a clickable link
|
|
||||||
- QR code for mobile
|
|
||||||
- Polls `tailscale status` until authenticated
|
|
||||||
|
|
||||||
**Connected:**
|
|
||||||
- Tailnet URL (clickable)
|
|
||||||
- Tailnet name
|
|
||||||
- Device hostname
|
|
||||||
- `tailscale serve` toggle
|
|
||||||
- "Disconnect" button
|
|
||||||
|
|
||||||
## cli
|
|
||||||
|
|
||||||
```bash
|
|
||||||
toes tailscale # show status
|
|
||||||
toes tailscale connect # start auth flow, print URL, wait
|
|
||||||
toes tailscale disconnect # log out of tailnet
|
|
||||||
toes tailscale serve # toggle tailscale serve on/off
|
|
||||||
```
|
|
||||||
|
|
||||||
### `toes tailscale`
|
|
||||||
|
|
||||||
```
|
|
||||||
Tailscale: connected
|
|
||||||
Tailnet: user@github
|
|
||||||
Hostname: toes.tail1234.ts.net
|
|
||||||
IP: 100.64.0.1
|
|
||||||
Serve: on (port 80)
|
|
||||||
```
|
|
||||||
|
|
||||||
Or when not connected:
|
|
||||||
|
|
||||||
```
|
|
||||||
Tailscale: not connected
|
|
||||||
|
|
||||||
Run `toes tailscale connect` to get started.
|
|
||||||
```
|
|
||||||
|
|
||||||
### `toes tailscale connect`
|
|
||||||
|
|
||||||
```
|
|
||||||
Visit this URL to authenticate:
|
|
||||||
https://login.tailscale.com/a/abc123
|
|
||||||
|
|
||||||
Waiting for authentication... done!
|
|
||||||
Connected to tailnet user@github
|
|
||||||
https://toes.tail1234.ts.net
|
|
||||||
```
|
|
||||||
|
|
||||||
## server api
|
|
||||||
|
|
||||||
All endpoints shell out to the `tailscale` CLI and parse output.
|
|
||||||
|
|
||||||
### `GET /api/tailscale`
|
|
||||||
|
|
||||||
Returns current status.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"installed": true,
|
|
||||||
"connected": true,
|
|
||||||
"hostname": "toes",
|
|
||||||
"tailnetName": "user@github",
|
|
||||||
"url": "https://toes.tail1234.ts.net",
|
|
||||||
"ip": "100.64.0.1",
|
|
||||||
"serving": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When not connected:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"installed": true,
|
|
||||||
"connected": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When tailscale isn't installed:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"installed": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/tailscale/connect`
|
|
||||||
|
|
||||||
Runs `tailscale login`. Returns the auth URL.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"authUrl": "https://login.tailscale.com/a/abc123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /api/tailscale/disconnect`
|
|
||||||
|
|
||||||
Runs `tailscale logout`.
|
|
||||||
|
|
||||||
### `POST /api/tailscale/serve`
|
|
||||||
|
|
||||||
Toggles `tailscale serve`. Body:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "enabled": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
## install
|
|
||||||
|
|
||||||
`scripts/install.sh` installs tailscale and enables the daemon, but does not authenticate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
|
||||||
sudo systemctl enable tailscaled
|
|
||||||
```
|
|
||||||
|
|
||||||
## permissions
|
|
||||||
|
|
||||||
The `toes` user needs passwordless sudo for tailscale commands. Add to sudoers during install:
|
|
||||||
|
|
||||||
```
|
|
||||||
toes ALL=(ALL) NOPASSWD: /usr/bin/tailscale
|
|
||||||
```
|
|
||||||
|
|
||||||
This lets the server run `sudo tailscale login`, `sudo tailscale serve`, etc. without a password prompt.
|
|
||||||
|
|
@ -34,20 +34,12 @@ app.get('/', c => {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## environment
|
|
||||||
|
|
||||||
- `PORT` - your assigned port
|
|
||||||
- `APPS_DIR` - path to `/apps` directory
|
|
||||||
- `DATA_DIR` - per-app data directory (`toes/<tool-name>/`)
|
|
||||||
- `TOES_URL` - base URL of the Toes server
|
|
||||||
- `TOES_DIR` - path to the toes config directory
|
|
||||||
|
|
||||||
## accessing app files
|
## accessing app files
|
||||||
|
|
||||||
Always go through the `current` symlink:
|
Always go through the `current` symlink:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
const APPS_DIR = process.env.APPS_DIR ?? '.'
|
||||||
const appPath = join(APPS_DIR, appName, 'current')
|
const appPath = join(APPS_DIR, appName, 'current')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"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=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
#!/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 ""
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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}`)
|
|
||||||
14
package.json
14
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/toes",
|
"name": "@because/toes",
|
||||||
"version": "0.0.8",
|
"version": "0.0.4",
|
||||||
"description": "personal web appliance - turn it on and forget about the cloud",
|
"description": "personal web appliance - turn it on and forget about the cloud",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -15,19 +15,14 @@
|
||||||
"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",
|
||||||
"debug": "DEBUG=1 bun run dev",
|
"dev": "bun run --hot src/server/index.tsx",
|
||||||
"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",
|
||||||
|
|
@ -39,13 +34,12 @@
|
||||||
"@types/diff": "^8.0.0"
|
"@types/diff": "^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.2",
|
||||||
"@because/sneaker": "^0.0.3",
|
"commander": "^14.0.2",
|
||||||
"commander": "^14.0.3",
|
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
#!/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,5 +1,4 @@
|
||||||
#!/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)
|
||||||
|
|
@ -44,7 +43,7 @@ async function buildTarget(target: BuildTarget) {
|
||||||
ENTRY_POINT,
|
ENTRY_POINT,
|
||||||
'--compile',
|
'--compile',
|
||||||
'--target',
|
'--target',
|
||||||
`bun-${target.os}-${target.arch}`,
|
'bun',
|
||||||
'--minify',
|
'--minify',
|
||||||
'--sourcemap=external',
|
'--sourcemap=external',
|
||||||
'--outfile',
|
'--outfile',
|
||||||
|
|
@ -52,6 +51,11 @@ 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,12 +2,7 @@
|
||||||
|
|
||||||
# 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.
|
||||||
TOES_USER="${TOES_USER:-toes}"
|
HOST="${HOST:-toes@toes.local}"
|
||||||
HOST="${HOST:-toes.local}"
|
URL="${URL:-http://toes.local}"
|
||||||
SSH_HOST="$TOES_USER@$HOST"
|
DEST="${DEST:-~/.toes}"
|
||||||
URL="${URL:-http://$HOST}"
|
APPS_DIR="${APPS_DIR:-~/apps}"
|
||||||
DEST="${DEST:-$HOME/toes}"
|
|
||||||
DATA_DIR="${DATA_DIR:-$HOME/data}"
|
|
||||||
APPS_DIR="${APPS_DIR:-$HOME/apps}"
|
|
||||||
|
|
||||||
mkdir -p "$DEST" "$DATA_DIR" "$APPS_DIR"
|
|
||||||
|
|
|
||||||
|
|
@ -8,32 +8,16 @@ 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: pull, build, sync apps, restart
|
# SSH to target and update
|
||||||
ssh "$SSH_HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
|
ssh "$HOST" "cd $DEST && git pull origin main && bun run build && sudo systemctl restart toes.service"
|
||||||
set -e
|
|
||||||
|
|
||||||
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
|
echo "=> Deployed to $HOST"
|
||||||
|
|
||||||
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,15 +19,6 @@ 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
|
||||||
|
|
@ -37,11 +28,7 @@ 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
|
||||||
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
|
quiet curl -fsSL https://bun.sh/install | bash
|
||||||
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
|
||||||
|
|
@ -53,30 +40,9 @@ 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 data and apps directories"
|
echo ">> Creating apps directory"
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -93,32 +59,13 @@ 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"
|
||||||
sudo raspi-config nonint do_boot_behaviour B4
|
quiet mkdir -p ~/.config/labwc
|
||||||
|
|
||||||
# labwc (older RPi OS / manual installs)
|
|
||||||
mkdir -p ~/.config/labwc
|
|
||||||
cat > ~/.config/labwc/autostart <<'EOF'
|
cat > ~/.config/labwc/autostart <<'EOF'
|
||||||
chromium --noerrdialogs --disable-infobars --kiosk http://localhost
|
chromium-browser --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 || true
|
quiet systemctl status "$SERVICE_NAME" --no-pager -l
|
||||||
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 "$SSH_HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
#!/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 "$SSH_HOST" "sudo systemctl restart toes.service"
|
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 "$SSH_HOST" "sudo systemctl start toes.service"
|
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 "$SSH_HOST" "sudo systemctl stop toes.service"
|
ssh "$HOST" "sudo systemctl stop toes.service"
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,62 +7,11 @@ interface EnvVar {
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseKeyValue(keyOrKeyValue: string, valueArg?: string): { key: string, value: string } | null {
|
export async function envList(name: string | undefined) {
|
||||||
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)
|
const appName = resolveAppName(name)
|
||||||
if (!appName) return
|
if (!appName) return
|
||||||
|
|
||||||
const [vars, globalVars] = await Promise.all([
|
const vars = await get<EnvVar[]>(`/api/apps/${appName}/env`)
|
||||||
get<EnvVar[]>(`/api/apps/${appName}/env`),
|
|
||||||
get<EnvVar[]>('/api/env'),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!vars) {
|
if (!vars) {
|
||||||
console.error(`App not found: ${appName}`)
|
console.error(`App not found: ${appName}`)
|
||||||
return
|
return
|
||||||
|
|
@ -71,9 +20,7 @@ export async function envList(name: string | undefined, opts: { global?: boolean
|
||||||
console.log(color.bold().cyan(`Environment Variables for ${appName}`))
|
console.log(color.bold().cyan(`Environment Variables for ${appName}`))
|
||||||
console.log()
|
console.log()
|
||||||
|
|
||||||
const appKeys = new Set(vars.map(v => v.key))
|
if (vars.length === 0) {
|
||||||
|
|
||||||
if (vars.length === 0 && (!globalVars || globalVars.length === 0)) {
|
|
||||||
console.log(color.gray(' No environment variables set'))
|
console.log(color.gray(' No environment variables set'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -81,30 +28,31 @@ export async function envList(name: string | undefined, opts: { global?: boolean
|
||||||
for (const v of vars) {
|
for (const v of vars) {
|
||||||
console.log(` ${color.bold(v.key)}=${color.gray(v.value)}`)
|
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 }) {
|
export async function envSet(name: string | undefined, keyOrKeyValue: string, valueArg?: string) {
|
||||||
// With --global, args shift: name becomes key, key becomes value
|
let key: string
|
||||||
if (opts.global) {
|
let value: string
|
||||||
const actualKey = name ?? keyOrKeyValue
|
|
||||||
const actualValue = name ? keyOrKeyValue : valueArg
|
if (valueArg !== undefined) {
|
||||||
return globalEnvSet(actualKey, actualValue)
|
// KEY value format
|
||||||
|
key = keyOrKeyValue.trim()
|
||||||
|
value = valueArg
|
||||||
|
} else {
|
||||||
|
// KEY=value format
|
||||||
|
const eqIndex = keyOrKeyValue.indexOf('=')
|
||||||
|
if (eqIndex === -1) {
|
||||||
|
console.error('Invalid format. Use: KEY value or KEY=value')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key = keyOrKeyValue.slice(0, eqIndex).trim()
|
||||||
|
value = keyOrKeyValue.slice(eqIndex + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseKeyValue(keyOrKeyValue, valueArg)
|
if (!key) {
|
||||||
if (!parsed) return
|
console.error('Key cannot be empty')
|
||||||
const { key, value } = parsed
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const appName = resolveAppName(name)
|
const appName = resolveAppName(name)
|
||||||
if (!appName) return
|
if (!appName) return
|
||||||
|
|
@ -122,21 +70,7 @@ export async function envSet(name: string | undefined, keyOrKeyValue: string, va
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function envRm(name: string | undefined, key: string, opts: { global?: boolean }) {
|
export async function envRm(name: string | undefined, key: string) {
|
||||||
// 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) {
|
if (!key) {
|
||||||
console.error('Key is required')
|
console.error('Key is required')
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
export { cronList, cronLog, cronRun, cronStatus } from './cron'
|
|
||||||
export { envList, envRm, envSet } from './env'
|
export { envList, envRm, envSet } from './env'
|
||||||
export { logApp } from './logs'
|
export { logApp } from './logs'
|
||||||
export {
|
export {
|
||||||
|
|
@ -10,10 +9,7 @@ export {
|
||||||
renameApp,
|
renameApp,
|
||||||
restartApp,
|
restartApp,
|
||||||
rmApp,
|
rmApp,
|
||||||
shareApp,
|
|
||||||
startApp,
|
startApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
unshareApp,
|
|
||||||
} from './manage'
|
} from './manage'
|
||||||
export { metricsApp } from './metrics'
|
export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
|
||||||
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,44 @@
|
||||||
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, readFileSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, 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 syncState = readSyncState(process.cwd())
|
const source = process.env.TOES_URL
|
||||||
if (syncState) {
|
? 'TOES_URL'
|
||||||
console.log(`Version: ${color.bold(syncState.version)}`)
|
: process.env.TOES_HOST
|
||||||
|
? '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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,17 +55,9 @@ export async function infoApp(arg?: string) {
|
||||||
const icon = STATE_ICONS[app.state] ?? '◯'
|
const icon = STATE_ICONS[app.state] ?? '◯'
|
||||||
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
|
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
|
||||||
console.log(` State: ${app.state}`)
|
console.log(` State: ${app.state}`)
|
||||||
if (app.state === 'running') {
|
|
||||||
console.log(` URL: ${buildAppUrl(app.name, HOST)}`)
|
|
||||||
}
|
|
||||||
if (app.port) {
|
if (app.port) {
|
||||||
console.log(` Port: ${app.port}`)
|
console.log(` Port: ${app.port}`)
|
||||||
}
|
console.log(` URL: http://localhost:${app.port}`)
|
||||||
if (app.tunnelUrl) {
|
|
||||||
console.log(` Tunnel: ${app.tunnelUrl}`)
|
|
||||||
}
|
|
||||||
if (app.pid) {
|
|
||||||
console.log(` PID: ${app.pid}`)
|
|
||||||
}
|
}
|
||||||
if (app.started) {
|
if (app.started) {
|
||||||
const uptime = Date.now() - app.started
|
const uptime = Date.now() - app.started
|
||||||
|
|
@ -85,24 +74,15 @@ export async function infoApp(arg?: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListAppsOptions {
|
interface ListAppsOptions {
|
||||||
apps?: boolean
|
|
||||||
tools?: boolean
|
tools?: boolean
|
||||||
|
all?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listApps(options: ListAppsOptions) {
|
export async function listApps(options: ListAppsOptions) {
|
||||||
const allApps: App[] | undefined = await get('/api/apps')
|
const allApps: App[] | undefined = await get('/api/apps')
|
||||||
if (!allApps) return
|
if (!allApps) return
|
||||||
|
|
||||||
if (options.apps || options.tools) {
|
if (options.all) {
|
||||||
const filtered = allApps.filter((app) => {
|
|
||||||
if (options.tools) return app.tool
|
|
||||||
return !app.tool
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const app of filtered) {
|
|
||||||
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const apps = allApps.filter((app) => !app.tool)
|
const apps = allApps.filter((app) => !app.tool)
|
||||||
const tools = allApps.filter((app) => app.tool)
|
const tools = allApps.filter((app) => app.tool)
|
||||||
|
|
||||||
|
|
@ -124,6 +104,15 @@ export async function listApps(options: ListAppsOptions) {
|
||||||
console.log(` ${STATE_ICONS[tool.state] ?? '◯'} ${tool.name}`)
|
console.log(` ${STATE_ICONS[tool.state] ?? '◯'} ${tool.name}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const filtered = allApps.filter((app) => {
|
||||||
|
if (options.tools) return app.tool
|
||||||
|
return !app.tool
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const app of filtered) {
|
||||||
|
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,26 +131,12 @@ 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', 'tsconfig.json']
|
const filesToCheck = ['index.tsx', 'package.json', '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(', ')}`)
|
||||||
|
|
@ -210,34 +185,11 @@ export async function openApp(arg?: string) {
|
||||||
console.error(`App is not running: ${name}`)
|
console.error(`App is not running: ${name}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const url = buildAppUrl(app.name, HOST)
|
const url = `http://localhost:${app.port}`
|
||||||
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
|
||||||
|
|
@ -272,15 +224,7 @@ 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
|
||||||
const result = await post(`/api/apps/${name}/restart`)
|
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) {
|
||||||
|
|
@ -312,36 +256,11 @@ 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
|
||||||
const result = await post(`/api/apps/${name}/start`)
|
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
|
||||||
const result = await post(`/api/apps/${name}/stop`)
|
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')}`)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
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`))
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +1,27 @@
|
||||||
import type { Manifest } from '@types'
|
import type { Manifest } from '@types'
|
||||||
import { loadGitignore } from '@gitignore'
|
import { loadGitignore } from '@gitignore'
|
||||||
import { generateManifest, readSyncState, writeSyncState } from '%sync'
|
import { computeHash, generateManifest } from '%sync'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { diffLines } from 'diff'
|
import { diffLines } from 'diff'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
|
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
|
||||||
import { confirm, prompt } from '../prompts'
|
import { confirm, prompt } from '../prompts'
|
||||||
import { getAppName, getAppPackage, isApp, resolveAppName } from '../name'
|
import { getAppName, isApp, resolveAppName } from '../name'
|
||||||
|
|
||||||
const s = (n: number) => n === 1 ? '' : 's'
|
|
||||||
|
|
||||||
function notAppError(): string {
|
|
||||||
const pkg = getAppPackage()
|
|
||||||
if (!pkg) return 'No package.json found. Use `toes get <app>` to grab one.'
|
|
||||||
if (!pkg.scripts?.toes) return 'Missing scripts.toes in package.json. Use `toes new` to add it.'
|
|
||||||
return 'Not a toes app'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Rename {
|
|
||||||
from: string
|
|
||||||
to: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ManifestDiff {
|
interface ManifestDiff {
|
||||||
changed: string[]
|
changed: string[]
|
||||||
localOnly: string[]
|
localOnly: string[]
|
||||||
remoteOnly: string[]
|
remoteOnly: string[]
|
||||||
renamed: Rename[]
|
|
||||||
localManifest: Manifest
|
localManifest: Manifest
|
||||||
remoteManifest: Manifest | null
|
remoteManifest: Manifest | null
|
||||||
remoteVersion: string | null
|
|
||||||
serverChanged: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function historyApp(name?: string) {
|
|
||||||
const appName = resolveAppName(name)
|
|
||||||
if (!appName) return
|
|
||||||
|
|
||||||
type HistoryEntry = {
|
|
||||||
version: string
|
|
||||||
current: boolean
|
|
||||||
added: string[]
|
|
||||||
modified: string[]
|
|
||||||
deleted: string[]
|
|
||||||
renamed: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type HistoryResponse = { history: HistoryEntry[] }
|
|
||||||
|
|
||||||
const result = await get<HistoryResponse>(`/api/sync/apps/${appName}/history`)
|
|
||||||
if (!result) return
|
|
||||||
|
|
||||||
if (result.history.length === 0) {
|
|
||||||
console.log(`No versions found for ${color.bold(appName)}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`History for ${color.bold(appName)}:\n`)
|
|
||||||
|
|
||||||
for (const entry of result.history) {
|
|
||||||
const date = formatVersion(entry.version)
|
|
||||||
const label = entry.current ? ` ${color.green('→')} ${color.bold(entry.version)}` : ` ${entry.version}`
|
|
||||||
const suffix = entry.current ? ` ${color.green('(current)')}` : ''
|
|
||||||
console.log(`${label} ${color.gray(date)}${suffix}`)
|
|
||||||
|
|
||||||
const renamed = entry.renamed ?? []
|
|
||||||
const hasChanges = entry.added.length > 0 || entry.modified.length > 0 || entry.deleted.length > 0 || renamed.length > 0
|
|
||||||
if (!hasChanges) {
|
|
||||||
console.log(color.gray(' No changes'))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const rename of renamed) {
|
|
||||||
console.log(` ${color.cyan('→')} ${rename}`)
|
|
||||||
}
|
|
||||||
for (const file of entry.added) {
|
|
||||||
console.log(` ${color.green('+')} ${file}`)
|
|
||||||
}
|
|
||||||
for (const file of entry.modified) {
|
|
||||||
console.log(` ${color.magenta('*')} ${file}`)
|
|
||||||
}
|
|
||||||
for (const file of entry.deleted) {
|
|
||||||
console.log(` ${color.red('-')} ${file}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getApp(name: string) {
|
export async function getApp(name: string) {
|
||||||
console.log(`Fetching ${color.bold(name)} from server...`)
|
console.log(`Fetching ${color.bold(name)} from server...`)
|
||||||
|
|
||||||
const result = await getManifest(name)
|
const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`)
|
||||||
if (!result || !result.exists || !result.manifest) {
|
if (!manifest) {
|
||||||
console.error(`App not found: ${name}`)
|
console.error(`App not found: ${name}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -105,8 +34,8 @@ export async function getApp(name: string) {
|
||||||
|
|
||||||
mkdirSync(appPath, { recursive: true })
|
mkdirSync(appPath, { recursive: true })
|
||||||
|
|
||||||
const files = Object.keys(result.manifest.files)
|
const files = Object.keys(manifest.files)
|
||||||
console.log(`Downloading ${files.length} file${s(files.length)}...`)
|
console.log(`Downloading ${files.length} files...`)
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const content = await download(`/api/sync/apps/${name}/files/${file}`)
|
const content = await download(`/api/sync/apps/${name}/files/${file}`)
|
||||||
|
|
@ -125,70 +54,47 @@ export async function getApp(name: string) {
|
||||||
writeFileSync(fullPath, content)
|
writeFileSync(fullPath, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.version) {
|
|
||||||
writeSyncState(appPath, { version: result.version })
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(color.green(`✓ Downloaded ${name}`))
|
console.log(color.green(`✓ Downloaded ${name}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}) {
|
export async function pushApp() {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const appName = getAppName()
|
const appName = getAppName()
|
||||||
const diff = await getManifestDiff(appName)
|
|
||||||
|
|
||||||
if (diff === null) {
|
const localManifest = generateManifest(process.cwd(), appName)
|
||||||
|
const result = await getManifest(appName)
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
// Connection error - already printed
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff
|
if (!result.exists) {
|
||||||
|
|
||||||
if (!remoteManifest) {
|
|
||||||
const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`)
|
const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If server changed, abort unless --force (skip for new apps)
|
const localFiles = new Set(Object.keys(localManifest.files))
|
||||||
if (remoteManifest && serverChanged && !options.force) {
|
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))
|
||||||
console.error('Cannot push: server has changed since last sync')
|
|
||||||
console.error('\nRun `toes pull` first, or `toes push --force` to overwrite')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Files to upload: changed + localOnly
|
// Files to upload (new or changed)
|
||||||
const toUpload = [...changed, ...localOnly]
|
const toUpload: string[] = []
|
||||||
// Files to delete on server: remoteOnly (local deletions when version matches, or forced)
|
for (const file of localFiles) {
|
||||||
const toDelete = !serverChanged || options.force ? [...remoteOnly] : []
|
const local = localManifest.files[file]!
|
||||||
|
const remote = result.manifest?.files[file]
|
||||||
// Detect renames among upload/delete pairs (same hash, different path)
|
if (!remote || local.hash !== remote.hash) {
|
||||||
const renames: Rename[] = [...renamed]
|
toUpload.push(file)
|
||||||
const remoteByHash = new Map<string, string>()
|
|
||||||
if (remoteManifest) {
|
|
||||||
for (const file of toDelete) {
|
|
||||||
const info = remoteManifest.files[file]
|
|
||||||
if (info) remoteByHash.set(info.hash, file)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renamedUploads = new Set<string>()
|
// Note: We don't delete files in versioned deployments - new version is separate directory
|
||||||
const renamedDeletes = new Set<string>()
|
|
||||||
for (const file of toUpload) {
|
|
||||||
const hash = localManifest.files[file]?.hash
|
|
||||||
if (!hash) continue
|
|
||||||
const remoteFile = remoteByHash.get(hash)
|
|
||||||
if (remoteFile && !renamedDeletes.has(remoteFile)) {
|
|
||||||
renames.push({ from: remoteFile, to: file })
|
|
||||||
renamedUploads.add(file)
|
|
||||||
renamedDeletes.add(remoteFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toUpload.length === 0 && toDelete.length === 0) {
|
if (toUpload.length === 0) {
|
||||||
if (!options.quiet) console.log('Already up to date')
|
console.log('Already up to date')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,28 +112,11 @@ export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}
|
||||||
console.log(`Deploying version ${color.bold(version)}...`)
|
console.log(`Deploying version ${color.bold(version)}...`)
|
||||||
|
|
||||||
// 2. Upload changed files to new version
|
// 2. Upload changed files to new version
|
||||||
const actualUploads = toUpload.filter(f => !renamedUploads.has(f))
|
if (toUpload.length > 0) {
|
||||||
const actualDeletes = toDelete.filter(f => !renamedDeletes.has(f))
|
console.log(`Uploading ${toUpload.length} files...`)
|
||||||
|
|
||||||
if (renames.length > 0) {
|
|
||||||
console.log(`Renaming ${renames.length} file${s(renames.length)}...`)
|
|
||||||
for (const { from, to } of renames) {
|
|
||||||
const content = readFileSync(join(process.cwd(), to))
|
|
||||||
const uploadOk = await put(`/api/sync/apps/${appName}/files/${to}?version=${version}`, content)
|
|
||||||
const deleteOk = await del(`/api/sync/apps/${appName}/files/${from}?version=${version}`)
|
|
||||||
if (uploadOk && deleteOk) {
|
|
||||||
console.log(` ${color.cyan('→')} ${from} → ${to}`)
|
|
||||||
} else {
|
|
||||||
console.log(` ${color.red('✗')} ${from} → ${to} (failed)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actualUploads.length > 0) {
|
|
||||||
console.log(`Uploading ${actualUploads.length} file${s(actualUploads.length)}...`)
|
|
||||||
let failedUploads = 0
|
let failedUploads = 0
|
||||||
|
|
||||||
for (const file of actualUploads) {
|
for (const file of toUpload) {
|
||||||
const content = readFileSync(join(process.cwd(), file))
|
const content = readFileSync(join(process.cwd(), file))
|
||||||
const success = await put(`/api/sync/apps/${appName}/files/${file}?version=${version}`, content)
|
const success = await put(`/api/sync/apps/${appName}/files/${file}?version=${version}`, content)
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|
@ -239,26 +128,13 @@ export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failedUploads > 0) {
|
if (failedUploads > 0) {
|
||||||
console.error(`Failed to upload ${failedUploads} file${s(failedUploads)}. Deployment aborted.`)
|
console.error(`Failed to upload ${failedUploads} file(s). Deployment aborted.`)
|
||||||
console.error(`Incomplete version ${version} left on server (not activated).`)
|
console.error(`Incomplete version ${version} left on server (not activated).`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Delete files that no longer exist locally
|
// 3. Activate new version (updates symlink and restarts app)
|
||||||
if (actualDeletes.length > 0) {
|
|
||||||
console.log(`Deleting ${actualDeletes.length} file${s(actualDeletes.length)}...`)
|
|
||||||
for (const file of actualDeletes) {
|
|
||||||
const success = await del(`/api/sync/apps/${appName}/files/${file}?version=${version}`)
|
|
||||||
if (success) {
|
|
||||||
console.log(` ${color.red('-')} ${file}`)
|
|
||||||
} else {
|
|
||||||
console.log(` ${color.red('✗')} ${file} (failed)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Activate new version (updates symlink and restarts app)
|
|
||||||
type ActivateResponse = { ok: boolean }
|
type ActivateResponse = { ok: boolean }
|
||||||
const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${version}`)
|
const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${version}`)
|
||||||
if (!activateRes?.ok) {
|
if (!activateRes?.ok) {
|
||||||
|
|
@ -266,15 +142,12 @@ export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Write sync version after successful push
|
|
||||||
writeSyncState(process.cwd(), { version })
|
|
||||||
|
|
||||||
console.log(color.green(`✓ Deployed and activated version ${version}`))
|
console.log(color.green(`✓ Deployed and activated version ${version}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}) {
|
export async function pullApp(options: { force?: boolean } = {}) {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,57 +158,37 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { changed, localOnly, remoteOnly, remoteManifest, remoteVersion, serverChanged } = diff
|
const { changed, localOnly, remoteOnly, remoteManifest } = diff
|
||||||
|
|
||||||
if (!remoteManifest) {
|
if (!remoteManifest) {
|
||||||
console.error('App not found on server')
|
console.error('App not found on server')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverChanged) {
|
// Check for local changes that would be overwritten
|
||||||
// Server hasn't changed — all diffs are local, nothing to pull
|
const wouldOverwrite = changed.length > 0 || localOnly.length > 0
|
||||||
if (!options.quiet) {
|
if (wouldOverwrite && !options.force) {
|
||||||
if (changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0) {
|
|
||||||
console.log('Server is up to date. You have local changes — use `toes push`.')
|
|
||||||
} else {
|
|
||||||
console.log('Already up to date')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server changed — download diffs from remote
|
|
||||||
const hasDiffs = changed.length > 0 || localOnly.length > 0
|
|
||||||
if (hasDiffs && !options.force) {
|
|
||||||
console.error('Cannot pull: you have local changes that would be overwritten')
|
console.error('Cannot pull: you have local changes that would be overwritten')
|
||||||
for (const file of changed) {
|
console.error(' Use `toes status` and `toes diff` to see differences')
|
||||||
console.error(` ${color.magenta('*')} ${file}`)
|
console.error(' Use `toes pull --force` to overwrite local changes')
|
||||||
}
|
|
||||||
for (const file of localOnly) {
|
|
||||||
console.error(` ${color.green('+')} ${file} (local only)`)
|
|
||||||
}
|
|
||||||
console.error('\nUse `toes pull --force` to overwrite local changes')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files to download: changed + remoteOnly
|
// Files to download: changed + remoteOnly
|
||||||
const toDownload = [...changed, ...remoteOnly]
|
const toDownload = [...changed, ...remoteOnly]
|
||||||
// Files to delete locally: only when forcing
|
|
||||||
const toDelete = options.force ? localOnly : []
|
// Files to delete: localOnly
|
||||||
|
const toDelete = localOnly
|
||||||
|
|
||||||
if (toDownload.length === 0 && toDelete.length === 0) {
|
if (toDownload.length === 0 && toDelete.length === 0) {
|
||||||
// Server version changed but files are identical — just update stored version
|
console.log('Already up to date')
|
||||||
if (remoteVersion) {
|
|
||||||
writeSyncState(process.cwd(), { version: remoteVersion })
|
|
||||||
}
|
|
||||||
if (!options.quiet) console.log('Already up to date')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Pulling ${color.bold(appName)} from server...`)
|
console.log(`Pulling ${color.bold(appName)} from server...`)
|
||||||
|
|
||||||
if (toDownload.length > 0) {
|
if (toDownload.length > 0) {
|
||||||
console.log(`Downloading ${toDownload.length} file${s(toDownload.length)}...`)
|
console.log(`Downloading ${toDownload.length} files...`)
|
||||||
for (const file of toDownload) {
|
for (const file of toDownload) {
|
||||||
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
||||||
if (!content) {
|
if (!content) {
|
||||||
|
|
@ -356,26 +209,20 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toDelete.length > 0) {
|
if (toDelete.length > 0) {
|
||||||
console.log(`Deleting ${toDelete.length} local file${s(toDelete.length)}...`)
|
console.log(`Deleting ${toDelete.length} local files...`)
|
||||||
for (const file of toDelete) {
|
for (const file of toDelete) {
|
||||||
const fullPath = join(process.cwd(), file)
|
const fullPath = join(process.cwd(), file)
|
||||||
if (existsSync(fullPath)) {
|
|
||||||
unlinkSync(fullPath)
|
unlinkSync(fullPath)
|
||||||
console.log(` ${color.red('-')} ${file}`)
|
console.log(` ${color.red('✗')} ${file}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (remoteVersion) {
|
|
||||||
writeSyncState(process.cwd(), { version: remoteVersion })
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(color.green('✓ Pull complete'))
|
console.log(color.green('✓ Pull complete'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diffApp() {
|
export async function diffApp() {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -386,36 +233,21 @@ export async function diffApp() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { changed, localOnly, remoteOnly, renamed } = diff
|
const { changed, localOnly, remoteOnly } = diff
|
||||||
|
|
||||||
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0 && renamed.length === 0) {
|
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) {
|
||||||
|
// console.log(color.green('✓ No differences'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show renames
|
// Fetch all changed files in parallel
|
||||||
for (const { from, to } of renamed) {
|
|
||||||
console.log(color.cyan('\nRenamed'))
|
|
||||||
console.log(color.bold(`${from} → ${to}`))
|
|
||||||
console.log(color.gray('─'.repeat(60)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all changed files in parallel (skip binary files)
|
|
||||||
const remoteContents = await Promise.all(
|
const remoteContents = await Promise.all(
|
||||||
changed.map(file => isBinary(file) ? null : download(`/api/sync/apps/${appName}/files/${file}`))
|
changed.map(file => download(`/api/sync/apps/${appName}/files/${file}`))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show diffs for changed files
|
// Show diffs for changed files
|
||||||
for (let i = 0; i < changed.length; i++) {
|
for (let i = 0; i < changed.length; i++) {
|
||||||
const file = changed[i]!
|
const file = changed[i]!
|
||||||
|
|
||||||
console.log(color.bold(`\n${file}`))
|
|
||||||
console.log(color.gray('─'.repeat(60)))
|
|
||||||
|
|
||||||
if (isBinary(file)) {
|
|
||||||
console.log(color.gray('Binary file changed'))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteContent = remoteContents[i]
|
const remoteContent = remoteContents[i]
|
||||||
const localContent = readFileSync(join(process.cwd(), file), 'utf-8')
|
const localContent = readFileSync(join(process.cwd(), file), 'utf-8')
|
||||||
|
|
||||||
|
|
@ -425,6 +257,9 @@ export async function diffApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteText = new TextDecoder().decode(remoteContent)
|
const remoteText = new TextDecoder().decode(remoteContent)
|
||||||
|
|
||||||
|
console.log(color.bold(`\n${file}`))
|
||||||
|
console.log(color.gray('─'.repeat(60)))
|
||||||
showDiff(remoteText, localContent)
|
showDiff(remoteText, localContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,12 +268,6 @@ export async function diffApp() {
|
||||||
console.log(color.green('\nNew file (local only)'))
|
console.log(color.green('\nNew file (local only)'))
|
||||||
console.log(color.bold(`${file}`))
|
console.log(color.bold(`${file}`))
|
||||||
console.log(color.gray('─'.repeat(60)))
|
console.log(color.gray('─'.repeat(60)))
|
||||||
|
|
||||||
if (isBinary(file)) {
|
|
||||||
console.log(color.gray('Binary file'))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = readFileSync(join(process.cwd(), file), 'utf-8')
|
const content = readFileSync(join(process.cwd(), file), 'utf-8')
|
||||||
const lines = content.split('\n')
|
const lines = content.split('\n')
|
||||||
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
||||||
|
|
@ -449,9 +278,9 @@ export async function diffApp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all remote-only files in parallel (skip binary files)
|
// Fetch all remote-only files in parallel
|
||||||
const remoteOnlyContents = await Promise.all(
|
const remoteOnlyContents = await Promise.all(
|
||||||
remoteOnly.map(file => isBinary(file) ? null : download(`/api/sync/apps/${appName}/files/${file}`))
|
remoteOnly.map(file => download(`/api/sync/apps/${appName}/files/${file}`))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show remote-only files
|
// Show remote-only files
|
||||||
|
|
@ -461,13 +290,7 @@ export async function diffApp() {
|
||||||
|
|
||||||
console.log(color.bold(`\n${file}`))
|
console.log(color.bold(`\n${file}`))
|
||||||
console.log(color.gray('─'.repeat(60)))
|
console.log(color.gray('─'.repeat(60)))
|
||||||
console.log(color.red('Remote only'))
|
console.log(color.red('Remote only (would be deleted on push)'))
|
||||||
|
|
||||||
if (isBinary(file)) {
|
|
||||||
console.log(color.gray('Binary file'))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
const text = new TextDecoder().decode(content)
|
const text = new TextDecoder().decode(content)
|
||||||
const lines = text.split('\n')
|
const lines = text.split('\n')
|
||||||
|
|
@ -486,7 +309,7 @@ export async function diffApp() {
|
||||||
|
|
||||||
export async function statusApp() {
|
export async function statusApp() {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,98 +320,91 @@ export async function statusApp() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff
|
const { changed, localOnly, remoteOnly, localManifest, remoteManifest } = diff
|
||||||
|
|
||||||
|
// toPush = changed + localOnly (new or modified locally)
|
||||||
|
const toPush = [...changed, ...localOnly]
|
||||||
|
|
||||||
|
// Local changes block pull
|
||||||
|
const hasLocalChanges = toPush.length > 0
|
||||||
|
|
||||||
|
// Display status
|
||||||
|
// console.log(`Status for ${color.bold(appName)}:\n`)
|
||||||
|
|
||||||
if (!remoteManifest) {
|
if (!remoteManifest) {
|
||||||
console.log(color.yellow('App does not exist on server'))
|
console.log(color.yellow('App does not exist on server'))
|
||||||
const localFileCount = Object.keys(localManifest.files).length
|
const localFileCount = Object.keys(localManifest.files).length
|
||||||
console.log(`\nWould create new app with ${localFileCount} file${s(localFileCount)} on push\n`)
|
console.log(`\nWould create new app with ${localFileCount} files on push\n`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDiffs = changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0 || renamed.length > 0
|
// Push status
|
||||||
|
if (toPush.length > 0 || remoteOnly.length > 0) {
|
||||||
if (!hasDiffs) {
|
|
||||||
if (serverChanged) {
|
|
||||||
// Files identical but version changed — update stored version silently
|
|
||||||
const { remoteVersion } = diff
|
|
||||||
if (remoteVersion) {
|
|
||||||
writeSyncState(process.cwd(), { version: remoteVersion })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(color.green('✓ In sync with server'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!serverChanged) {
|
|
||||||
// Server hasn't moved — all diffs are local changes to push
|
|
||||||
console.log(color.bold('Changes to push:'))
|
console.log(color.bold('Changes to push:'))
|
||||||
for (const { from, to } of renamed) {
|
for (const file of toPush) {
|
||||||
console.log(` ${color.cyan('→')} ${from} → ${to}`)
|
console.log(` ${color.green('↑')} ${file}`)
|
||||||
}
|
|
||||||
for (const file of changed) {
|
|
||||||
console.log(` ${color.magenta('*')} ${file}`)
|
|
||||||
}
|
|
||||||
for (const file of localOnly) {
|
|
||||||
console.log(` ${color.green('+')} ${file}`)
|
|
||||||
}
|
}
|
||||||
for (const file of remoteOnly) {
|
for (const file of remoteOnly) {
|
||||||
console.log(` ${color.red('-')} ${file}`)
|
console.log(` ${color.red('✗')} ${file}`)
|
||||||
}
|
}
|
||||||
console.log()
|
console.log()
|
||||||
} else {
|
|
||||||
// Server changed — show diffs neutrally
|
|
||||||
console.log(color.yellow('Server has changed since last sync\n'))
|
|
||||||
console.log(color.bold('Differences:'))
|
|
||||||
for (const { from, to } of renamed) {
|
|
||||||
console.log(` ${color.cyan('→')} ${from} → ${to}`)
|
|
||||||
}
|
|
||||||
for (const file of changed) {
|
|
||||||
console.log(` ${color.magenta('*')} ${file}`)
|
|
||||||
}
|
|
||||||
for (const file of localOnly) {
|
|
||||||
console.log(` ${color.green('+')} ${file} (local only)`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull status (only show if no local changes blocking)
|
||||||
|
if (!hasLocalChanges && remoteOnly.length > 0) {
|
||||||
|
console.log(color.bold('Changes to pull:'))
|
||||||
for (const file of remoteOnly) {
|
for (const file of remoteOnly) {
|
||||||
console.log(` ${color.green('+')} ${file} (remote only)`)
|
console.log(` ${color.green('↓')} ${file}`)
|
||||||
}
|
}
|
||||||
console.log(`\nRun ${color.bold('toes pull')} to update, or ${color.bold('toes push --force')} to overwrite server`)
|
|
||||||
console.log()
|
console.log()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
if (toPush.length === 0 && remoteOnly.length === 0) {
|
||||||
|
console.log(color.green('✓ In sync with server'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncApp() {
|
export async function syncApp() {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const appName = getAppName()
|
const appName = getAppName()
|
||||||
|
const gitignore = loadGitignore(process.cwd())
|
||||||
|
const localHashes = new Map<string, string>()
|
||||||
|
|
||||||
// Verify app exists on server
|
// Initialize local hashes
|
||||||
const result = await getManifest(appName)
|
const manifest = generateManifest(process.cwd(), appName)
|
||||||
if (result === null) return
|
for (const [path, info] of Object.entries(manifest.files)) {
|
||||||
if (!result.exists) {
|
localHashes.set(path, info.hash)
|
||||||
console.error(`App ${color.bold(appName)} doesn't exist on server. Run ${color.bold('toes push')} first.`)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Syncing ${color.bold(appName)}...`)
|
console.log(`Syncing ${color.bold(appName)}...`)
|
||||||
|
|
||||||
// Initial sync: pull remote changes, then push local
|
// Watch local files
|
||||||
await mergeSync(appName)
|
const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => {
|
||||||
|
|
||||||
const gitignore = loadGitignore(process.cwd())
|
|
||||||
|
|
||||||
// Watch local files with debounce → push
|
|
||||||
let pushTimer: Timer | null = null
|
|
||||||
const watcher = watch(process.cwd(), { recursive: true }, (_event, filename) => {
|
|
||||||
if (!filename || gitignore.shouldExclude(filename)) return
|
if (!filename || gitignore.shouldExclude(filename)) return
|
||||||
if (pushTimer) clearTimeout(pushTimer)
|
|
||||||
pushTimer = setTimeout(() => pushApp({ quiet: true }), 500)
|
const fullPath = join(process.cwd(), filename)
|
||||||
|
|
||||||
|
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
|
||||||
|
const content = readFileSync(fullPath)
|
||||||
|
const hash = computeHash(content)
|
||||||
|
if (localHashes.get(filename) !== hash) {
|
||||||
|
localHashes.set(filename, hash)
|
||||||
|
await put(`/api/sync/apps/${appName}/files/${filename}`, content)
|
||||||
|
console.log(` ${color.green('↑')} ${filename}`)
|
||||||
|
}
|
||||||
|
} else if (!existsSync(fullPath)) {
|
||||||
|
localHashes.delete(filename)
|
||||||
|
await del(`/api/sync/apps/${appName}/files/${filename}`)
|
||||||
|
console.log(` ${color.red('✗')} ${filename}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Connect to SSE for remote changes → pull
|
// Connect to SSE for remote changes
|
||||||
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
|
|
@ -610,12 +426,11 @@ export async function syncApp() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` Connected, watching for changes...`)
|
console.log(` Connected to server, watching for changes...`)
|
||||||
|
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
let pullTimer: Timer | null = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -628,13 +443,30 @@ export async function syncApp() {
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.startsWith('data: ')) continue
|
if (!line.startsWith('data: ')) continue
|
||||||
if (pullTimer) clearTimeout(pullTimer)
|
const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete', path: string, hash?: string }
|
||||||
pullTimer = setTimeout(() => mergeSync(appName), 500)
|
|
||||||
|
if (event.type === 'change') {
|
||||||
|
// Skip if we already have this version (handles echo from our own changes)
|
||||||
|
if (localHashes.get(event.path) === event.hash) continue
|
||||||
|
const content = await download(`/api/sync/apps/${appName}/files/${event.path}`)
|
||||||
|
if (content) {
|
||||||
|
const fullPath = join(process.cwd(), event.path)
|
||||||
|
mkdirSync(dirname(fullPath), { recursive: true })
|
||||||
|
writeFileSync(fullPath, content)
|
||||||
|
localHashes.set(event.path, event.hash!)
|
||||||
|
console.log(` ${color.green('↓')} ${event.path}`)
|
||||||
|
}
|
||||||
|
} else if (event.type === 'delete') {
|
||||||
|
const fullPath = join(process.cwd(), event.path)
|
||||||
|
if (existsSync(fullPath)) {
|
||||||
|
unlinkSync(fullPath)
|
||||||
|
localHashes.delete(event.path)
|
||||||
|
console.log(` ${color.red('✗')} ${event.path} (remote)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (pushTimer) clearTimeout(pushTimer)
|
|
||||||
if (pullTimer) clearTimeout(pullTimer)
|
|
||||||
watcher.close()
|
watcher.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -749,7 +581,7 @@ const STASH_BASE = '/tmp/toes-stash'
|
||||||
|
|
||||||
export async function stashApp() {
|
export async function stashApp() {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -800,12 +632,12 @@ export async function stashApp() {
|
||||||
|
|
||||||
const content = readFileSync(srcPath)
|
const content = readFileSync(srcPath)
|
||||||
writeFileSync(destPath, content)
|
writeFileSync(destPath, content)
|
||||||
console.log(` ${color.magenta('*')} ${file}`)
|
console.log(` ${color.yellow('→')} ${file}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore changed files from server
|
// Restore changed files from server
|
||||||
if (changed.length > 0) {
|
if (changed.length > 0) {
|
||||||
console.log(`\nRestoring ${changed.length} changed file${s(changed.length)} from server...`)
|
console.log(`\nRestoring ${changed.length} changed files from server...`)
|
||||||
for (const file of changed) {
|
for (const file of changed) {
|
||||||
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
||||||
if (content) {
|
if (content) {
|
||||||
|
|
@ -816,13 +648,13 @@ export async function stashApp() {
|
||||||
|
|
||||||
// Delete local-only files
|
// Delete local-only files
|
||||||
if (localOnly.length > 0) {
|
if (localOnly.length > 0) {
|
||||||
console.log(`Removing ${localOnly.length} local-only file${s(localOnly.length)}...`)
|
console.log(`Removing ${localOnly.length} local-only files...`)
|
||||||
for (const file of localOnly) {
|
for (const file of localOnly) {
|
||||||
unlinkSync(join(process.cwd(), file))
|
unlinkSync(join(process.cwd(), file))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(color.green(`\n✓ Stashed ${toStash.length} file${s(toStash.length)}`))
|
console.log(color.green(`\n✓ Stashed ${toStash.length} file(s)`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stashListApp() {
|
export async function stashListApp() {
|
||||||
|
|
@ -848,7 +680,7 @@ export async function stashListApp() {
|
||||||
files: string[]
|
files: string[]
|
||||||
}
|
}
|
||||||
const date = new Date(metadata.timestamp).toLocaleString()
|
const date = new Date(metadata.timestamp).toLocaleString()
|
||||||
console.log(` ${color.bold(stash.name)} ${color.gray(date)} ${color.gray(`(${metadata.files.length} file${s(metadata.files.length)})`)}`)
|
console.log(` ${color.bold(stash.name)} ${color.gray(date)} ${color.gray(`(${metadata.files.length} files)`)}`)
|
||||||
} else {
|
} else {
|
||||||
console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`)
|
console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`)
|
||||||
}
|
}
|
||||||
|
|
@ -858,7 +690,7 @@ export async function stashListApp() {
|
||||||
|
|
||||||
export async function stashPopApp() {
|
export async function stashPopApp() {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -910,12 +742,12 @@ export async function stashPopApp() {
|
||||||
// Remove stash directory
|
// Remove stash directory
|
||||||
rmSync(stashDir, { recursive: true })
|
rmSync(stashDir, { recursive: true })
|
||||||
|
|
||||||
console.log(color.green(`\n✓ Restored ${metadata.files.length} file${s(metadata.files.length)}`))
|
console.log(color.green(`\n✓ Restored ${metadata.files.length} file(s)`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) {
|
export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error(notAppError())
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -936,7 +768,7 @@ export async function cleanApp(options: { force?: boolean, dryRun?: boolean } =
|
||||||
if (options.dryRun) {
|
if (options.dryRun) {
|
||||||
console.log('Would remove:')
|
console.log('Would remove:')
|
||||||
for (const file of localOnly) {
|
for (const file of localOnly) {
|
||||||
console.log(` ${color.red('-')} ${file}`)
|
console.log(` ${color.red('✗')} ${file}`)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -944,20 +776,20 @@ export async function cleanApp(options: { force?: boolean, dryRun?: boolean } =
|
||||||
if (!options.force) {
|
if (!options.force) {
|
||||||
console.log('Files not on server:')
|
console.log('Files not on server:')
|
||||||
for (const file of localOnly) {
|
for (const file of localOnly) {
|
||||||
console.log(` ${color.red('-')} ${file}`)
|
console.log(` ${color.red('✗')} ${file}`)
|
||||||
}
|
}
|
||||||
console.log()
|
console.log()
|
||||||
const ok = await confirm(`Remove ${localOnly.length} file${s(localOnly.length)}?`)
|
const ok = await confirm(`Remove ${localOnly.length} file(s)?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of localOnly) {
|
for (const file of localOnly) {
|
||||||
const fullPath = join(process.cwd(), file)
|
const fullPath = join(process.cwd(), file)
|
||||||
unlinkSync(fullPath)
|
unlinkSync(fullPath)
|
||||||
console.log(` ${color.red('-')} ${file}`)
|
console.log(` ${color.red('✗')} ${file}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(color.green(`✓ Removed ${localOnly.length} file${s(localOnly.length)}`))
|
console.log(color.green(`✓ Removed ${localOnly.length} file(s)`))
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VersionsResponse {
|
interface VersionsResponse {
|
||||||
|
|
@ -997,38 +829,6 @@ function formatVersion(version: string): string {
|
||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergeSync(appName: string): Promise<void> {
|
|
||||||
const diff = await getManifestDiff(appName)
|
|
||||||
if (!diff) return
|
|
||||||
|
|
||||||
const { changed, remoteOnly, remoteManifest, serverChanged } = diff
|
|
||||||
if (!remoteManifest) return
|
|
||||||
|
|
||||||
if (serverChanged) {
|
|
||||||
// Pull remote changes
|
|
||||||
const toPull = [...changed, ...remoteOnly]
|
|
||||||
|
|
||||||
if (toPull.length > 0) {
|
|
||||||
for (const file of toPull) {
|
|
||||||
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
|
||||||
if (!content) continue
|
|
||||||
|
|
||||||
const fullPath = join(process.cwd(), file)
|
|
||||||
const dir = dirname(fullPath)
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(fullPath, content)
|
|
||||||
console.log(` ${color.green('↓')} ${file}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push merged state to server
|
|
||||||
await pushApp({ quiet: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
||||||
const localManifest = generateManifest(process.cwd(), appName)
|
const localManifest = generateManifest(process.cwd(), appName)
|
||||||
const result = await getManifest(appName)
|
const result = await getManifest(appName)
|
||||||
|
|
@ -1038,20 +838,15 @@ async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteManifest = result.manifest ?? null
|
|
||||||
const remoteVersion = result.version ?? null
|
|
||||||
const syncState = readSyncState(process.cwd())
|
|
||||||
const serverChanged = !syncState || syncState.version !== remoteVersion
|
|
||||||
|
|
||||||
const localFiles = new Set(Object.keys(localManifest.files))
|
const localFiles = new Set(Object.keys(localManifest.files))
|
||||||
const remoteFiles = new Set(Object.keys(remoteManifest?.files ?? {}))
|
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))
|
||||||
|
|
||||||
// Files that differ
|
// Files that differ
|
||||||
const changed: string[] = []
|
const changed: string[] = []
|
||||||
for (const file of localFiles) {
|
for (const file of localFiles) {
|
||||||
if (remoteFiles.has(file)) {
|
if (remoteFiles.has(file)) {
|
||||||
const local = localManifest.files[file]!
|
const local = localManifest.files[file]!
|
||||||
const remote = remoteManifest!.files[file]!
|
const remote = result.manifest!.files[file]!
|
||||||
if (local.hash !== remote.hash) {
|
if (local.hash !== remote.hash) {
|
||||||
changed.push(file)
|
changed.push(file)
|
||||||
}
|
}
|
||||||
|
|
@ -1066,87 +861,34 @@ async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files only in remote (filtered by local gitignore)
|
// Files only in remote
|
||||||
const gitignore = loadGitignore(process.cwd())
|
|
||||||
const remoteOnly: string[] = []
|
const remoteOnly: string[] = []
|
||||||
for (const file of remoteFiles) {
|
for (const file of remoteFiles) {
|
||||||
if (!localFiles.has(file) && !gitignore.shouldExclude(file)) {
|
if (!localFiles.has(file)) {
|
||||||
remoteOnly.push(file)
|
remoteOnly.push(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect renames: localOnly + remoteOnly files with matching hashes
|
|
||||||
const renamed: Rename[] = []
|
|
||||||
const remoteByHash = new Map<string, string>()
|
|
||||||
for (const file of remoteOnly) {
|
|
||||||
const hash = remoteManifest!.files[file]!.hash
|
|
||||||
remoteByHash.set(hash, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedLocal = new Set<string>()
|
|
||||||
const matchedRemote = new Set<string>()
|
|
||||||
for (const file of localOnly) {
|
|
||||||
const hash = localManifest.files[file]!.hash
|
|
||||||
const remoteFile = remoteByHash.get(hash)
|
|
||||||
if (remoteFile && !matchedRemote.has(remoteFile)) {
|
|
||||||
renamed.push({ from: remoteFile, to: file })
|
|
||||||
matchedLocal.add(file)
|
|
||||||
matchedRemote.add(remoteFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changed,
|
changed,
|
||||||
localOnly: localOnly.filter(f => !matchedLocal.has(f)),
|
localOnly,
|
||||||
remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)),
|
remoteOnly,
|
||||||
renamed,
|
|
||||||
localManifest,
|
localManifest,
|
||||||
remoteManifest,
|
remoteManifest: result.manifest ?? null,
|
||||||
remoteVersion,
|
|
||||||
serverChanged,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BINARY_EXTENSIONS = new Set([
|
|
||||||
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.heic', '.tiff',
|
|
||||||
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
||||||
'.mp3', '.mp4', '.wav', '.ogg', '.webm', '.avi', '.mov',
|
|
||||||
'.pdf', '.zip', '.tar', '.gz', '.br', '.zst',
|
|
||||||
'.wasm', '.exe', '.dll', '.so', '.dylib',
|
|
||||||
])
|
|
||||||
|
|
||||||
const isBinary = (filename: string) => {
|
|
||||||
const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase()
|
|
||||||
return BINARY_EXTENSIONS.has(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDiff(remote: string, local: string) {
|
function showDiff(remote: string, local: string) {
|
||||||
const changes = diffLines(remote, local)
|
const changes = diffLines(remote, local)
|
||||||
let lineCount = 0
|
let lineCount = 0
|
||||||
const maxLines = 50
|
const maxLines = 50
|
||||||
const contextLines = 3
|
const contextLines = 3
|
||||||
let remoteLine = 1
|
|
||||||
let localLine = 1
|
|
||||||
let needsHeader = true
|
|
||||||
|
|
||||||
let hunkCount = 0
|
|
||||||
|
|
||||||
const printHeader = (_rStart: number, lStart: number) => {
|
|
||||||
if (hunkCount > 0) console.log()
|
|
||||||
if (lStart > 1) {
|
|
||||||
console.log(color.cyan(`Line ${lStart}:`))
|
|
||||||
lineCount++
|
|
||||||
}
|
|
||||||
needsHeader = false
|
|
||||||
hunkCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < changes.length; i++) {
|
for (let i = 0; i < changes.length; i++) {
|
||||||
const part = changes[i]!
|
const part = changes[i]!
|
||||||
const lines = part.value.replace(/\n$/, '').split('\n')
|
const lines = part.value.replace(/\n$/, '').split('\n')
|
||||||
|
|
||||||
if (part.added) {
|
if (part.added) {
|
||||||
if (needsHeader) printHeader(remoteLine, localLine)
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (lineCount >= maxLines) {
|
if (lineCount >= maxLines) {
|
||||||
console.log(color.gray('... diff truncated'))
|
console.log(color.gray('... diff truncated'))
|
||||||
|
|
@ -1155,9 +897,7 @@ function showDiff(remote: string, local: string) {
|
||||||
console.log(color.green(`+ ${line}`))
|
console.log(color.green(`+ ${line}`))
|
||||||
lineCount++
|
lineCount++
|
||||||
}
|
}
|
||||||
localLine += lines.length
|
|
||||||
} else if (part.removed) {
|
} else if (part.removed) {
|
||||||
if (needsHeader) printHeader(remoteLine, localLine)
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (lineCount >= maxLines) {
|
if (lineCount >= maxLines) {
|
||||||
console.log(color.gray('... diff truncated'))
|
console.log(color.gray('... diff truncated'))
|
||||||
|
|
@ -1166,7 +906,6 @@ function showDiff(remote: string, local: string) {
|
||||||
console.log(color.red(`- ${line}`))
|
console.log(color.red(`- ${line}`))
|
||||||
lineCount++
|
lineCount++
|
||||||
}
|
}
|
||||||
remoteLine += lines.length
|
|
||||||
} else {
|
} else {
|
||||||
// Context: show lines near changes
|
// Context: show lines near changes
|
||||||
const prevHasChange = i > 0 && (changes[i - 1]!.added || changes[i - 1]!.removed)
|
const prevHasChange = i > 0 && (changes[i - 1]!.added || changes[i - 1]!.removed)
|
||||||
|
|
@ -1187,11 +926,9 @@ function showDiff(remote: string, local: string) {
|
||||||
if (nextHasChange) {
|
if (nextHasChange) {
|
||||||
const start = Math.max(0, lines.length - contextLines)
|
const start = Math.max(0, lines.length - contextLines)
|
||||||
if (start > 0) {
|
if (start > 0) {
|
||||||
needsHeader = true
|
console.log(color.gray(' ...'))
|
||||||
|
lineCount++
|
||||||
}
|
}
|
||||||
const headerLine = remoteLine + start
|
|
||||||
const headerLocalLine = localLine + start
|
|
||||||
if (needsHeader) printHeader(headerLine, headerLocalLine)
|
|
||||||
for (let j = start; j < lines.length; j++) {
|
for (let j = start; j < lines.length; j++) {
|
||||||
if (lineCount >= maxLines) {
|
if (lineCount >= maxLines) {
|
||||||
console.log(color.gray('... diff truncated'))
|
console.log(color.gray('... diff truncated'))
|
||||||
|
|
@ -1202,7 +939,7 @@ function showDiff(remote: string, local: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Show context after previous change
|
// Show context after previous change
|
||||||
if (prevHasChange && !nextHasChange) {
|
if (prevHasChange) {
|
||||||
const end = Math.min(lines.length, contextLines)
|
const end = Math.min(lines.length, contextLines)
|
||||||
for (let j = 0; j < end; j++) {
|
for (let j = 0; j < end; j++) {
|
||||||
if (lineCount >= maxLines) {
|
if (lineCount >= maxLines) {
|
||||||
|
|
@ -1212,13 +949,11 @@ function showDiff(remote: string, local: string) {
|
||||||
console.log(color.gray(` ${lines[j]}`))
|
console.log(color.gray(` ${lines[j]}`))
|
||||||
lineCount++
|
lineCount++
|
||||||
}
|
}
|
||||||
if (end < lines.length) {
|
if (end < lines.length && !nextHasChange) {
|
||||||
needsHeader = true
|
console.log(color.gray(' ...'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
remoteLine += lines.length
|
|
||||||
localLine += lines.length
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import type { Manifest } from '@types'
|
import type { Manifest } from '@types'
|
||||||
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
|
||||||
|
|
||||||
const normalizeUrl = (url: string) =>
|
function getDefaultHost(): string {
|
||||||
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return `http://toes.local:${process.env.PORT ?? 80}`
|
||||||
|
}
|
||||||
|
return `http://localhost:${process.env.PORT ?? 3000}`
|
||||||
|
}
|
||||||
|
|
||||||
function tryParseError(text: string): string | undefined {
|
const defaultPort = process.env.NODE_ENV === 'production' ? 80 : 3000
|
||||||
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
|
||||||
? normalizeUrl(process.env.TOES_URL)
|
?? (process.env.TOES_HOST ? `http://${process.env.TOES_HOST}:${process.env.PORT ?? defaultPort}` : undefined)
|
||||||
: DEFAULT_HOST
|
?? getDefaultHost()
|
||||||
|
|
||||||
export function makeUrl(path: string): string {
|
export function makeUrl(path: string): string {
|
||||||
return `${HOST}${path}`
|
return `${HOST}${path}`
|
||||||
|
|
@ -24,11 +20,7 @@ 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 to connect to a different host`)
|
console.error(` Set TOES_URL or TOES_HOST to connect to a different host`)
|
||||||
return
|
|
||||||
}
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error(`Error: ${error.message}`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|
@ -37,25 +29,19 @@ 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) {
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
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, version?: string } | null> {
|
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
||||||
if (res.status === 404) return { exists: false }
|
if (res.status === 404) return { exists: false }
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
const data = await res.json()
|
return { exists: true, manifest: await res.json() }
|
||||||
const { version, ...manifest } = data
|
|
||||||
return { exists: true, manifest, version }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
return null
|
return null
|
||||||
|
|
@ -69,11 +55,7 @@ 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) {
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
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)
|
||||||
|
|
@ -86,11 +68,7 @@ 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) {
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
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)
|
||||||
|
|
@ -102,11 +80,7 @@ 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) {
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
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)
|
||||||
|
|
@ -118,11 +92,7 @@ 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) {
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
185
src/cli/setup.ts
185
src/cli/setup.ts
|
|
@ -1,22 +1,14 @@
|
||||||
import { program } from 'commander'
|
import { program } from 'commander'
|
||||||
|
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
|
|
||||||
import pkg from '../../package.json'
|
|
||||||
import { withPager } from './pager'
|
|
||||||
import {
|
import {
|
||||||
cleanApp,
|
cleanApp,
|
||||||
configShow,
|
configShow,
|
||||||
cronList,
|
|
||||||
cronLog,
|
|
||||||
cronRun,
|
|
||||||
cronStatus,
|
|
||||||
diffApp,
|
diffApp,
|
||||||
envList,
|
envList,
|
||||||
envRm,
|
envRm,
|
||||||
envSet,
|
envSet,
|
||||||
getApp,
|
getApp,
|
||||||
historyApp,
|
|
||||||
infoApp,
|
infoApp,
|
||||||
listApps,
|
listApps,
|
||||||
logApp,
|
logApp,
|
||||||
|
|
@ -32,29 +24,28 @@ import {
|
||||||
stashListApp,
|
stashListApp,
|
||||||
stashPopApp,
|
stashPopApp,
|
||||||
startApp,
|
startApp,
|
||||||
metricsApp,
|
|
||||||
shareApp,
|
|
||||||
statusApp,
|
statusApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
syncApp,
|
syncApp,
|
||||||
unshareApp,
|
|
||||||
versionsApp,
|
versionsApp,
|
||||||
} from './commands'
|
} from './commands'
|
||||||
|
|
||||||
program
|
program
|
||||||
.name('toes')
|
.name('toes')
|
||||||
.version(`v${pkg.version}`, '-v, --version')
|
.version('v0.0.4', '-v, --version')
|
||||||
.addHelpText('beforeAll', (ctx) => {
|
.addHelpText('beforeAll', (ctx) => {
|
||||||
if (ctx.command === program) {
|
if (ctx.command === program) {
|
||||||
return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
|
return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n')
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
.addHelpCommand(false)
|
|
||||||
.configureOutput({
|
.configureOutput({
|
||||||
writeOut: (str) => {
|
writeOut: (str) => {
|
||||||
const colored = str
|
const colored = str
|
||||||
.replace(/^([A-Z][\w ]*:)/gm, color.yellow('$1'))
|
.replace(/^(Usage:)/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)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -63,102 +54,44 @@ program
|
||||||
.command('version', { hidden: true })
|
.command('version', { hidden: true })
|
||||||
.action(() => console.log(program.version()))
|
.action(() => console.log(program.version()))
|
||||||
|
|
||||||
// Apps
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('list')
|
.command('config')
|
||||||
.helpGroup('Apps:')
|
.description('Show current host configuration')
|
||||||
.description('List all apps')
|
.action(configShow)
|
||||||
.option('-t, --tools', 'show only tools')
|
|
||||||
.option('-a, --apps', 'show only apps (exclude tools)')
|
|
||||||
.action(listApps)
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('info')
|
.command('info')
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Show info for an app')
|
.description('Show info for an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(infoApp)
|
.action(infoApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('new')
|
.command('list')
|
||||||
.helpGroup('Apps:')
|
.description('List all apps')
|
||||||
.description('Create a new toes app')
|
.option('-t, --tools', 'show only tools')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.option('-a, --all', 'show all apps including tools')
|
||||||
.option('--ssr', 'SSR template with pages directory (default)')
|
.action(listApps)
|
||||||
.option('--bare', 'minimal template with no pages')
|
|
||||||
.option('--spa', 'single-page app with client-side rendering')
|
|
||||||
.action(newApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('get')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Download an app from server')
|
|
||||||
.argument('<name>', 'app name')
|
|
||||||
.action(getApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('open')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Open an app in browser')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(openApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('rename')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Rename an app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.argument('<new-name>', 'new app name')
|
|
||||||
.action(renameApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('rm')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Remove an app from the server')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(rmApp)
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('start')
|
.command('start')
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Start an app')
|
.description('Start an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(startApp)
|
.action(startApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('stop')
|
.command('stop')
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Stop an app')
|
.description('Stop an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(stopApp)
|
.action(stopApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('restart')
|
.command('restart')
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Restart an app')
|
.description('Restart an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(restartApp)
|
.action(restartApp)
|
||||||
|
|
||||||
program
|
|
||||||
.command('share')
|
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Share an app via public tunnel')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(shareApp)
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('unshare')
|
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Stop sharing an app')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(unshareApp)
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('logs')
|
.command('logs')
|
||||||
.helpGroup('Lifecycle:')
|
|
||||||
.description('Show logs for an app')
|
.description('Show logs for an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.option('-f, --follow', 'follow log output')
|
.option('-f, --follow', 'follow log output')
|
||||||
|
|
@ -177,75 +110,54 @@ program
|
||||||
.action(logApp)
|
.action(logApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('metrics')
|
.command('open')
|
||||||
.helpGroup('Lifecycle:')
|
.description('Open an app in browser')
|
||||||
.description('Show CPU, memory, and disk metrics for apps')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(metricsApp)
|
.action(openApp)
|
||||||
|
|
||||||
const cron = program
|
program
|
||||||
.command('cron')
|
.command('get')
|
||||||
.helpGroup('Lifecycle:')
|
.description('Download an app from server')
|
||||||
.description('Manage cron jobs')
|
.argument('<name>', 'app name')
|
||||||
.argument('[app]', 'app name (list jobs for specific app)')
|
.action(getApp)
|
||||||
.action(cronList)
|
|
||||||
|
|
||||||
cron
|
program
|
||||||
.command('log')
|
.command('new')
|
||||||
.description('Show cron job logs')
|
.description('Create a new toes app')
|
||||||
.argument('[target]', 'app name or job (app:name)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.option('-f, --follow', 'follow log output')
|
.option('--ssr', 'SSR template with pages directory (default)')
|
||||||
.action(cronLog)
|
.option('--bare', 'minimal template with no pages')
|
||||||
|
.option('--spa', 'single-page app with client-side rendering')
|
||||||
cron
|
.action(newApp)
|
||||||
.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(() => withPager(diffApp))
|
.action(diffApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('sync')
|
.command('sync')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Watch and sync changes bidirectionally')
|
.description('Watch and sync changes bidirectionally')
|
||||||
.action(syncApp)
|
.action(syncApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('clean')
|
.command('clean')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Remove local files not on server')
|
.description('Remove local files not on server')
|
||||||
.option('-f, --force', 'skip confirmation')
|
.option('-f, --force', 'skip confirmation')
|
||||||
.option('-n, --dry-run', 'show what would be removed')
|
.option('-n, --dry-run', 'show what would be removed')
|
||||||
|
|
@ -253,7 +165,6 @@ program
|
||||||
|
|
||||||
const stash = program
|
const stash = program
|
||||||
.command('stash')
|
.command('stash')
|
||||||
.helpGroup('Sync:')
|
|
||||||
.description('Stash local changes')
|
.description('Stash local changes')
|
||||||
.action(stashApp)
|
.action(stashApp)
|
||||||
|
|
||||||
|
|
@ -267,20 +178,10 @@ stash
|
||||||
.description('List all stashes')
|
.description('List all stashes')
|
||||||
.action(stashListApp)
|
.action(stashListApp)
|
||||||
|
|
||||||
// Config
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('config')
|
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('Show current host configuration')
|
|
||||||
.action(configShow)
|
|
||||||
|
|
||||||
const env = program
|
const env = program
|
||||||
.command('env')
|
.command('env')
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('Manage environment variables')
|
.description('Manage environment variables')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.option('-g, --global', 'manage global variables shared by all apps')
|
|
||||||
.action(envList)
|
.action(envList)
|
||||||
|
|
||||||
env
|
env
|
||||||
|
|
@ -289,7 +190,6 @@ env
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.argument('<key>', 'variable name')
|
.argument('<key>', 'variable name')
|
||||||
.argument('[value]', 'variable value (or use KEY=value format)')
|
.argument('[value]', 'variable value (or use KEY=value format)')
|
||||||
.option('-g, --global', 'set a global variable shared by all apps')
|
|
||||||
.action(envSet)
|
.action(envSet)
|
||||||
|
|
||||||
env
|
env
|
||||||
|
|
@ -297,29 +197,32 @@ env
|
||||||
.description('Remove an environment variable')
|
.description('Remove an environment variable')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.argument('<key>', 'variable name to remove')
|
.argument('<key>', 'variable name to remove')
|
||||||
.option('-g, --global', 'remove a global variable')
|
|
||||||
.action(envRm)
|
.action(envRm)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('versions')
|
.command('versions')
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('List deployed versions')
|
.description('List deployed versions')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.action(versionsApp)
|
.action(versionsApp)
|
||||||
|
|
||||||
program
|
|
||||||
.command('history')
|
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('Show file changes between versions')
|
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
|
||||||
.action(historyApp)
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('rollback')
|
.command('rollback')
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('Rollback to a previous version')
|
.description('Rollback to a previous version')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
||||||
.action((name, options) => rollbackApp(name, options.version))
|
.action((name, options) => rollbackApp(name, options.version))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('rm')
|
||||||
|
.description('Remove an app from the server')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.action(rmApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('rename')
|
||||||
|
.description('Rename an app')
|
||||||
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
|
.argument('<new-name>', 'new app name')
|
||||||
|
.action(renameApp)
|
||||||
|
|
||||||
export { program }
|
export { program }
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,6 @@ export const getLogDates = (name: string): Promise<string[]> =>
|
||||||
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||||
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
||||||
|
|
||||||
export const getWifiConfig = (): Promise<{ network: string, password: string }> =>
|
|
||||||
fetch('/api/system/wifi').then(r => r.json())
|
|
||||||
|
|
||||||
export const saveWifiConfig = (config: { network: string, password: string }) =>
|
|
||||||
fetch('/api/system/wifi', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(config),
|
|
||||||
}).then(r => r.json())
|
|
||||||
|
|
||||||
export const shareApp = (name: string) =>
|
|
||||||
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
|
||||||
|
|
||||||
export const unshareApp = (name: string) =>
|
|
||||||
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
|
||||||
|
|
||||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||||
|
|
||||||
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { define } from '@because/forge'
|
import { define } from '@because/forge'
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { buildAppUrl } from '../../shared/urls'
|
import { restartApp, startApp, stopApp } from '../api'
|
||||||
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
|
|
||||||
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||||
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
|
import { apps, selectedTab } from '../state'
|
||||||
import {
|
import {
|
||||||
ActionBar,
|
ActionBar,
|
||||||
Button,
|
Button,
|
||||||
ClickableAppName,
|
ClickableAppName,
|
||||||
HamburgerButton,
|
|
||||||
HamburgerLine,
|
|
||||||
HeaderActions,
|
HeaderActions,
|
||||||
InfoLabel,
|
InfoLabel,
|
||||||
InfoRow,
|
InfoRow,
|
||||||
|
|
@ -47,30 +44,16 @@ 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>
|
||||||
|
|
@ -87,24 +70,16 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
{stateLabels[app.state]}
|
{stateLabels[app.state]}
|
||||||
</InfoValue>
|
</InfoValue>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
{app.state === 'running' && (
|
{app.state === 'running' && app.port && (
|
||||||
<InfoRow>
|
<InfoRow>
|
||||||
<InfoLabel>URL</InfoLabel>
|
<InfoLabel>URL</InfoLabel>
|
||||||
<InfoValue>
|
<InfoValue>
|
||||||
<Link href={buildAppUrl(app.name, location.origin)} target="_blank">
|
<Link href={`http://localhost:${app.port}`} target="_blank">
|
||||||
{buildAppUrl(app.name, location.origin)}
|
http://localhost:{app.port}
|
||||||
</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>
|
||||||
|
|
@ -113,14 +88,6 @@ 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>
|
||||||
|
|
@ -140,7 +107,7 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
<LogsSection app={app} />
|
<LogsSection app={app} />
|
||||||
|
|
||||||
<ActionBar>
|
<ActionBar>
|
||||||
{(app.state === 'stopped' || app.state === 'error') && (
|
{app.state === 'stopped' && (
|
||||||
<Button variant="primary" onClick={() => startApp(app.name)}>
|
<Button variant="primary" onClick={() => startApp(app.name)}>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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,60 +1,22 @@
|
||||||
import { Styles } from '@because/forge'
|
import { Styles } from '@because/forge'
|
||||||
import { openNewAppModal } from '../modals'
|
|
||||||
import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
|
|
||||||
import {
|
|
||||||
HamburgerButton,
|
|
||||||
HamburgerLine,
|
|
||||||
Layout,
|
|
||||||
Main,
|
|
||||||
MainContent as MainContentContainer,
|
|
||||||
MainHeader,
|
|
||||||
MainTitle,
|
|
||||||
NewAppButton,
|
|
||||||
} from '../styles'
|
|
||||||
import { AppDetail } from './AppDetail'
|
|
||||||
import { AppSelector } from './AppSelector'
|
|
||||||
import { DashboardLanding } from './DashboardLanding'
|
|
||||||
import { Modal } from './modal'
|
import { Modal } from './modal'
|
||||||
import { SettingsPage } from './SettingsPage'
|
import { apps, selectedApp } from '../state'
|
||||||
|
import { EmptyState, Layout } from '../styles'
|
||||||
|
import { AppDetail } from './AppDetail'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
|
|
||||||
function MobileSidebar({ render }: { render: () => void }) {
|
|
||||||
return (
|
|
||||||
<Main>
|
|
||||||
<MainHeader>
|
|
||||||
<MainTitle>
|
|
||||||
<HamburgerButton onClick={() => { setMobileSidebar(false); render() }} title="Hide apps">
|
|
||||||
<HamburgerLine />
|
|
||||||
<HamburgerLine />
|
|
||||||
<HamburgerLine />
|
|
||||||
</HamburgerButton>
|
|
||||||
<a href="/" style={{ textDecoration: 'none', color: 'inherit' }}>🐾 Toes</a>
|
|
||||||
</MainTitle>
|
|
||||||
</MainHeader>
|
|
||||||
<MainContentContainer>
|
|
||||||
<AppSelector render={render} large />
|
|
||||||
<div style={{ padding: '12px 16px' }}>
|
|
||||||
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
|
||||||
</div>
|
|
||||||
</MainContentContainer>
|
|
||||||
</Main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MainContent({ render }: { render: () => void }) {
|
|
||||||
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 }) {
|
export function Dashboard({ render }: { render: () => void }) {
|
||||||
|
const selected = apps.find(a => a.name === selectedApp)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Styles />
|
<Styles />
|
||||||
{!isNarrow && <Sidebar render={render} />}
|
<Sidebar render={render} />
|
||||||
<MainContent render={render} />
|
{selected ? (
|
||||||
|
<AppDetail app={selected} render={render} />
|
||||||
|
) : (
|
||||||
|
<EmptyState>Select an app to view details</EmptyState>
|
||||||
|
)}
|
||||||
<Modal />
|
<Modal />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { define } from '@because/forge'
|
import { define } from '@because/forge'
|
||||||
import type { App, LogLine as LogLineType } from '../../shared/types'
|
import type { App, LogLine as LogLineType } from '../../shared/types'
|
||||||
import { getLogDates, getLogsForDate } from '../api'
|
import { getLogDates, getLogsForDate } from '../api'
|
||||||
import { isNarrow } from '../state'
|
import { LogLine, LogsContainer, LogTime, Section, SectionTitle } from '../styles'
|
||||||
import { LogLine, LogsContainer, LogsHeader, LogTime, Section, SectionTitle } from '../styles'
|
|
||||||
import { theme } from '../themes'
|
import { theme } from '../themes'
|
||||||
import { update } from '../update'
|
import { update } from '../update'
|
||||||
|
|
||||||
|
|
@ -33,6 +32,14 @@ const getState = (appName: string): LogsState => {
|
||||||
return logsState.get(appName)!
|
return logsState.get(appName)!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LogsHeader = define('LogsHeader', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
})
|
||||||
|
|
||||||
const LogsControls = define('LogsControls', {
|
const LogsControls = define('LogsControls', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -206,7 +213,7 @@ export function LogsSection({ app }: { app: App }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, marginBottom: 0 }}>
|
<Section>
|
||||||
<LogsHeader>
|
<LogsHeader>
|
||||||
<SectionTitle style={{ marginBottom: 0 }}>Logs</SectionTitle>
|
<SectionTitle style={{ marginBottom: 0 }}>Logs</SectionTitle>
|
||||||
<LogsControls>
|
<LogsControls>
|
||||||
|
|
@ -229,7 +236,7 @@ export function LogsSection({ app }: { app: App }) {
|
||||||
</SmallSelect>
|
</SmallSelect>
|
||||||
</LogsControls>
|
</LogsControls>
|
||||||
</LogsHeader>
|
</LogsHeader>
|
||||||
<LogsContainer id="logs-content" narrow={isNarrow || undefined}>
|
<LogsContainer id="logs-content">
|
||||||
<LogsContent />
|
<LogsContent />
|
||||||
</LogsContainer>
|
</LogsContainer>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { navigate } from '../router'
|
import { apps, selectedApp, selectedTab, setSelectedTab } from '../state'
|
||||||
import { apps, getSelectedTab } from '../state'
|
|
||||||
import { Tab, TabBar } from '../styles'
|
import { Tab, TabBar } from '../styles'
|
||||||
import { resetToolIframe } from '../tool-iframes'
|
import { resetToolIframe } from '../tool-iframes'
|
||||||
|
|
||||||
export function Nav({ app, render }: { app: App; render: () => void }) {
|
export function Nav({ app, render }: { app: App; render: () => void }) {
|
||||||
const selectedTab = getSelectedTab(app.name)
|
|
||||||
|
|
||||||
const handleTabClick = (tab: string) => {
|
const handleTabClick = (tab: string) => {
|
||||||
// If clicking already-selected tool tab, reset to home
|
// If clicking already-selected tool tab, reset to home
|
||||||
if (tab === selectedTab && tab !== 'overview') {
|
if (tab === selectedTab && tab !== 'overview') {
|
||||||
resetToolIframe(tab, app.name)
|
resetToolIframe(tab, selectedApp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
|
setSelectedTab(tab)
|
||||||
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all tools
|
// Find all tools
|
||||||
|
|
@ -23,7 +21,7 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
|
||||||
return (
|
return (
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<Tab active={selectedTab === 'overview' ? true : undefined} onClick={() => handleTabClick('overview')}>
|
<Tab active={selectedTab === 'overview' ? true : undefined} onClick={() => handleTabClick('overview')}>
|
||||||
📋 Overview
|
Overview
|
||||||
</Tab>
|
</Tab>
|
||||||
{tools.map(tool => {
|
{tools.map(tool => {
|
||||||
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
|
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
|
||||||
|
|
@ -33,7 +31,7 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
|
||||||
active={selectedTab === tool.name ? true : undefined}
|
active={selectedTab === tool.name ? true : undefined}
|
||||||
onClick={() => handleTabClick(tool.name)}
|
onClick={() => handleTabClick(tool.name)}
|
||||||
>
|
>
|
||||||
{tool.icon} {titlecase(toolName)}
|
{titlecase(toolName)}
|
||||||
</Tab>
|
</Tab>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
import { useEffect, useState } from 'hono/jsx'
|
|
||||||
import { getWifiConfig, saveWifiConfig } from '../api'
|
|
||||||
import { navigate } from '../router'
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DashboardInstallCmd,
|
|
||||||
FormActions,
|
|
||||||
FormField,
|
|
||||||
FormInput,
|
|
||||||
FormLabel,
|
|
||||||
HeaderActions,
|
|
||||||
Main,
|
|
||||||
MainContent,
|
|
||||||
MainHeader,
|
|
||||||
MainTitle,
|
|
||||||
Section,
|
|
||||||
SectionTitle,
|
|
||||||
} from '../styles'
|
|
||||||
|
|
||||||
export function SettingsPage({ render }: { render: () => void }) {
|
|
||||||
const [network, setNetwork] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [saved, setSaved] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getWifiConfig().then(config => {
|
|
||||||
setNetwork(config.network)
|
|
||||||
setPassword(config.password)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
navigate('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async (e: Event) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSaving(true)
|
|
||||||
setSaved(false)
|
|
||||||
await saveWifiConfig({ network, password })
|
|
||||||
setSaving(false)
|
|
||||||
setSaved(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Main>
|
|
||||||
<MainHeader centered>
|
|
||||||
<MainTitle>Settings</MainTitle>
|
|
||||||
<HeaderActions>
|
|
||||||
<Button onClick={goBack}>Back</Button>
|
|
||||||
</HeaderActions>
|
|
||||||
</MainHeader>
|
|
||||||
<MainContent centered>
|
|
||||||
<Section>
|
|
||||||
<SectionTitle>WiFi</SectionTitle>
|
|
||||||
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400 }}>
|
|
||||||
<FormField>
|
|
||||||
<FormLabel>Network</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
type="text"
|
|
||||||
value={network}
|
|
||||||
onInput={(e: Event) => setNetwork((e.target as HTMLInputElement).value)}
|
|
||||||
placeholder="SSID"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField>
|
|
||||||
<FormLabel>Password</FormLabel>
|
|
||||||
<FormInput
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
|
|
||||||
placeholder="Password"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormActions>
|
|
||||||
{saved && <span style={{ fontSize: 13, color: '#888', alignSelf: 'center' }}>Saved</span>}
|
|
||||||
<Button variant="primary" type="submit" disabled={saving}>
|
|
||||||
{saving ? 'Saving...' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
</form>
|
|
||||||
</Section>
|
|
||||||
<Section>
|
|
||||||
<SectionTitle>Install CLI</SectionTitle>
|
|
||||||
<DashboardInstallCmd>
|
|
||||||
curl -fsSL {location.origin}/install | bash
|
|
||||||
</DashboardInstallCmd>
|
|
||||||
</Section>
|
|
||||||
</MainContent>
|
|
||||||
</Main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +1,88 @@
|
||||||
import { openNewAppModal } from '../modals'
|
import { openNewAppModal } from '../modals'
|
||||||
import {
|
import {
|
||||||
|
apps,
|
||||||
|
selectedApp,
|
||||||
|
setSelectedApp,
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
|
setSidebarSection,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
|
sidebarSection,
|
||||||
} from '../state'
|
} from '../state'
|
||||||
import {
|
import {
|
||||||
|
AppItem,
|
||||||
|
AppList,
|
||||||
HamburgerButton,
|
HamburgerButton,
|
||||||
HamburgerLine,
|
HamburgerLine,
|
||||||
Logo,
|
Logo,
|
||||||
LogoLink,
|
|
||||||
NewAppButton,
|
NewAppButton,
|
||||||
|
SectionSwitcher,
|
||||||
|
SectionTab,
|
||||||
Sidebar as SidebarContainer,
|
Sidebar as SidebarContainer,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
|
StatusDot,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
import { AppSelector } from './AppSelector'
|
|
||||||
|
|
||||||
export function Sidebar({ render }: { render: () => void }) {
|
export function Sidebar({ render }: { render: () => void }) {
|
||||||
|
const selectApp = (name: string) => {
|
||||||
|
setSelectedApp(name)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setSidebarCollapsed(!sidebarCollapsed)
|
setSidebarCollapsed(!sidebarCollapsed)
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
||||||
{sidebarCollapsed ? (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0' }}>
|
|
||||||
<HamburgerButton onClick={toggleSidebar} title="Show sidebar">
|
|
||||||
<HamburgerLine />
|
|
||||||
<HamburgerLine />
|
|
||||||
<HamburgerLine />
|
|
||||||
</HamburgerButton>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Logo>
|
<Logo>
|
||||||
<LogoLink href="/" title="Go to dashboard">
|
{!sidebarCollapsed && <span>🐾 Toes</span>}
|
||||||
🐾 Toes
|
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
|
||||||
</LogoLink>
|
|
||||||
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
|
|
||||||
<HamburgerLine />
|
<HamburgerLine />
|
||||||
<HamburgerLine />
|
<HamburgerLine />
|
||||||
<HamburgerLine />
|
<HamburgerLine />
|
||||||
</HamburgerButton>
|
</HamburgerButton>
|
||||||
</Logo>
|
</Logo>
|
||||||
|
{!sidebarCollapsed && toolApps.length > 0 && (
|
||||||
|
<SectionSwitcher>
|
||||||
|
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
|
||||||
|
Apps
|
||||||
|
</SectionTab>
|
||||||
|
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
|
||||||
|
Tools
|
||||||
|
</SectionTab>
|
||||||
|
</SectionSwitcher>
|
||||||
)}
|
)}
|
||||||
<AppSelector render={render} collapsed={sidebarCollapsed} />
|
<AppList>
|
||||||
|
{activeApps.map(app => (
|
||||||
|
<AppItem
|
||||||
|
key={app.name}
|
||||||
|
onClick={() => selectApp(app.name)}
|
||||||
|
selected={app.name === selectedApp ? true : undefined}
|
||||||
|
style={sidebarCollapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
|
||||||
|
title={sidebarCollapsed ? app.name : undefined}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<span style={{ fontSize: 18 }}>{app.icon}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize: 14 }}>{app.icon}</span>
|
||||||
|
{app.name}
|
||||||
|
<StatusDot state={app.state} style={{ marginLeft: 'auto' }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AppItem>
|
||||||
|
))}
|
||||||
|
</AppList>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user