Compare commits
4 Commits
main
...
wifi-setup
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b6d29ef38 | |||
| 7073cab8b5 | |||
| 3d40de5582 | |||
| ab3d379970 |
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,14 +1,10 @@
|
|||
.sandlot/
|
||||
.rev/
|
||||
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
pub/client/index.js
|
||||
toes/
|
||||
|
||||
# generated
|
||||
src/lib/templates.data.ts
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
|
|
|
|||
121
CLAUDE.md
121
CLAUDE.md
|
|
@ -16,43 +16,19 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
|
|||
- **Bun** runtime (not Node)
|
||||
- **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
|
||||
- **Forge** (typed CSS-in-JS) from `@because/forge`
|
||||
- **Commander** + **ansis** for CLI
|
||||
- **Commander** + **kleur** for CLI
|
||||
- TypeScript + Hono JSX
|
||||
- Client renders with `hono/jsx/dom` (no build step, served directly)
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
bun run dev # Hot reload (rebuilds client bundle on change)
|
||||
bun run start # Production (generates templates, then runs server)
|
||||
bun run check # Type check
|
||||
bun run test # Tests
|
||||
bun run build # Build client JS bundle (pub/client/index.js)
|
||||
bun run release # Build release tarball for the Pi
|
||||
bun run dev # Hot reload (deletes pub/client/index.js first)
|
||||
bun run start # Production
|
||||
bun run check # Type check
|
||||
bun run test # Tests
|
||||
```
|
||||
|
||||
## Building & Releasing
|
||||
|
||||
`bun run release` (runs `scripts/release.sh`) produces a self-contained tarball the Pi can install without building anything:
|
||||
|
||||
1. Client JS bundle → `pub/client/index.js`
|
||||
2. Embedded templates → `src/lib/templates.data.ts` (generated from `templates/` by `scripts/embed-templates.ts`)
|
||||
3. Pre-built bare git repos for bundled apps → `dist/repos/*.git` (built by `scripts/build-repos.sh`)
|
||||
4. Cross-compiled CLI binary → `dist/toes` (linux-arm64, built by `scripts/build.ts`)
|
||||
5. Everything staged and packed into `dist/toes-<version>.tar.gz`
|
||||
|
||||
The Pi installer (`install/install.sh`) downloads this tarball, runs `bun install` for the server and all bundled apps (in parallel), copies pre-built repos and CLI binary into place, and starts the systemd service. No git commands, no compilation on the Pi.
|
||||
|
||||
### Key scripts
|
||||
|
||||
- `scripts/build.sh` -- Builds client JS bundle only (used during dev)
|
||||
- `scripts/build-repos.sh` -- Pre-builds bare git repos for bundled apps (excludes node_modules, snapshots, logs)
|
||||
- `scripts/release.sh` -- Full release pipeline: client + templates + repos + CLI → tarball
|
||||
- `scripts/build.ts` -- CLI binary compiler (current platform or `--all` for cross-compile)
|
||||
- `scripts/embed-templates.ts` -- Generates `src/lib/templates.data.ts` from `templates/`
|
||||
- `install/install.sh` -- Pi installer, downloads release tarball and sets everything up
|
||||
- `scripts/remote-install.sh` -- Runs the installer on a remote Pi over SSH
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
|
@ -71,17 +47,11 @@ Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
|
|||
### Server (`src/server/`)
|
||||
|
||||
- `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
|
||||
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management.
|
||||
- `api/events.ts` -- SSE stream for discrete lifecycle events (`/stream`). Used by app processes (not the dashboard). Includes a 60s heartbeat ping.
|
||||
- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app delete, app reload (triggered by git tool after deploy), file watch SSE.
|
||||
- `api/system.ts` -- System info, metrics (CPU/RAM/disk per-app via `ps`/`du`), unified log aggregation, perf timing toggle, update check/apply, server restart.
|
||||
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), on-demand CLI binary builds (`/dist/:file`), install script endpoint (`/install`), SPA catch-all routes, subdomain proxy (including WebSocket). Initializes apps.
|
||||
- `mdns.ts` -- mDNS publishing via `avahi-publish` on Linux production. Publishes `<app>.hostname.local` A records pointing to local IP. Auto-republishes on unexpected exit with exponential backoff.
|
||||
- `proxy.ts` -- Subdomain routing: extracts subdomain from `*.localhost` or `*.X.local`, proxies HTTP requests and WebSocket connections to the app's port. Sets `x-app-url` header. Optional perf timing.
|
||||
- `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.
|
||||
- `sync.ts` -- Re-exports `computeHash` and `generateManifest` from `%sync` (lib).
|
||||
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY, plain logs otherwise). Debounced rendering at 50ms.
|
||||
- `tunnels.ts` -- Public tunnel management via `@because/sneaker`. Persists tunnel config in `TOES_DIR/tunnels.json`. Auto-reconnects on drop with exponential backoff (max 10 attempts). Manages share/unshare lifecycle.
|
||||
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
|
||||
|
||||
### Client (`src/client/`)
|
||||
|
||||
|
|
@ -92,11 +62,9 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
|
|||
- `api.ts` -- Fetch wrappers for server API calls.
|
||||
- `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication).
|
||||
- `update.tsx` -- SSE connection to `/api/apps/stream` for real-time state updates.
|
||||
- `router.ts` -- Client-side router. Intercepts link clicks, handles popstate, maps URL paths to state (`/app/:name/:tab`, `/settings`, dashboard tabs).
|
||||
- `ansi.ts` -- ANSI escape code handling for log rendering.
|
||||
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection, DashboardLanding, SettingsPage, Vitals, UnifiedLogs, Urls, emoji-picker, modal.
|
||||
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection.
|
||||
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
|
||||
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout, logs, misc).
|
||||
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout).
|
||||
- `themes/` -- Light/dark theme token definitions.
|
||||
|
||||
### CLI (`src/cli/`)
|
||||
|
|
@ -110,37 +78,31 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
|
|||
- `pager.ts` -- Pipe output through system pager.
|
||||
|
||||
CLI commands:
|
||||
- **Apps**: `status` (list or info), `new`, `get`, `open`, `rename`, `rm`
|
||||
- **Lifecycle**: `start`, `stop`, `restart`, `share`, `unshare`, `logs`, `metrics`, `cron` (list/log/status/run)
|
||||
- **Config**: `env` (list/set/rm, per-app or `--global`), `perf` (toggle request timing)
|
||||
- **Hidden**: `list`, `info`, `log`, `version`, `shell`
|
||||
|
||||
Some commands (`shell`, `get`, `open`) are disabled when running over SSH (`USER=cli`).
|
||||
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
|
||||
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`
|
||||
- **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash`
|
||||
- **Config**: `config`, `env`, `versions`, `history`, `rollback`
|
||||
|
||||
### Shared (`src/shared/`)
|
||||
|
||||
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
|
||||
|
||||
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`, `DEFAULT_EMOJI`, `VALID_NAME`
|
||||
- `events.ts` -- `ToesEvent`, `ToesEventType`, `ToesEventInput` type definitions
|
||||
- `urls.ts` -- `toSubdomain()` and `buildAppUrl()` for subdomain URL construction
|
||||
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
|
||||
- `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.
|
||||
|
||||
- `config.ts` -- `HOSTNAME` and `LOCAL_HOST` (`hostname.local`) constants
|
||||
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa), reads embedded templates from `templates.data.ts`
|
||||
- `templates.data.ts` -- Generated file containing embedded template contents (built by `scripts/embed-templates.ts`)
|
||||
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
|
||||
- `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 `computeHash`, `generateManifest`, `FileInfo`, `Manifest`, `VALID_NAME` (`src/index.ts` -> `src/tools/index.ts`)
|
||||
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`, `on` (event subscription), `appUrl`, `VALID_NAME`
|
||||
- `@because/toes` -- re-exports from server (`src/index.ts` -> `src/server/sync.ts`)
|
||||
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`
|
||||
|
||||
### Pages (`src/pages/`)
|
||||
|
||||
|
|
@ -150,29 +112,21 @@ Hype page routes. `index.tsx` renders the Shell.
|
|||
|
||||
### App Lifecycle
|
||||
|
||||
States: `invalid` | `error` | `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
|
||||
States: `invalid` -> `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
|
||||
|
||||
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
|
||||
- Spawn: `Bun.spawn(['bun', 'run', 'toes'])` with `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR`, `DATA_ROOT`, `TOES_URL`, `TOES_DIR`, `NO_AUTOPORT`, plus per-app env vars from `loadAppEnv()`
|
||||
- Startup: runs `bun install` first if `node_modules/` missing, then polls `/ok` every 500ms (30s timeout)
|
||||
- Health checks: every 30s to `/ok` (5s timeout), 3 consecutive failures trigger restart
|
||||
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), max 5 attempts, resets after 60s stable run. State becomes `error` after max attempts.
|
||||
- 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
|
||||
- On startup, kills stale processes on ports 3001-3100 and orphaned `bun run toes` processes
|
||||
|
||||
### Subdomain Proxy
|
||||
|
||||
Every app gets a subdomain: `<app>.localhost` (dev) or `<app>.hostname.local` (prod). The server's fetch handler (`index.tsx`) checks for subdomains first and proxies to the app's port. WebSocket connections are also proxied via Bun's `server.upgrade()` with upstream bridging. The `x-app-url` header is set so apps know their public URL.
|
||||
|
||||
### Tools vs Apps
|
||||
|
||||
Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server via subdomain: `/tool/:tool?app=foo` -> `http://<tool>.host/?app=foo`. Tool API calls can also be proxied: `/api/tools/:tool/*` -> `http://localhost:<toolPort>/*`.
|
||||
|
||||
The `apps` field in package.json controls whether a tool shows on app detail pages (`false` to hide). The `dashboard` field controls whether a tool shows on the dashboard landing page.
|
||||
Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server: `/tool/:tool?app=foo` -> `http://host:toolPort/?app=foo`.
|
||||
|
||||
### Versioning
|
||||
|
||||
Apps use git for version control. Each app has a bare git repo at `DATA_DIR/repos/<name>.git`. Deploying is a `git push` to the server's git tool, which extracts HEAD into `APPS_DIR/<name>/` and reloads the app. History, diffing, and rollback use standard git commands.
|
||||
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
|
||||
|
||||
|
|
@ -180,14 +134,14 @@ Per-app env files in `TOES_DIR/env/`:
|
|||
- `_global.env` -- shared by all apps
|
||||
- `<appname>.env` -- per-app overrides
|
||||
|
||||
The server sets these on each app process: `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR` (per-app at `TOES_DIR/<name>`), `DATA_ROOT`, `TOES_DIR`, `TOES_URL`, `NO_AUTOPORT`.
|
||||
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:reload`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`.
|
||||
- `/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
|
||||
|
||||
|
|
@ -259,21 +213,6 @@ function start(app: App): void {
|
|||
}
|
||||
```
|
||||
|
||||
## Install & Deployment
|
||||
|
||||
The install script (`install/install.sh`) is designed to run on a fresh Pi or as an updater:
|
||||
|
||||
1. Installs system packages (git, fish, avahi-utils, etc.) via apt
|
||||
2. Installs Bun and grants `cap_net_bind_service`
|
||||
3. Downloads and extracts the release tarball into `~/toes`
|
||||
4. Runs `bun install` for the server
|
||||
5. Copies bundled apps to `~/apps/` and runs `bun install` for each (in parallel)
|
||||
6. Copies pre-built bare repos to `~/data/repos/` (for git-based versioning)
|
||||
7. Installs the pre-built CLI binary to `/usr/local/bin/toes`
|
||||
8. Sets up SSH access and the systemd service
|
||||
|
||||
The release tarball URL is configured as `RELEASE_URL` at the top of `install/install.sh`.
|
||||
|
||||
## Writing Apps and Tools
|
||||
|
||||
See `docs/GUIDE.md` for the guide to writing toes apps and tools.
|
||||
See `docs/CLAUDE.md` for the guide to writing toes apps and tools.
|
||||
|
|
|
|||
93
README.md
93
README.md
|
|
@ -1,82 +1,41 @@
|
|||
# 🐾 Toes
|
||||
|
||||
Personal web appliance you run on your home network.
|
||||
Toes is a personal web server you run in your home.
|
||||
|
||||
Plug it in, turn it on, and forget about the cloud.
|
||||
|
||||
## Development
|
||||
## quickstart
|
||||
|
||||
```bash
|
||||
bun run dev # Hot reload (rebuilds client bundle on change)
|
||||
bun run start # Production mode
|
||||
bun run check # Type check
|
||||
bun run test # Tests
|
||||
bun run build # Build client JS bundle
|
||||
bun run release # Build a release tarball for the Pi
|
||||
```
|
||||
1. Plug in and turn on your Toes computer.
|
||||
2. Connect to the **Toes Setup** WiFi network (password: **toessetup**).
|
||||
A setup page will appear — choose your home WiFi and enter its password.
|
||||
3. Visit https://toes.local to get started!
|
||||
|
||||
### Releasing
|
||||
## features
|
||||
- Hosts bun/hono/hype webapps - both SSR and SPA.
|
||||
- `toes` CLI for pushing and pulling from your server.
|
||||
- `toes` CLI for local dev mode.
|
||||
- https://toes.local web UI for managing your projects.
|
||||
- Per-branch staging environments for Claude.
|
||||
|
||||
`bun run release` builds everything the Pi needs into a single tarball:
|
||||
## cli configuration
|
||||
|
||||
1. Client JS bundle (`pub/client/index.js`)
|
||||
2. Embedded templates (`src/lib/templates.data.ts`)
|
||||
3. Pre-built bare git repos for bundled apps (`dist/repos/`)
|
||||
4. Cross-compiled CLI binary for linux-arm64 (`dist/toes`)
|
||||
|
||||
Output: `dist/toes-<version>.tar.gz`
|
||||
|
||||
The Pi does zero building — it untars, runs `bun install`, and starts. Upload the tarball to wherever `RELEASE_URL` in `install/install.sh` points (currently `https://toes.dev/release/latest.tar.gz`).
|
||||
|
||||
### Scripts
|
||||
|
||||
| Script | What it does |
|
||||
|--------|-------------|
|
||||
| `scripts/build.sh` | Builds the client JS bundle into `pub/client/index.js` |
|
||||
| `scripts/build-repos.sh` | Pre-builds bare git repos for bundled apps in `dist/repos/` |
|
||||
| `scripts/release.sh` | Full release: client + templates + repos + CLI → tarball |
|
||||
| `scripts/build.ts` | Builds the CLI binary (current platform or cross-compile) |
|
||||
| `scripts/embed-templates.ts` | Generates `src/lib/templates.data.ts` from `templates/` |
|
||||
| `scripts/setup-ssh.sh` | Configures SSH access for the `cli` user on the Pi |
|
||||
| `scripts/remote-install.sh` | Runs the installer on a remote Pi over SSH |
|
||||
|
||||
## Setup
|
||||
|
||||
Toes runs on a Raspberry Pi 5 with a `toes` user and passwordless sudo.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://toes.dev/install | bash
|
||||
```
|
||||
|
||||
The installer downloads the release tarball, installs bun and system packages, runs `bun install` for the server and all bundled apps (in parallel), copies the pre-built CLI and git repos into place, and starts the systemd service.
|
||||
|
||||
Dashboard: `http://<hostname>.local`
|
||||
|
||||
## Features
|
||||
|
||||
- Hosts Bun/Hype webapps (SSR and SPA)
|
||||
- `git push` Heroku-style deploys
|
||||
- Web dashboard with real-time status, logs, and tools
|
||||
- `toes` CLI (local install or SSH)
|
||||
- Per-app environment variables, cron jobs, metrics
|
||||
- Public sharing via tunnels
|
||||
|
||||
## SSH CLI
|
||||
|
||||
Manage your server from any machine on the network — no install required.
|
||||
|
||||
```bash
|
||||
ssh cli@toes.local # interactive shell with tab completion
|
||||
ssh cli@toes.local list # run a single command
|
||||
ssh cli@toes.local logs fog # stream logs for an app
|
||||
```
|
||||
|
||||
## CLI Configuration
|
||||
|
||||
By default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
|
||||
by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
|
||||
|
||||
```bash
|
||||
toes config # show current host
|
||||
TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
|
||||
TOES_URL=http://mypi.local toes list # connect to hostname
|
||||
```
|
||||
|
||||
set `NODE_ENV=production` to default to `toes.local:80`.
|
||||
|
||||
## fun stuff
|
||||
|
||||
- textOS (TODO, more?)
|
||||
- Claude that knows about all your toes APIS and your projects.
|
||||
- non-webapps
|
||||
|
||||
## february goal
|
||||
|
||||
- [ ] Corey and Chris are running Toes servers on their home networks, hosting personal projects and games.
|
||||
|
|
|
|||
|
|
@ -3,18 +3,11 @@ import { define, stylesToCSS } from '@because/forge'
|
|||
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, resolve, extname, basename } from 'path'
|
||||
import { join, extname, basename } from 'path'
|
||||
import type { Child } from 'hono/jsx'
|
||||
|
||||
const APPS_DIR = process.env.APPS_DIR!
|
||||
|
||||
const safePath = (base: string, ...segments: string[]) => {
|
||||
const norm = resolve(base)
|
||||
const full = resolve(norm, ...segments)
|
||||
if (!full.startsWith(norm + '/') && full !== norm) return null
|
||||
return full
|
||||
}
|
||||
|
||||
const app = new Hype({ prettyHTML: false })
|
||||
|
||||
const Container = define('Container', {
|
||||
|
|
@ -264,15 +257,21 @@ const fileMemoryScript = `
|
|||
var params = new URLSearchParams(window.location.search);
|
||||
var app = params.get('app');
|
||||
var file = params.get('file');
|
||||
var version = params.get('version') || 'current';
|
||||
if (!app) return;
|
||||
var key = 'code-app:' + app + ':file';
|
||||
var key = 'code-app:' + app + ':' + version + ':file';
|
||||
if (params.has('file')) {
|
||||
// Explicit file param (even empty) - save it
|
||||
if (file) localStorage.setItem(key, file);
|
||||
else localStorage.removeItem(key);
|
||||
} else {
|
||||
// No file param - restore saved location
|
||||
var saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
window.location.replace('/?app=' + encodeURIComponent(app) + '&file=' + encodeURIComponent(saved));
|
||||
var url = '/?app=' + encodeURIComponent(app);
|
||||
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
|
||||
url += '&file=' + encodeURIComponent(saved);
|
||||
window.location.replace(url);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
@ -328,14 +327,14 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
|||
|
||||
app.get('/raw', async c => {
|
||||
const appName = c.req.query('app')
|
||||
const version = c.req.query('version') || 'current'
|
||||
const filePath = c.req.query('file')
|
||||
|
||||
if (!appName || !filePath) {
|
||||
return c.text('Missing app or file parameter', 400)
|
||||
}
|
||||
|
||||
const fullPath = safePath(APPS_DIR, appName, filePath)
|
||||
if (!fullPath) return c.text('Invalid path', 400)
|
||||
const fullPath = join(APPS_DIR, appName, version, filePath)
|
||||
const file = Bun.file(fullPath)
|
||||
|
||||
if (!await file.exists()) {
|
||||
|
|
@ -347,14 +346,14 @@ app.get('/raw', async c => {
|
|||
|
||||
app.post('/save', async c => {
|
||||
const appName = c.req.query('app')
|
||||
const version = c.req.query('version') || 'current'
|
||||
const filePath = c.req.query('file')
|
||||
|
||||
if (!appName || !filePath) {
|
||||
return c.text('Missing app or file parameter', 400)
|
||||
}
|
||||
|
||||
const fullPath = safePath(APPS_DIR, appName, filePath)
|
||||
if (!fullPath) return c.text('Invalid path', 400)
|
||||
const fullPath = join(APPS_DIR, appName, version, filePath)
|
||||
const content = await c.req.text()
|
||||
|
||||
try {
|
||||
|
|
@ -386,9 +385,10 @@ async function listFiles(appPath: string, subPath: string = '') {
|
|||
interface BreadcrumbProps {
|
||||
appName: string
|
||||
filePath: string
|
||||
versionParam: string
|
||||
}
|
||||
|
||||
function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
|
||||
function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
|
||||
const parts = filePath ? filePath.split('/').filter(Boolean) : []
|
||||
const crumbs: { name: string; path: string }[] = []
|
||||
|
||||
|
|
@ -401,7 +401,7 @@ function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
|
|||
return (
|
||||
<Breadcrumb>
|
||||
{crumbs.length > 0 ? (
|
||||
<BreadcrumbLink href={`/?app=${appName}&file=`}>{appName}</BreadcrumbLink>
|
||||
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=`}>{appName}</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
|
||||
)}
|
||||
|
|
@ -411,7 +411,7 @@ function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
|
|||
{i === crumbs.length - 1 ? (
|
||||
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
|
||||
) : (
|
||||
<BreadcrumbLink href={`/?app=${appName}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
|
||||
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
|
|
@ -479,6 +479,7 @@ function getPrismLanguage(filename: string): string {
|
|||
|
||||
app.get('/', async c => {
|
||||
const appName = c.req.query('app')
|
||||
const version = c.req.query('version') || 'current'
|
||||
const filePath = c.req.query('file') || ''
|
||||
|
||||
if (!appName) {
|
||||
|
|
@ -489,34 +490,19 @@ app.get('/', async c => {
|
|||
)
|
||||
}
|
||||
|
||||
const appPath = safePath(APPS_DIR, appName)
|
||||
if (!appPath) {
|
||||
return c.html(
|
||||
<Layout title="Code Browser">
|
||||
<ErrorBox>Invalid app name</ErrorBox>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
const appPath = join(APPS_DIR, appName, version)
|
||||
|
||||
try {
|
||||
await stat(appPath)
|
||||
} catch {
|
||||
return c.html(
|
||||
<Layout title="Code Browser">
|
||||
<ErrorBox>App "{appName}" not found</ErrorBox>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const fullPath = safePath(appPath, filePath)
|
||||
if (!fullPath) {
|
||||
return c.html(
|
||||
<Layout title="Code Browser">
|
||||
<ErrorBox>Invalid file path</ErrorBox>
|
||||
<ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const fullPath = join(appPath, filePath)
|
||||
let fileStats
|
||||
|
||||
try {
|
||||
|
|
@ -529,16 +515,18 @@ app.get('/', async c => {
|
|||
)
|
||||
}
|
||||
|
||||
const versionParam = version !== 'current' ? `&version=${version}` : ''
|
||||
|
||||
if (fileStats.isFile()) {
|
||||
const filename = basename(fullPath)
|
||||
const fileType = getFileType(filename)
|
||||
const rawUrl = `/raw?app=${appName}&file=${filePath}`
|
||||
const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
|
||||
const downloadUrl = `${rawUrl}&download=1`
|
||||
|
||||
if (fileType === 'image') {
|
||||
return c.html(
|
||||
<Layout title={`${appName}/${filePath}`}>
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||
<MediaContainer>
|
||||
<MediaHeader>
|
||||
<span>{filename}</span>
|
||||
|
|
@ -555,7 +543,7 @@ app.get('/', async c => {
|
|||
if (fileType === 'audio') {
|
||||
return c.html(
|
||||
<Layout title={`${appName}/${filePath}`}>
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||
<MediaContainer>
|
||||
<MediaHeader>
|
||||
<span>{filename}</span>
|
||||
|
|
@ -572,7 +560,7 @@ app.get('/', async c => {
|
|||
if (fileType === 'video') {
|
||||
return c.html(
|
||||
<Layout title={`${appName}/${filePath}`}>
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||
<MediaContainer>
|
||||
<MediaHeader>
|
||||
<span>{filename}</span>
|
||||
|
|
@ -627,7 +615,7 @@ saveBtn.onclick = async () => {
|
|||
status.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/save?app=${appName}&file=${filePath}', {
|
||||
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: jar.toString()
|
||||
|
|
@ -653,14 +641,14 @@ document.addEventListener('keydown', (e) => {
|
|||
`
|
||||
return c.html(
|
||||
<Layout title={`${appName}/${filePath}`} editable>
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||
<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}&file=${filePath}`}>Done</EditLink>
|
||||
<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>
|
||||
|
|
@ -672,11 +660,11 @@ document.addEventListener('keydown', (e) => {
|
|||
|
||||
return c.html(
|
||||
<Layout title={`${appName}/${filePath}`} highlight>
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||
<CodeBlock>
|
||||
<CodeHeader>
|
||||
<span>{filename}</span>
|
||||
<EditLink href={`/?app=${appName}&file=${filePath}&edit=1`}>Edit</EditLink>
|
||||
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
|
||||
</CodeHeader>
|
||||
<pre><code class={`language-${language}`}>{content}</code></pre>
|
||||
</CodeBlock>
|
||||
|
|
@ -688,11 +676,11 @@ document.addEventListener('keydown', (e) => {
|
|||
|
||||
return c.html(
|
||||
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
||||
<FileList>
|
||||
{files.map(file => (
|
||||
<FileItem>
|
||||
<FileLink href={`/?app=${appName}&file=${file.path}`}>
|
||||
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
|
||||
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
|
||||
<span>{file.name}</span>
|
||||
</FileLink>
|
||||
|
|
@ -15,7 +15,7 @@ const APPS_DIR = process.env.APPS_DIR!
|
|||
|
||||
const app = new Hype({ prettyHTML: false })
|
||||
|
||||
// Styles
|
||||
// Styles (follow versions tool pattern)
|
||||
const Container = define('Container', {
|
||||
fontFamily: theme('fonts-sans'),
|
||||
padding: '20px',
|
||||
|
|
@ -572,7 +572,7 @@ app.post('/new', async c => {
|
|||
return c.redirect('/new?error=invalid-name')
|
||||
}
|
||||
|
||||
const cronDir = join(APPS_DIR, appName, 'cron')
|
||||
const cronDir = join(APPS_DIR, appName, 'current', 'cron')
|
||||
const filePath = join(cronDir, `${name}.ts`)
|
||||
|
||||
// Check if file already exists
|
||||
|
|
@ -691,7 +691,7 @@ watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
|||
debounceTimer = setTimeout(rediscover, 100)
|
||||
})
|
||||
|
||||
on(['app:reload', 'app:delete'], (event) => {
|
||||
on(['app:activate', 'app:delete'], (event) => {
|
||||
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
|
||||
rediscover()
|
||||
})
|
||||
|
|
@ -13,7 +13,8 @@ export async function getApps(): Promise<string[]> {
|
|||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (existsSync(join(APPS_DIR, entry.name, 'package.json'))) {
|
||||
// Check if it has a current symlink (valid app)
|
||||
if (existsSync(join(APPS_DIR, entry.name, 'current'))) {
|
||||
apps.push(entry.name)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +35,7 @@ export async function discoverCronJobs(): Promise<DiscoveryResult> {
|
|||
for (const app of apps) {
|
||||
if (!app.isDirectory()) continue
|
||||
|
||||
const cronDir = join(APPS_DIR, app.name, 'cron')
|
||||
const cronDir = join(APPS_DIR, app.name, 'current', 'cron')
|
||||
if (!existsSync(cronDir)) continue
|
||||
|
||||
const files = await readdir(cronDir)
|
||||
|
|
@ -37,7 +37,7 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
|
|||
job.lastDuration = undefined
|
||||
onUpdate()
|
||||
|
||||
const cwd = join(APPS_DIR, job.app)
|
||||
const cwd = join(APPS_DIR, job.app, 'current')
|
||||
|
||||
forwardLog(job.app, `[cron] Running ${job.name}`)
|
||||
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"dependencies": {
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"@because/toes": "^0.0.18",
|
||||
"@because/toes": "^0.0.8",
|
||||
"croner": "^9.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -288,23 +288,6 @@ document.querySelectorAll('[data-reveal]').forEach(btn => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="key"]').forEach(input => {
|
||||
input.addEventListener('paste', e => {
|
||||
const text = e.clipboardData?.getData('text') ?? '';
|
||||
const eqIndex = text.indexOf('=');
|
||||
if (eqIndex === -1) return;
|
||||
e.preventDefault();
|
||||
const key = text.slice(0, eqIndex).trim();
|
||||
const value = text.slice(eqIndex + 1).trim();
|
||||
input.value = key;
|
||||
const valueInput = input.closest('form').querySelector('input[name="value"]');
|
||||
if (valueInput) {
|
||||
valueInput.value = value;
|
||||
valueInput.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
`
|
||||
|
||||
app.get('/ok', c => c.text('ok'))
|
||||
|
|
@ -317,35 +300,9 @@ app.get('/', async c => {
|
|||
const appName = c.req.query('app')
|
||||
|
||||
if (!appName) {
|
||||
// Dashboard view: global env vars only
|
||||
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
|
||||
|
||||
return c.html(
|
||||
<Layout title="Global Environment Variables">
|
||||
{globalVars.length === 0 ? (
|
||||
<EmptyState>No global environment variables</EmptyState>
|
||||
) : (
|
||||
<EnvList>
|
||||
{globalVars.map(v => (
|
||||
<EnvItem data-env-item>
|
||||
<EnvKey>{v.key}</EnvKey>
|
||||
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
|
||||
<EnvActions>
|
||||
<Button data-reveal>Reveal</Button>
|
||||
<form method="post" action={`/delete-global?key=${v.key}`} style="margin:0">
|
||||
<DangerButton type="submit">Delete</DangerButton>
|
||||
</form>
|
||||
</EnvActions>
|
||||
</EnvItem>
|
||||
))}
|
||||
</EnvList>
|
||||
)}
|
||||
<Form method="POST" action="/set-global">
|
||||
<Input type="text" name="key" placeholder="KEY" required />
|
||||
<Input type="text" name="value" placeholder="value" required />
|
||||
<Button type="submit">Add</Button>
|
||||
</Form>
|
||||
<Hint>Global vars are available to all apps. Changes take effect on next app restart.</Hint>
|
||||
<Layout title="Environment Variables">
|
||||
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -480,6 +437,7 @@ app.post('/delete', async c => {
|
|||
|
||||
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()
|
||||
|
|
@ -497,17 +455,17 @@ app.post('/set-global', async c => {
|
|||
}
|
||||
|
||||
writeEnvFile(GLOBAL_ENV_PATH, vars)
|
||||
return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
|
||||
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 (!key) return c.text('Missing key', 400)
|
||||
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(appName ? `/?app=${appName}&tab=global` : '/')
|
||||
return c.redirect(`/?app=${appName}&tab=global`)
|
||||
})
|
||||
|
||||
export default app.defaults
|
||||
|
|
@ -10,8 +10,7 @@
|
|||
},
|
||||
"toes": {
|
||||
"tool": ".env",
|
||||
"icon": "🔑",
|
||||
"dashboard": true
|
||||
"icon": "🔑"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "git",
|
||||
"dependencies": {
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"@because/toes": "^0.0.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||
|
||||
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||
|
||||
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
|
||||
|
||||
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.3", "https://npm.nose.space/@types/node/-/node-25.3.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"hono": ["hono@4.12.3", "https://npm.nose.space/hono/-/hono-4.12.3.tgz", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -1,869 +0,0 @@
|
|||
import { Hype } from '@because/hype'
|
||||
import { define, stylesToCSS } from '@because/forge'
|
||||
import { baseStyles, ToolScript, theme, on, VALID_NAME } from '@because/toes/tools'
|
||||
import { mkdirSync } from 'fs'
|
||||
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
|
||||
import { join, resolve } from 'path'
|
||||
import type { Child } from 'hono/jsx'
|
||||
|
||||
const APP_URL = process.env.APP_URL!
|
||||
const APPS_DIR = process.env.APPS_DIR!
|
||||
const DATA_DIR = process.env.DATA_DIR!
|
||||
const DATA_ROOT = process.env.DATA_ROOT!
|
||||
const TOES_URL = process.env.TOES_URL!
|
||||
|
||||
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
|
||||
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
|
||||
|
||||
const app = new Hype({ prettyHTML: false, layout: false })
|
||||
const deployLocks = new Map<string, Promise<void>>()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Badge = define('Badge', {
|
||||
fontSize: '12px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: theme('radius-md'),
|
||||
backgroundColor: theme('colors-bgElement'),
|
||||
color: theme('colors-textMuted'),
|
||||
})
|
||||
|
||||
const CodeBlock = define('CodeBlock', {
|
||||
base: 'pre',
|
||||
backgroundColor: theme('colors-bgElement'),
|
||||
border: `1px solid ${theme('colors-border')}`,
|
||||
borderRadius: theme('radius-md'),
|
||||
padding: theme('spacing-lg'),
|
||||
fontFamily: theme('fonts-mono'),
|
||||
fontSize: '13px',
|
||||
overflowX: 'auto',
|
||||
color: theme('colors-text'),
|
||||
lineHeight: '1.5',
|
||||
})
|
||||
|
||||
const Container = define('Container', {
|
||||
fontFamily: theme('fonts-sans'),
|
||||
padding: '20px',
|
||||
paddingTop: 0,
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
color: theme('colors-text'),
|
||||
})
|
||||
|
||||
const Heading = define('Heading', {
|
||||
base: 'h3',
|
||||
margin: '24px 0 8px',
|
||||
color: theme('colors-text'),
|
||||
})
|
||||
|
||||
const HelpText = define('HelpText', {
|
||||
color: theme('colors-textMuted'),
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
margin: '12px 0',
|
||||
})
|
||||
|
||||
const RepoItem = define('RepoItem', {
|
||||
padding: '12px 15px',
|
||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
states: {
|
||||
':last-child': { borderBottom: 'none' },
|
||||
':hover': { backgroundColor: theme('colors-bgHover') },
|
||||
},
|
||||
})
|
||||
|
||||
const RepoList = define('RepoList', {
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: '20px 0',
|
||||
border: `1px solid ${theme('colors-border')}`,
|
||||
borderRadius: theme('radius-md'),
|
||||
overflow: 'hidden',
|
||||
})
|
||||
|
||||
const RepoName = define('RepoName', {
|
||||
fontFamily: theme('fonts-mono'),
|
||||
fontSize: '15px',
|
||||
fontWeight: 'bold',
|
||||
color: theme('colors-text'),
|
||||
})
|
||||
|
||||
const Tab = define('Tab', {
|
||||
base: 'button',
|
||||
padding: '6px 0',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: theme('colors-textMuted'),
|
||||
states: {
|
||||
':hover': { color: theme('colors-text') },
|
||||
'.active': {
|
||||
color: theme('colors-text'),
|
||||
borderBottomColor: theme('colors-primary'),
|
||||
fontWeight: '500',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const TabBar = define('TabBar', {
|
||||
display: 'flex',
|
||||
gap: '24px',
|
||||
marginBottom: '20px',
|
||||
})
|
||||
|
||||
const Toggle = define('Toggle', {
|
||||
base: 'button',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '3px 10px',
|
||||
borderRadius: theme('radius-md'),
|
||||
border: `1px solid ${theme('colors-border')}`,
|
||||
backgroundColor: theme('colors-bgElement'),
|
||||
color: theme('colors-textMuted'),
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
states: {
|
||||
':hover': { borderColor: theme('colors-textMuted') },
|
||||
'.public': {
|
||||
backgroundColor: theme('colors-statusRunning'),
|
||||
color: 'white',
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AppRepoProps {
|
||||
appName: string
|
||||
baseUrl: string
|
||||
branch: string
|
||||
exists: boolean
|
||||
commits: boolean
|
||||
}
|
||||
|
||||
interface LayoutProps {
|
||||
title: string
|
||||
children: Child
|
||||
}
|
||||
|
||||
interface RepoListPageProps {
|
||||
baseUrl: string
|
||||
external: boolean
|
||||
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility; tool: boolean }>
|
||||
tunnelUrl?: string
|
||||
}
|
||||
|
||||
type Visibility = 'public' | 'private'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)
|
||||
|
||||
// resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal
|
||||
const validRepoName = (name: string) =>
|
||||
VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)
|
||||
|
||||
async function activateApp(name: string): Promise<string | null> {
|
||||
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
const msg = (body as Record<string, string>).error ?? `reload returned ${res.status}`
|
||||
console.error(`Reload failed for ${name}:`, msg)
|
||||
return msg
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const bare = repoPath(repoName)
|
||||
|
||||
if (!(await hasCommits(bare))) {
|
||||
return { ok: false, error: 'No commits in repository' }
|
||||
}
|
||||
|
||||
// Validate in a temp dir before touching the real app dir
|
||||
const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`)
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
await mkdir(tmpDir, { recursive: true })
|
||||
|
||||
// Extract HEAD into the temp directory
|
||||
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], {
|
||||
stdin: archive.stdout,
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
// Consume stderr concurrently to prevent pipe buffer from filling and blocking the process
|
||||
const [archiveExit, tarExit, archiveErr, tarErr] = await Promise.all([
|
||||
archive.exited,
|
||||
tar.exited,
|
||||
new Response(archive.stderr).text(),
|
||||
new Response(tar.stderr).text(),
|
||||
])
|
||||
|
||||
if (archiveExit !== 0 || tarExit !== 0) {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
|
||||
}
|
||||
|
||||
// Stop the app before swapping directories
|
||||
await stopIfRunning(repoName)
|
||||
|
||||
// Validation passed — swap directories (reload endpoint handles restart)
|
||||
const appDir = join(APPS_DIR, repoName)
|
||||
await rm(appDir, { recursive: true, force: true })
|
||||
await rename(tmpDir, appDir)
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Bun.file().exists() is for files only — it returns false for directories.
|
||||
// Use stat() to check directory existence instead.
|
||||
async function dirExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBareRepo(name: string): Promise<string> {
|
||||
const bare = repoPath(name)
|
||||
if (!(await dirExists(bare))) {
|
||||
await mkdir(bare, { recursive: true })
|
||||
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: bare }).exited
|
||||
await run(['git', 'init', '--bare'])
|
||||
await run(['git', 'symbolic-ref', 'HEAD', 'refs/heads/main'])
|
||||
await run(['git', 'config', 'http.receivepack', 'true'])
|
||||
}
|
||||
return bare
|
||||
}
|
||||
|
||||
function findLastFlush(data: Uint8Array): number {
|
||||
for (let i = data.length - 4; i >= 0; i--) {
|
||||
if (data[i] === 0x30 && data[i + 1] === 0x30 &&
|
||||
data[i + 2] === 0x30 && data[i + 3] === 0x30) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
async function getVisibility(repo: string): Promise<Visibility> {
|
||||
const all = await loadVisibility()
|
||||
return all[repo] ?? 'private'
|
||||
}
|
||||
|
||||
async function getDefaultBranch(bare: string): Promise<string> {
|
||||
const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
|
||||
cwd: bare,
|
||||
stdout: 'pipe',
|
||||
// Ignore stderr to avoid filling the pipe buffer and blocking the process
|
||||
stderr: 'ignore',
|
||||
})
|
||||
if ((await proc.exited) === 0) {
|
||||
const ref = await new Response(proc.stdout).text()
|
||||
return ref.trim().replace('refs/heads/', '')
|
||||
}
|
||||
return 'main'
|
||||
}
|
||||
|
||||
async function gitRpc(
|
||||
repo: string,
|
||||
service: string,
|
||||
body: Uint8Array | ReadableStream<Uint8Array> | null,
|
||||
): Promise<Response> {
|
||||
const bare = repoPath(repo)
|
||||
|
||||
const proc = Bun.spawn([service, '--stateless-rpc', bare], {
|
||||
stdin: body ?? 'ignore',
|
||||
stdout: 'pipe',
|
||||
// Ignore stderr to avoid filling the pipe buffer and blocking the process
|
||||
stderr: 'ignore',
|
||||
})
|
||||
|
||||
return new Response(proc.stdout, {
|
||||
headers: {
|
||||
'Content-Type': `application/x-${service}-result`,
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function gitService(repo: string, service: string): Promise<Response | null> {
|
||||
const bare = repoPath(repo)
|
||||
if (!(await dirExists(bare))) return null
|
||||
|
||||
const proc = Bun.spawn([service, '--stateless-rpc', '--advertise-refs', bare], {
|
||||
stdout: 'pipe',
|
||||
// Ignore stderr to avoid filling the pipe buffer and blocking the process
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const stdout = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
|
||||
await proc.exited
|
||||
|
||||
const header = serviceHeader(service)
|
||||
const body = new Uint8Array(header.length + stdout.byteLength)
|
||||
body.set(header, 0)
|
||||
body.set(stdout, header.length)
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': `application/x-${service}-advertisement`,
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function gitSidebandMessage(text: string): Uint8Array {
|
||||
const encoder = new TextEncoder()
|
||||
const lines = text.split('\n').filter(Boolean)
|
||||
const parts: Uint8Array[] = []
|
||||
for (const line of lines) {
|
||||
const msg = `\x02remote: ${line}\n`
|
||||
const hex = (4 + msg.length).toString(16).padStart(4, '0')
|
||||
parts.push(encoder.encode(hex + msg))
|
||||
}
|
||||
const total = parts.reduce((sum, p) => sum + p.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let offset = 0
|
||||
for (const part of parts) {
|
||||
out.set(part, offset)
|
||||
offset += part.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function hasCommits(bare: string): Promise<boolean> {
|
||||
const proc = Bun.spawn(['git', 'rev-parse', 'HEAD'], {
|
||||
cwd: bare,
|
||||
// Only checking exit code; ignore stdout/stderr to avoid filling the pipe buffer
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
return (await proc.exited) === 0
|
||||
}
|
||||
|
||||
function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array {
|
||||
const pos = findLastFlush(gitBody)
|
||||
if (pos === -1) {
|
||||
const out = new Uint8Array(gitBody.length + msg.length)
|
||||
out.set(gitBody, 0)
|
||||
out.set(msg, gitBody.length)
|
||||
return out
|
||||
}
|
||||
const out = new Uint8Array(gitBody.length + msg.length)
|
||||
out.set(gitBody.subarray(0, pos), 0)
|
||||
out.set(msg, pos)
|
||||
out.set(gitBody.subarray(pos), pos + msg.length)
|
||||
return out
|
||||
}
|
||||
|
||||
async function loadVisibility(): Promise<Record<string, Visibility>> {
|
||||
try {
|
||||
const data = await readFile(VISIBILITY_PATH, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function Layout({ title, children }: LayoutProps) {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<ToolScript />
|
||||
<Container>{children}</Container>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
async function listRepos(): Promise<string[]> {
|
||||
if (!(await dirExists(REPOS_DIR))) return []
|
||||
const entries = await readdir(REPOS_DIR, { withFileTypes: true })
|
||||
return entries
|
||||
.filter(e => e.isDirectory() && e.name.endsWith('.git'))
|
||||
.map(e => e.name.replace(/\.git$/, ''))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function serviceHeader(service: string): Uint8Array {
|
||||
const line = `# service=${service}\n`
|
||||
const hex = (4 + line.length).toString(16).padStart(4, '0')
|
||||
const header = `${hex}${line}0000`
|
||||
return new TextEncoder().encode(header)
|
||||
}
|
||||
|
||||
async function saveVisibility(repo: string, visibility: Visibility): Promise<void> {
|
||||
const all = await loadVisibility()
|
||||
all[repo] = visibility
|
||||
await writeFile(VISIBILITY_PATH, JSON.stringify(all, null, 2))
|
||||
}
|
||||
|
||||
async function stopIfRunning(name: string): Promise<void> {
|
||||
const res = await fetch(`${TOES_URL}/api/apps/${name}`)
|
||||
if (!res.ok) return
|
||||
|
||||
const app = await res.json() as { state: string }
|
||||
if (app.state !== 'running' && app.state !== 'starting') return
|
||||
|
||||
await fetch(`${TOES_URL}/api/apps/${name}/stop`, { method: 'POST' })
|
||||
|
||||
const maxWait = 10000
|
||||
const poll = 100
|
||||
let waited = 0
|
||||
while (waited < maxWait) {
|
||||
await new Promise(r => setTimeout(r, poll))
|
||||
waited += poll
|
||||
const check = await fetch(`${TOES_URL}/api/apps/${name}`)
|
||||
if (!check.ok) break
|
||||
const { state } = await check.json() as { state: string }
|
||||
if (state !== 'running' && state !== 'stopping' && state !== 'starting') break
|
||||
}
|
||||
}
|
||||
|
||||
async function withDeployLock<T>(repo: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = deployLocks.get(repo) ?? Promise.resolve()
|
||||
const { promise: lock, resolve: release } = Promise.withResolvers<void>()
|
||||
deployLocks.set(repo, lock)
|
||||
await prev
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
release()
|
||||
if (deployLocks.get(repo) === lock) deployLocks.delete(repo)
|
||||
}
|
||||
}
|
||||
|
||||
function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
|
||||
return (
|
||||
<Layout title={`Git - ${appName}`}>
|
||||
{exists && commits ? (
|
||||
<>
|
||||
<Heading>Repository</Heading>
|
||||
<RepoList>
|
||||
<RepoItem>
|
||||
<div>
|
||||
<RepoName>{appName}</RepoName>
|
||||
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
||||
git clone {baseUrl}/{appName}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<Badge>{branch}</Badge>
|
||||
<Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
||||
</div>
|
||||
</RepoItem>
|
||||
</RepoList>
|
||||
|
||||
<Heading>Push Changes</Heading>
|
||||
<CodeBlock>{[
|
||||
`git push toes ${branch}`,
|
||||
'',
|
||||
'# Or if remote not yet added:',
|
||||
`git remote add toes ${baseUrl}/${appName}`,
|
||||
`git push toes ${branch}`,
|
||||
].join('\n')}</CodeBlock>
|
||||
</>
|
||||
) : exists ? (
|
||||
<>
|
||||
<Heading>Repository</Heading>
|
||||
<RepoList>
|
||||
<RepoItem>
|
||||
<div>
|
||||
<RepoName>{appName}</RepoName>
|
||||
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
||||
git clone {baseUrl}/{appName}
|
||||
</HelpText>
|
||||
</div>
|
||||
<Badge>empty</Badge>
|
||||
</RepoItem>
|
||||
</RepoList>
|
||||
|
||||
<Heading>Push to Deploy</Heading>
|
||||
<CodeBlock>{[
|
||||
`git remote add toes ${baseUrl}/${appName}`,
|
||||
'git push toes main',
|
||||
].join('\n')}</CodeBlock>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Heading>Push to Deploy</Heading>
|
||||
<HelpText>
|
||||
No git repository for <strong>{appName}</strong> yet.
|
||||
Push to create one and deploy.
|
||||
</HelpText>
|
||||
<CodeBlock>{[
|
||||
`git remote add toes ${baseUrl}/${appName}`,
|
||||
'git push toes main',
|
||||
].join('\n')}</CodeBlock>
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
function RepoListItems({ baseUrl, external, repos, tunnelUrl }: {
|
||||
baseUrl: string
|
||||
external: boolean
|
||||
repos: RepoListPageProps['repos']
|
||||
tunnelUrl?: string
|
||||
}) {
|
||||
if (repos.length === 0) {
|
||||
return <HelpText>No repositories yet.</HelpText>
|
||||
}
|
||||
return (
|
||||
<RepoList>
|
||||
{repos.map(({ name, commits, branch, visibility }) => (
|
||||
<RepoItem>
|
||||
<div>
|
||||
<RepoName>{name}</RepoName>
|
||||
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
||||
git clone {baseUrl}/{name}
|
||||
</HelpText>
|
||||
{!external && tunnelUrl && visibility === 'public' && (
|
||||
<HelpText style="margin: 2px 0 0; font-size: 12px">
|
||||
git clone {tunnelUrl}/{name}
|
||||
</HelpText>
|
||||
)}
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
{!external && (
|
||||
<Toggle
|
||||
class={visibility === 'public' ? 'public' : ''}
|
||||
data-repo={name}
|
||||
data-visibility={visibility}
|
||||
onclick="toggleVisibility(this)"
|
||||
>
|
||||
{visibility === 'public' ? 'public' : 'private'}
|
||||
</Toggle>
|
||||
)}
|
||||
<Badge>{branch}</Badge>
|
||||
{commits
|
||||
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
||||
: <Badge>empty</Badge>}
|
||||
</div>
|
||||
</RepoItem>
|
||||
))}
|
||||
</RepoList>
|
||||
)
|
||||
}
|
||||
|
||||
function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) {
|
||||
const appRepos = repos.filter(r => !r.tool)
|
||||
const toolRepos = repos.filter(r => r.tool)
|
||||
|
||||
return (
|
||||
<Layout title="Git">
|
||||
{!external && (
|
||||
<>
|
||||
<Heading>Push to Deploy</Heading>
|
||||
<HelpText>
|
||||
Push a git repository to deploy it as a toes app.
|
||||
The repo must contain a <code>package.json</code> with a <code>scripts.toes</code> entry.
|
||||
</HelpText>
|
||||
|
||||
<CodeBlock>{[
|
||||
'# Add this server as a remote and push',
|
||||
`git remote add toes ${baseUrl}/<app-name>`,
|
||||
'git push toes main',
|
||||
'',
|
||||
'# Or push an existing repo',
|
||||
`git push ${baseUrl}/<app-name> main`,
|
||||
].join('\n')}</CodeBlock>
|
||||
</>
|
||||
)}
|
||||
|
||||
{repos.length > 0 && appRepos.length > 0 && toolRepos.length > 0 && (
|
||||
<>
|
||||
<Heading>Repositories</Heading>
|
||||
<TabBar>
|
||||
<Tab class="active" data-tab="tab-apps" onclick="switchTab(this)">Apps</Tab>
|
||||
<Tab data-tab="tab-tools" onclick="switchTab(this)">Tools</Tab>
|
||||
</TabBar>
|
||||
<div>
|
||||
<div id="tab-apps">
|
||||
<RepoListItems baseUrl={baseUrl} external={external} repos={appRepos} tunnelUrl={tunnelUrl} />
|
||||
</div>
|
||||
<div id="tab-tools" style="display: none">
|
||||
<RepoListItems baseUrl={baseUrl} external={external} repos={toolRepos} tunnelUrl={tunnelUrl} />
|
||||
</div>
|
||||
</div>
|
||||
{!external && <script src="/client/toggle.js" />}
|
||||
<script src="/client/tabs.js" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{repos.length > 0 && (appRepos.length === 0 || toolRepos.length === 0) && (
|
||||
<>
|
||||
<Heading>Repositories</Heading>
|
||||
<RepoListItems baseUrl={baseUrl} external={external} repos={repos} tunnelUrl={tunnelUrl} />
|
||||
{!external && <script src="/client/toggle.js" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{repos.length === 0 && (
|
||||
<HelpText>No repositories yet. Push one to get started.</HelpText>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
mkdirSync(REPOS_DIR, { recursive: true })
|
||||
|
||||
// Auto-deploy bare repos that don't have a corresponding app directory
|
||||
async function deployUndeployedRepos() {
|
||||
const repos = await listRepos()
|
||||
for (const name of repos) {
|
||||
const appDir = join(APPS_DIR, name)
|
||||
if (await dirExists(appDir)) continue
|
||||
|
||||
const bare = repoPath(name)
|
||||
if (!(await hasCommits(bare))) continue
|
||||
|
||||
console.log(`Auto-deploying undeployed repo: ${name}`)
|
||||
const result = await deploy(name)
|
||||
if (result.ok) {
|
||||
await activateApp(name)
|
||||
} else {
|
||||
console.error(`Auto-deploy failed for ${name}: ${result.error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deployUndeployedRepos()
|
||||
|
||||
on('app:delete', async ({ app: name }) => {
|
||||
const bare = repoPath(name)
|
||||
if (await dirExists(bare)) await rm(bare, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
app.get('/ok', c => c.text('ok'))
|
||||
|
||||
app.get('/styles.css', c =>
|
||||
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
|
||||
)
|
||||
|
||||
|
||||
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
|
||||
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
|
||||
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
||||
const service = c.req.query('service')
|
||||
|
||||
if (!validRepoName(repoParam)) {
|
||||
return c.text('Invalid repository name', 400)
|
||||
}
|
||||
|
||||
if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
|
||||
return c.text('Invalid service', 400)
|
||||
}
|
||||
|
||||
if (c.req.header('x-sneaker')) {
|
||||
if (service === 'git-receive-pack') {
|
||||
return c.text('Push access denied over sneaker', 403)
|
||||
}
|
||||
if (await getVisibility(repoParam) !== 'public') {
|
||||
return c.text('Repository not found', 404)
|
||||
}
|
||||
}
|
||||
|
||||
if (service === 'git-receive-pack') {
|
||||
await ensureBareRepo(repoParam)
|
||||
}
|
||||
|
||||
const bare = repoPath(repoParam)
|
||||
if (!(await dirExists(bare))) {
|
||||
return c.text('Repository not found', 404)
|
||||
}
|
||||
|
||||
const res = await gitService(repoParam, service)
|
||||
return res ?? c.text('Repository not found', 404)
|
||||
})
|
||||
|
||||
// POST /:repo[.git]/git-upload-pack
|
||||
app.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], async c => {
|
||||
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
||||
|
||||
if (!validRepoName(repoParam)) {
|
||||
return c.text('Invalid repository name', 400)
|
||||
}
|
||||
|
||||
if (c.req.header('x-sneaker') && await getVisibility(repoParam) !== 'public') {
|
||||
return c.text('Repository not found', 404)
|
||||
}
|
||||
|
||||
const bare = repoPath(repoParam)
|
||||
if (!(await dirExists(bare))) {
|
||||
return c.text('Repository not found', 404)
|
||||
}
|
||||
|
||||
return gitRpc(repoParam, 'git-upload-pack', c.req.raw.body)
|
||||
})
|
||||
|
||||
// POST /:repo[.git]/git-receive-pack
|
||||
app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'], async c => {
|
||||
if (c.req.header('x-sneaker')) {
|
||||
return c.text('Push access denied over sneaker', 403)
|
||||
}
|
||||
|
||||
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
||||
|
||||
if (!validRepoName(repoParam)) {
|
||||
return c.text('Invalid repository name', 400)
|
||||
}
|
||||
|
||||
await ensureBareRepo(repoParam)
|
||||
|
||||
// Buffer the request body before passing to git-receive-pack. Piping a live
|
||||
// HTTP ReadableStream directly to subprocess stdin deadlocks on large pushes:
|
||||
// the pipe buffer fills, stalling the stream reader, while git-receive-pack
|
||||
// can't finish reading stdin to produce stdout — both sides block.
|
||||
const body = new Uint8Array(await c.req.raw.arrayBuffer())
|
||||
const response = await gitRpc(repoParam, 'git-receive-pack', body)
|
||||
// Buffer the full response so we can inject sideband error messages before the
|
||||
// final flush-pkt on deploy failure. The receive-pack response is just ref status
|
||||
// lines (not pack data), so the buffer is small regardless of push size.
|
||||
const gitBody = new Uint8Array(await response.arrayBuffer())
|
||||
|
||||
const deployError = await withDeployLock(repoParam, async () => {
|
||||
try {
|
||||
const result = await deploy(repoParam)
|
||||
if (result.ok) {
|
||||
const err = await activateApp(repoParam)
|
||||
if (err) {
|
||||
console.error(`Reload failed for ${repoParam}: ${err}`)
|
||||
return `Deploy succeeded but reload failed: ${err}`
|
||||
}
|
||||
console.log(`Deployed ${repoParam}`)
|
||||
return null
|
||||
}
|
||||
console.error(`Deploy failed for ${repoParam}: ${result.error}`)
|
||||
return `Deploy failed: ${result.error}`
|
||||
} catch (e) {
|
||||
console.error(`Deploy error for ${repoParam}:`, e)
|
||||
return `Deploy failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': response.headers.get('Content-Type') ?? 'application/x-git-receive-pack-result',
|
||||
'Cache-Control': 'no-cache',
|
||||
}
|
||||
|
||||
if (deployError) {
|
||||
return new Response(insertBeforeFlush(gitBody, gitSidebandMessage(deployError)), { headers })
|
||||
}
|
||||
|
||||
return new Response(gitBody, { headers })
|
||||
})
|
||||
|
||||
app.post('/api/visibility/:repo', async c => {
|
||||
if (c.req.header('x-sneaker')) return c.json({ error: 'Forbidden' }, 403)
|
||||
|
||||
const repo = c.req.param('repo')
|
||||
if (!validRepoName(repo)) return c.json({ error: 'Invalid repository name' }, 400)
|
||||
|
||||
const body = await c.req.json<{ visibility: string }>()
|
||||
if (body.visibility !== 'public' && body.visibility !== 'private') {
|
||||
return c.json({ error: 'Visibility must be "public" or "private"' }, 400)
|
||||
}
|
||||
|
||||
await saveVisibility(repo, body.visibility)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/', async c => {
|
||||
const appName = c.req.query('app')
|
||||
const sneakerHost = c.req.header('x-sneaker')
|
||||
const external = !!sneakerHost
|
||||
const baseUrl = sneakerHost ? `https://${sneakerHost}` : APP_URL
|
||||
|
||||
// When viewing a specific app, only show that app's repo
|
||||
if (appName) {
|
||||
const bare = repoPath(appName)
|
||||
const exists = await dirExists(bare)
|
||||
const [commits, branch] = exists
|
||||
? await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
|
||||
: [false, 'main']
|
||||
|
||||
return c.html(<AppRepo appName={appName} baseUrl={baseUrl} branch={branch} exists={exists} commits={commits} />)
|
||||
}
|
||||
|
||||
// No app selected — show all repos
|
||||
const repos = await listRepos()
|
||||
|
||||
// Fetch all apps to determine which repos are tools
|
||||
let toolSet = new Set<string>()
|
||||
try {
|
||||
const res = await fetch(`${TOES_URL}/api/apps`)
|
||||
if (res.ok) {
|
||||
const apps = await res.json() as Array<{ name: string; tool?: boolean | string }>
|
||||
for (const a of apps) {
|
||||
if (a.tool) toolSet.add(a.name)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const repoData = await Promise.all(repos.map(async name => {
|
||||
const bare = repoPath(name)
|
||||
const [commits, branch, visibility] = await Promise.all([
|
||||
hasCommits(bare),
|
||||
getDefaultBranch(bare),
|
||||
getVisibility(name),
|
||||
])
|
||||
return { name, commits, branch, visibility, tool: toolSet.has(name) }
|
||||
}))
|
||||
|
||||
// Hide private repos from external (sneaker) requests
|
||||
const filtered = external
|
||||
? repoData.filter(r => r.visibility === 'public')
|
||||
: repoData
|
||||
|
||||
// Fetch tunnel URL for the git tool so we can show it for public repos
|
||||
let tunnelUrl: string | undefined
|
||||
if (!external) {
|
||||
try {
|
||||
const res = await fetch(`${TOES_URL}/api/apps/git`)
|
||||
if (res.ok) {
|
||||
const info = await res.json() as { tunnelUrl?: string }
|
||||
tunnelUrl = info.tunnelUrl
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} tunnelUrl={tunnelUrl} />)
|
||||
})
|
||||
|
||||
export default app.defaults
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
function switchTab(btn: HTMLButtonElement) {
|
||||
const tabs = btn.parentElement!.querySelectorAll('button')
|
||||
for (const tab of tabs) tab.classList.remove('active')
|
||||
btn.classList.add('active')
|
||||
|
||||
const panels = btn.parentElement!.nextElementSibling!.children
|
||||
for (const panel of panels) (panel as HTMLElement).style.display = 'none'
|
||||
|
||||
const target = document.getElementById(btn.dataset.tab!)
|
||||
if (target) target.style.display = 'block'
|
||||
}
|
||||
|
||||
Object.assign(window, { switchTab })
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
function toggleVisibility(btn: HTMLButtonElement) {
|
||||
const repo = btn.dataset.repo!
|
||||
const current = btn.dataset.visibility!
|
||||
const next = current === 'public' ? 'private' : 'public'
|
||||
btn.dataset.visibility = next
|
||||
btn.textContent = next
|
||||
btn.classList.toggle('public', next === 'public')
|
||||
fetch('/api/visibility/' + encodeURIComponent(repo), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ visibility: next }),
|
||||
}).catch(() => {
|
||||
btn.dataset.visibility = current
|
||||
btn.textContent = current
|
||||
btn.classList.toggle('public', current === 'public')
|
||||
})
|
||||
}
|
||||
|
||||
Object.assign(window, { toggleVisibility })
|
||||
|
|
@ -55,12 +55,6 @@ interface ProcessMetrics {
|
|||
rss: number
|
||||
}
|
||||
|
||||
interface SystemMetrics {
|
||||
cpu: number
|
||||
ram: { used: number, total: number, percent: number }
|
||||
disk: { used: number, total: number, percent: number }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Process Metrics Collection
|
||||
// ============================================================================
|
||||
|
|
@ -408,40 +402,6 @@ const ChartWrapper = define('ChartWrapper', {
|
|||
height: '150px',
|
||||
})
|
||||
|
||||
const GaugeLabel = define('GaugeLabel', {
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: theme('colors-textMuted'),
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
})
|
||||
|
||||
const GaugeValueText = define('GaugeValueText', {
|
||||
textAlign: 'center',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
marginTop: '-4px',
|
||||
color: theme('colors-text'),
|
||||
})
|
||||
|
||||
const GaugesCard = define('GaugesCard', {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: theme('colors-bgElement'),
|
||||
border: `1px solid ${theme('colors-border')}`,
|
||||
borderRadius: theme('radius-md'),
|
||||
padding: '24px',
|
||||
})
|
||||
|
||||
const GaugesGrid = define('GaugesGrid', {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '40px',
|
||||
padding: '20px 0',
|
||||
})
|
||||
|
||||
const NoDataMessage = define('NoDataMessage', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
|
@ -475,14 +435,6 @@ function formatRss(kb?: number): string {
|
|||
return `${(kb / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
async function fetchSystemMetrics(): Promise<SystemMetrics> {
|
||||
try {
|
||||
const res = await fetch(`${TOES_URL}/api/system/metrics`)
|
||||
if (res.ok) return await res.json() as SystemMetrics
|
||||
} catch {}
|
||||
return { cpu: 0, ram: { used: 0, total: 0, percent: 0 }, disk: { used: 0, total: 0, percent: 0 } }
|
||||
}
|
||||
|
||||
function getStatusColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
|
|
@ -525,107 +477,6 @@ function Layout({ title, children }: LayoutProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gauge Rendering
|
||||
// ============================================================================
|
||||
|
||||
const G_SEGMENTS = 19
|
||||
const G_START = -225
|
||||
const G_SWEEP = 270
|
||||
const G_CX = 60
|
||||
const G_CY = 60
|
||||
const G_R = 44
|
||||
const G_GAP = 3
|
||||
const G_SW = 8
|
||||
const G_NL = 38
|
||||
|
||||
const gToRad = (deg: number) => (deg * Math.PI) / 180
|
||||
|
||||
const gSegColor = (i: number): string => {
|
||||
const t = i / (G_SEGMENTS - 1)
|
||||
if (t < 0.4) return '#4caf50'
|
||||
if (t < 0.6) return '#8bc34a'
|
||||
if (t < 0.75) return '#ffc107'
|
||||
if (t < 0.9) return '#ff9800'
|
||||
return '#f44336'
|
||||
}
|
||||
|
||||
function renderGauge(value: number, id: string) {
|
||||
const segSweep = G_SWEEP / G_SEGMENTS
|
||||
const active = Math.round((value / 100) * G_SEGMENTS)
|
||||
const innerR = G_R - G_SW / 2
|
||||
const outerR = G_R + G_SW / 2
|
||||
|
||||
const segments = []
|
||||
for (let i = 0; i < G_SEGMENTS; i++) {
|
||||
const s = G_START + i * segSweep + G_GAP / 2
|
||||
const e = G_START + (i + 1) * segSweep - G_GAP / 2
|
||||
const x1 = G_CX + outerR * Math.cos(gToRad(s))
|
||||
const y1 = G_CY + outerR * Math.sin(gToRad(s))
|
||||
const x2 = G_CX + outerR * Math.cos(gToRad(e))
|
||||
const y2 = G_CY + outerR * Math.sin(gToRad(e))
|
||||
const x3 = G_CX + innerR * Math.cos(gToRad(e))
|
||||
const y3 = G_CY + innerR * Math.sin(gToRad(e))
|
||||
const x4 = G_CX + innerR * Math.cos(gToRad(s))
|
||||
const y4 = G_CY + innerR * Math.sin(gToRad(s))
|
||||
segments.push(
|
||||
<path
|
||||
key={i}
|
||||
data-segment={i}
|
||||
d={`M ${x1} ${y1} A ${outerR} ${outerR} 0 0 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 0 0 ${x4} ${y4} Z`}
|
||||
fill={i < active ? gSegColor(i) : 'var(--colors-border)'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const angle = G_START + (value / 100) * G_SWEEP
|
||||
const nx = G_CX + G_NL * Math.cos(gToRad(angle))
|
||||
const ny = G_CY + G_NL * Math.sin(gToRad(angle))
|
||||
const pa = angle + 90
|
||||
const bw = 3
|
||||
const bx1 = G_CX + bw * Math.cos(gToRad(pa))
|
||||
const by1 = G_CY + bw * Math.sin(gToRad(pa))
|
||||
const bx2 = G_CX - bw * Math.cos(gToRad(pa))
|
||||
const by2 = G_CY - bw * Math.sin(gToRad(pa))
|
||||
|
||||
return (
|
||||
<GaugesCard>
|
||||
<GaugeLabel>{id}</GaugeLabel>
|
||||
<svg id={`gauge-${id}`} viewBox="10 10 100 55" width="140" height="80" style="overflow: visible">
|
||||
{segments}
|
||||
<polygon data-needle points={`${nx},${ny} ${bx1},${by1} ${bx2},${by2}`} fill="var(--colors-text)" />
|
||||
<circle cx={G_CX} cy={G_CY} r="4" fill="var(--colors-textMuted)" />
|
||||
</svg>
|
||||
<GaugeValueText id={`value-${id}`}>{value}%</GaugeValueText>
|
||||
</GaugesCard>
|
||||
)
|
||||
}
|
||||
|
||||
const gaugeScript = `
|
||||
(function() {
|
||||
var S=19,ST=-225,SW=270,CX=60,CY=60,R=44,W=8,NL=38,GAP=3;
|
||||
var iR=R-W/2, oR=R+W/2;
|
||||
function rad(d){return d*Math.PI/180}
|
||||
function sc(i){var t=i/(S-1);return t<.4?'#4caf50':t<.6?'#8bc34a':t<.75?'#ffc107':t<.9?'#ff9800':'#f44336'}
|
||||
function upd(id,v){
|
||||
var svg=document.getElementById('gauge-'+id);if(!svg)return;
|
||||
var a=Math.round((v/100)*S);
|
||||
svg.querySelectorAll('[data-segment]').forEach(function(p,i){p.setAttribute('fill',i<a?sc(i):'var(--colors-border)')});
|
||||
var ang=ST+(v/100)*SW,nx=CX+NL*Math.cos(rad(ang)),ny=CY+NL*Math.sin(rad(ang));
|
||||
var pa=ang+90,bw=3;
|
||||
var bx1=CX+bw*Math.cos(rad(pa)),by1=CY+bw*Math.sin(rad(pa));
|
||||
var bx2=CX-bw*Math.cos(rad(pa)),by2=CY-bw*Math.sin(rad(pa));
|
||||
var n=svg.querySelector('[data-needle]');if(n)n.setAttribute('points',nx+','+ny+' '+bx1+','+by1+' '+bx2+','+by2);
|
||||
var el=document.getElementById('value-'+id);if(el)el.textContent=v+'%';
|
||||
}
|
||||
setInterval(function(){
|
||||
fetch('/api/system').then(function(r){return r.json()}).then(function(m){
|
||||
upd('cpu',m.cpu);upd('ram',m.ram.percent);upd('disk',m.disk.percent);
|
||||
}).catch(function(){});
|
||||
},2000);
|
||||
})();
|
||||
`
|
||||
|
||||
// ============================================================================
|
||||
// App
|
||||
// ============================================================================
|
||||
|
|
@ -659,11 +510,6 @@ app.get('/api/data-history/:name', c => {
|
|||
return c.json(history)
|
||||
})
|
||||
|
||||
app.get('/api/system', async c => {
|
||||
const metrics = await fetchSystemMetrics()
|
||||
return c.json(metrics)
|
||||
})
|
||||
|
||||
app.get('/api/history/:name', c => {
|
||||
const name = c.req.param('name')
|
||||
const history = getHistory(name)
|
||||
|
|
@ -1110,17 +956,67 @@ app.get('/', async c => {
|
|||
)
|
||||
}
|
||||
|
||||
// Dashboard view: system metrics gauges
|
||||
const sys = await fetchSystemMetrics()
|
||||
// All apps view
|
||||
const metrics = await getAppMetrics()
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
if (metrics.length === 0) {
|
||||
return c.html(
|
||||
<Layout title="Metrics">
|
||||
<EmptyState>No apps found</EmptyState>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const running = metrics.filter(s => s.state === 'running')
|
||||
const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
|
||||
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
|
||||
const totalData = metrics.reduce((sum, s) => sum + (s.dataSize ?? 0), 0)
|
||||
|
||||
return c.html(
|
||||
<Layout title="Metrics">
|
||||
<GaugesGrid>
|
||||
{renderGauge(sys.cpu, 'cpu')}
|
||||
{renderGauge(sys.ram.percent, 'ram')}
|
||||
{renderGauge(sys.disk.percent, 'disk')}
|
||||
</GaugesGrid>
|
||||
<script dangerouslySetInnerHTML={{ __html: gaugeScript }} />
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Name</Th>
|
||||
<Th>State</Th>
|
||||
<ThRight>PID</ThRight>
|
||||
<ThRight>CPU</ThRight>
|
||||
<ThRight>MEM</ThRight>
|
||||
<ThRight>RSS</ThRight>
|
||||
<ThRight>Data</ThRight>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.map(s => (
|
||||
<Tr>
|
||||
<Td>
|
||||
{s.name}
|
||||
{s.tool && <ToolBadge>[tool]</ToolBadge>}
|
||||
</Td>
|
||||
<Td>
|
||||
<StatusBadge style={`color: ${getStatusColor(s.state)}`}>
|
||||
{s.state}
|
||||
</StatusBadge>
|
||||
</Td>
|
||||
<TdRight>{s.pid ?? '-'}</TdRight>
|
||||
<TdRight>{formatPercent(s.cpu)}</TdRight>
|
||||
<TdRight>{formatPercent(s.memory)}</TdRight>
|
||||
<TdRight>{formatRss(s.rss)}</TdRight>
|
||||
<TdRight>{formatBytes(s.dataSize)}</TdRight>
|
||||
</Tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Summary>
|
||||
{running.length} running · {formatPercent(totalCpu)} CPU · {formatRss(totalRss)} RSS · {formatBytes(totalData)} data
|
||||
</Summary>
|
||||
</Layout>
|
||||
)
|
||||
})
|
||||
|
|
@ -10,8 +10,7 @@
|
|||
},
|
||||
"toes": {
|
||||
"tool": true,
|
||||
"icon": "📊",
|
||||
"dashboard": true
|
||||
"icon": "📊"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
45
apps/versions/20260130-000000/bun.lock
Normal file
45
apps/versions/20260130-000000/bun.lock
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "versions",
|
||||
"dependencies": {
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"@because/toes": "^0.0.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||
|
||||
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||
|
||||
"@because/toes": ["@because/toes@0.0.5", "https://npm.nose.space/@because/toes/-/toes-0.0.5.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-YM1VuR1sym7m7pFcaiqnjg6eJUyhJYUH2ROBb+xi+HEXajq46ZL8KDyyCtz7WiHTfrbxcEWGjqyj20a7UppcJg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
177
apps/versions/20260130-000000/index.tsx
Normal file
177
apps/versions/20260130-000000/index.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { Hype } from '@because/hype'
|
||||
import { define, stylesToCSS } from '@because/forge'
|
||||
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||
import { readdir, readlink, stat } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { Child } from 'hono/jsx'
|
||||
|
||||
const APPS_DIR = process.env.APPS_DIR!
|
||||
const TOES_URL = process.env.TOES_URL!
|
||||
|
||||
const app = new Hype({ prettyHTML: false })
|
||||
|
||||
const Container = define('Container', {
|
||||
fontFamily: theme('fonts-sans'),
|
||||
padding: '20px',
|
||||
paddingTop: 0,
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
color: theme('colors-text'),
|
||||
})
|
||||
|
||||
const VersionList = define('VersionList', {
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: '20px 0',
|
||||
border: `1px solid ${theme('colors-border')}`,
|
||||
borderRadius: theme('radius-md'),
|
||||
overflow: 'hidden',
|
||||
})
|
||||
|
||||
const VersionItem = define('VersionItem', {
|
||||
padding: '12px 15px',
|
||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
states: {
|
||||
':last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
':hover': {
|
||||
backgroundColor: theme('colors-bgHover'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const VersionLink = define('VersionLink', {
|
||||
base: 'a',
|
||||
textDecoration: 'none',
|
||||
color: theme('colors-link'),
|
||||
fontFamily: theme('fonts-mono'),
|
||||
fontSize: '15px',
|
||||
cursor: 'pointer',
|
||||
states: {
|
||||
':hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Badge = define('Badge', {
|
||||
fontSize: '12px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: theme('radius-md'),
|
||||
backgroundColor: theme('colors-bgElement'),
|
||||
color: theme('colors-statusRunning'),
|
||||
fontWeight: 'bold',
|
||||
})
|
||||
|
||||
const ErrorBox = define('ErrorBox', {
|
||||
color: theme('colors-error'),
|
||||
padding: '20px',
|
||||
backgroundColor: theme('colors-bgElement'),
|
||||
borderRadius: theme('radius-md'),
|
||||
margin: '20px 0',
|
||||
})
|
||||
|
||||
interface LayoutProps {
|
||||
title: string
|
||||
children: Child
|
||||
}
|
||||
|
||||
function Layout({ title, children }: LayoutProps) {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<ToolScript />
|
||||
<Container>
|
||||
{children}
|
||||
</Container>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
app.get('/ok', c => c.text('ok'))
|
||||
|
||||
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||
'Content-Type': 'text/css; charset=utf-8',
|
||||
}))
|
||||
|
||||
async function getVersions(appPath: string): Promise<{ name: string; isCurrent: boolean }[]> {
|
||||
const entries = await readdir(appPath, { withFileTypes: true })
|
||||
|
||||
let currentTarget = ''
|
||||
try {
|
||||
currentTarget = await readlink(join(appPath, 'current'))
|
||||
} catch { }
|
||||
|
||||
return entries
|
||||
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
|
||||
.map(e => ({ name: e.name, isCurrent: e.name === currentTarget }))
|
||||
.sort((a, b) => b.name.localeCompare(a.name))
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}:${ts.slice(13, 15)}`
|
||||
}
|
||||
|
||||
app.get('/', async c => {
|
||||
const appName = c.req.query('app')
|
||||
|
||||
if (!appName) {
|
||||
return c.html(
|
||||
<Layout title="Versions">
|
||||
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const appPath = join(APPS_DIR, appName)
|
||||
|
||||
try {
|
||||
await stat(appPath)
|
||||
} catch {
|
||||
return c.html(
|
||||
<Layout title="Versions">
|
||||
<ErrorBox>App "{appName}" not found</ErrorBox>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const versions = await getVersions(appPath)
|
||||
|
||||
if (versions.length === 0) {
|
||||
return c.html(
|
||||
<Layout title="Versions">
|
||||
<ErrorBox>No versions found</ErrorBox>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return c.html(
|
||||
<Layout title="Versions">
|
||||
<VersionList>
|
||||
{versions.map(v => (
|
||||
<VersionItem>
|
||||
<VersionLink
|
||||
href={`${TOES_URL}/tool/code?app=${appName}&version=${v.name}`}
|
||||
>
|
||||
{formatTimestamp(v.name)}
|
||||
</VersionLink>
|
||||
{v.isCurrent && <Badge>current</Badge>}
|
||||
</VersionItem>
|
||||
))}
|
||||
</VersionList>
|
||||
</Layout>
|
||||
)
|
||||
})
|
||||
|
||||
export default app.defaults
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "git",
|
||||
"name": "versions",
|
||||
"module": "index.tsx",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
|
@ -10,9 +10,7 @@
|
|||
},
|
||||
"toes": {
|
||||
"tool": true,
|
||||
"dashboard": true,
|
||||
"share": true,
|
||||
"icon": "🔀"
|
||||
"icon": "📦"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
|
|
@ -23,6 +21,6 @@
|
|||
"dependencies": {
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"@because/toes": "0.0.12"
|
||||
"@because/toes": "^0.0.5"
|
||||
}
|
||||
}
|
||||
40
bun.lock
40
bun.lock
|
|
@ -5,48 +5,48 @@
|
|||
"": {
|
||||
"name": "@because/toes",
|
||||
"dependencies": {
|
||||
"@because/forge": "^0.0.7",
|
||||
"@because/hype": "^0.0.9",
|
||||
"@because/sneaker": "^0.0.5",
|
||||
"@because/toes": "^0.0.15",
|
||||
"ansis": "^4.2.0",
|
||||
"commander": "14.0.3",
|
||||
"diff": "^8.0.4",
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"@because/sneaker": "^0.0.3",
|
||||
"commander": "^14.0.3",
|
||||
"diff": "^8.0.3",
|
||||
"kleur": "^4.1.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/diff": "^8.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@because/forge": ["@because/forge@0.0.7", "https://npm.nose.space/@because/forge/-/forge-0.0.7.tgz", {}, "sha512-vrpo9/l3YbpJikr4eGNBbXDoXa1q0TtretyXwlJys/5qWEuHJJ3F8sFQ8SEeqWq3j0k9LQfS1278YE6a9mpv6g=="],
|
||||
"@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.9", "https://npm.nose.space/@because/hype/-/hype-0.0.9.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" } }, "sha512-pCaGAP0d4JDkeuVDR+8x0AemaC/EjFNNBNHhC8eKcw7soQVoXG0kYJW4bIBlEBrY8pd2TBXmMEzRIcW4QnwXNw=="],
|
||||
"@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.5", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.5.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" } }, "sha512-GAvsh/i6N+KRV5mK/YosOMlZ3Lnm/y8kppQiKTZ5HlBDBnJfuxGJSp7aLyHZlHtSQi1jcGJWsx6NJ4RqCD7hNA=="],
|
||||
"@because/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=="],
|
||||
|
||||
"@because/toes": ["@because/toes@0.0.15", "https://npm.nose.space/@because/toes/-/toes-0.0.15.tgz", { "dependencies": { "@because/forge": "^0.0.7", "@because/hype": "^0.0.9", "@because/sneaker": "^0.0.5", "@because/toes": "^0.0.15", "ansis": "^4.2.0", "commander": "14.0.3", "diff": "^8.0.4" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-vHCMIx3w7AK1buWIKXTTWb2oxKCrOdXj0/p4+r+prjjfp1Q6RPmkfPFy1GVRZ4P1kVr8LO7PpDITCHNMr8cAlQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
"@types/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/node": ["@types/node@25.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
"@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=="],
|
||||
|
||||
"ansis": ["ansis@4.2.0", "https://npm.nose.space/ansis/-/ansis-4.2.0.tgz", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"diff": ["diff@8.0.4", "https://npm.nose.space/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
"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=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
"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=="],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ An app is an HTTP server that runs on its assigned port.
|
|||
|
||||
```
|
||||
apps/<name>/
|
||||
package.json
|
||||
index.tsx
|
||||
<timestamp>/ # YYYYMMDD-HHMMSS
|
||||
package.json
|
||||
index.tsx
|
||||
current -> <timestamp> # symlink to active version
|
||||
```
|
||||
|
||||
**package.json** must have `scripts.toes`:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ The cron tool discovers jobs from all apps and runs them automatically.
|
|||
Add a file to `cron/` in any app:
|
||||
|
||||
```ts
|
||||
// apps/my-app/cron/daily-cleanup.ts
|
||||
// apps/my-app/current/cron/daily-cleanup.ts
|
||||
export const schedule = "day"
|
||||
|
||||
export default async function() {
|
||||
|
|
@ -73,7 +73,7 @@ Jobs track:
|
|||
## discovery
|
||||
|
||||
The cron tool:
|
||||
1. Scans `APPS_DIR/*/cron/*.ts`
|
||||
1. Scans `APPS_DIR/*/current/cron/*.ts`
|
||||
2. Imports each file to read `schedule`
|
||||
3. Validates the schedule
|
||||
4. Registers with croner
|
||||
|
|
|
|||
211
docs/GUIDE.md
211
docs/GUIDE.md
|
|
@ -19,14 +19,15 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
|
|||
- [CLI Reference](#cli-reference)
|
||||
- [App Management](#app-management)
|
||||
- [Lifecycle](#lifecycle)
|
||||
- [Deploying Code](#deploying-code)
|
||||
- [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)
|
||||
- [Running over HTTP](#running-over-http)
|
||||
- [App Lifecycle](#app-lifecycle)
|
||||
- [Cron Jobs](#cron-jobs)
|
||||
- [Data Persistence](#data-persistence)
|
||||
|
|
@ -39,7 +40,7 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
|
|||
# Install the CLI
|
||||
curl -fsSL http://toes.local/install | bash
|
||||
|
||||
# Create a new app (scaffolds, inits git, and pushes to server)
|
||||
# Create a new app
|
||||
toes new my-app
|
||||
|
||||
# Enter the directory, install deps, and develop locally
|
||||
|
|
@ -47,9 +48,8 @@ cd my-app
|
|||
bun install
|
||||
bun dev
|
||||
|
||||
# Deploy changes (standard git)
|
||||
git add . && git commit -m "my changes"
|
||||
git push toes main
|
||||
# Push to the server
|
||||
toes push
|
||||
|
||||
# Open in browser
|
||||
toes open
|
||||
|
|
@ -57,7 +57,7 @@ toes open
|
|||
|
||||
Your app is now running at `http://my-app.toes.local`.
|
||||
|
||||
`toes new` automatically sets up a `toes` git remote pointing at the server. Pushing to it triggers a deploy.
|
||||
> **Tip:** Add `.toes` to your `.gitignore`. This file tracks local sync state and shouldn't be committed.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -85,8 +85,8 @@ A generated SSR app looks like this:
|
|||
|
||||
```
|
||||
my-app/
|
||||
.gitignore # Files to exclude from sync and deploy
|
||||
.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)
|
||||
|
|
@ -457,11 +457,13 @@ 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)
|
||||
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
|
||||
|
|
@ -520,21 +522,19 @@ toes new my-app --bare # Minimal template
|
|||
toes new my-app --spa # SPA template
|
||||
```
|
||||
|
||||
Scaffolds the app locally, initializes a git repo with a `toes` remote pointing at the server, and pushes. The git push triggers a deploy. If run without a name, scaffolds the current directory.
|
||||
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>`** — Clone an app from the server to your local machine.
|
||||
**`toes get <name>`** — Download an app from the server to your local machine.
|
||||
|
||||
```bash
|
||||
toes get my-app # Clones into ./my-app/
|
||||
toes get my-app # Creates ./my-app/ with all files
|
||||
cd my-app
|
||||
bun install
|
||||
bun dev # Develop locally
|
||||
```
|
||||
|
||||
The clone comes with a `toes` remote already configured, so `git push toes main` deploys.
|
||||
|
||||
**`toes open [name]`** — Open a running app in your browser.
|
||||
|
||||
**`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation.
|
||||
|
|
@ -562,38 +562,54 @@ toes logs my-app -f -g error # Follow and filter
|
|||
|
||||
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
|
||||
|
||||
### Deploying Code
|
||||
### Syncing Code
|
||||
|
||||
Toes uses git for deployments. Each app has a `toes` remote that points to the server's git tool. Pushing to it extracts the latest commit and deploys it.
|
||||
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
|
||||
# Make changes, commit, and deploy
|
||||
git add .
|
||||
git commit -m "update homepage"
|
||||
git push toes main
|
||||
toes push # Push changes (fails if server changed)
|
||||
toes push --force # Overwrite server changes
|
||||
```
|
||||
|
||||
The git push triggers the server to:
|
||||
1. Store the commit in a bare repo at `DATA_DIR/repos/<name>.git`
|
||||
2. Extract HEAD into the app directory
|
||||
3. Run `bun install` and restart the app
|
||||
Creates a new version on the server, uploads changed files, deletes removed files, then activates the new version. The app auto-restarts.
|
||||
|
||||
Use standard git commands for history, diffing, and rollback:
|
||||
**`toes pull`** — Pull changes from the server.
|
||||
|
||||
```bash
|
||||
git log # View deploy history
|
||||
git diff HEAD~1 # See what changed
|
||||
git revert HEAD # Undo last deploy
|
||||
git push toes main # Deploy the revert
|
||||
toes pull # Pull changes (fails if you have local changes)
|
||||
toes pull --force # Overwrite local changes
|
||||
```
|
||||
|
||||
To clone an existing app from the server:
|
||||
**`toes status`** — Show what would be pushed or pulled.
|
||||
|
||||
```bash
|
||||
git clone http://git.toes.local/my-app
|
||||
cd my-app
|
||||
bun install
|
||||
bun dev # Develop locally
|
||||
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
|
||||
|
|
@ -624,12 +640,26 @@ toes env rm -g API_KEY # Remove global var
|
|||
|
||||
### Versioning
|
||||
|
||||
Every `git push toes main` creates a new deploy. Version history is managed through git.
|
||||
Every push creates a timestamped version. The server keeps the last 5 versions.
|
||||
|
||||
**`toes versions [name]`** — List deployed versions.
|
||||
|
||||
```bash
|
||||
git log --oneline # List deploys
|
||||
git revert HEAD # Undo last change
|
||||
git push toes main # Deploy the revert
|
||||
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
|
||||
|
|
@ -682,24 +712,14 @@ toes metrics my-app # Single app
|
|||
|
||||
```bash
|
||||
toes share my-app
|
||||
# ↗ Sharing my-app... https://myapp.toes.space
|
||||
# ↗ Sharing my-app... https://abc123.trycloudflare.com
|
||||
```
|
||||
|
||||
**`toes unshare [name]`** — Stop sharing an app.
|
||||
|
||||
Every request to your app includes an `x-app-url` header with the app's public-facing URL. When shared, this is the tunnel URL (e.g., `https://myapp.toes.space`). When not shared, it's the local URL (e.g., `http://myapp.toes.local`). This works whether the request arrives through the local proxy or through a tunnel.
|
||||
### Configuration
|
||||
|
||||
Use `appUrl()` from `@because/toes/tools` to read it — never hardcode your app's URL:
|
||||
|
||||
```tsx
|
||||
import { appUrl } from '@because/toes/tools'
|
||||
|
||||
app.get('/callback', c => {
|
||||
const url = appUrl(c.req.raw)
|
||||
// "https://myapp.toes.space" when shared, "http://myapp.toes.local" otherwise
|
||||
return c.redirect(`${url}/done`)
|
||||
})
|
||||
```
|
||||
**`toes config`** — Show the current server URL and sync state.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -712,9 +732,8 @@ Toes injects these variables into every app process automatically:
|
|||
| `PORT` | Assigned port (3001-3100). Your app must listen on this port. |
|
||||
| `APPS_DIR` | Path to the apps directory on the server. |
|
||||
| `DATA_DIR` | Per-app data directory for persistent storage. |
|
||||
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local`). |
|
||||
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local:3000`). |
|
||||
| `TOES_DIR` | Path to the Toes config directory. |
|
||||
| `APP_URL` | The app's local URL (e.g., `http://myapp.toes.local`). For the public URL that accounts for sharing, use `appUrl(req)` from `@because/toes` (see [Sharing](#sharing)). |
|
||||
|
||||
You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals.
|
||||
|
||||
|
|
@ -748,92 +767,6 @@ app.get('/ok', c => c.text('ok'))
|
|||
|
||||
---
|
||||
|
||||
## Running over HTTP
|
||||
|
||||
Toes serves apps over plain HTTP (`http://<app>.toes.local`), not HTTPS. This is fine for a home network appliance, but a few browser features assume HTTPS and will silently break if you're not aware of them.
|
||||
|
||||
> **Note:** `localhost` gets a special pass — browsers treat it as a secure context even over HTTP. But `.local` domains don't get that exemption, so these gotchas apply when accessing your apps at `<app>.toes.local` from another device.
|
||||
|
||||
### Cookies
|
||||
|
||||
If you set cookies with the `Secure` flag, browsers will silently ignore them — the cookie just won't be stored.
|
||||
|
||||
Don't do this:
|
||||
|
||||
```tsx
|
||||
c.header('Set-Cookie', 'session=abc123; HttpOnly; Secure; SameSite=Lax')
|
||||
```
|
||||
|
||||
Do this instead:
|
||||
|
||||
```tsx
|
||||
c.header('Set-Cookie', 'session=abc123; HttpOnly; SameSite=Lax')
|
||||
```
|
||||
|
||||
If you're using a cookie library, make sure `secure` is set to `false` (or omitted):
|
||||
|
||||
```tsx
|
||||
import { setCookie } from 'hono/cookie'
|
||||
|
||||
setCookie(c, 'session', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure: false, // toes apps run over HTTP
|
||||
})
|
||||
```
|
||||
|
||||
### Clipboard API
|
||||
|
||||
`navigator.clipboard.writeText()` and `navigator.clipboard.readText()` require a secure context. They'll throw on `.local` domains.
|
||||
|
||||
Use the legacy fallback instead:
|
||||
|
||||
```tsx
|
||||
function copyToClipboard(text: string) {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
```
|
||||
|
||||
### Service Workers
|
||||
|
||||
Service workers only register on HTTPS origins (plus `localhost`). If you're building a PWA or want offline caching, it won't work on `.local`. This is a hard browser restriction with no workaround.
|
||||
|
||||
### Web Push Notifications
|
||||
|
||||
The Push API and `Notification.requestPermission()` require a secure context. For notifications on the local network, consider polling or SSE instead:
|
||||
|
||||
```tsx
|
||||
app.sse('/notifications', (send, c) => {
|
||||
// push updates over SSE instead of Web Push
|
||||
send({ title: 'New item', body: 'Something happened' })
|
||||
return () => {}
|
||||
})
|
||||
```
|
||||
|
||||
### Geolocation & Camera/Mic
|
||||
|
||||
`navigator.geolocation` and `navigator.mediaDevices.getUserMedia()` require a secure context. These won't work on `.local` domains.
|
||||
|
||||
### Web Crypto
|
||||
|
||||
`crypto.subtle` (for hashing, encryption, key generation) requires a secure context. Use a library like `tweetnacl` if you need crypto in the browser, or do it server-side:
|
||||
|
||||
```tsx
|
||||
// Server-side — works fine, no secure context needed
|
||||
const hash = new Bun.CryptoHasher('sha256').update(data).digest('hex')
|
||||
```
|
||||
|
||||
### What about `toes share`?
|
||||
|
||||
`toes share` tunnels your app through HTTPS, so all of the above works when accessed through the tunnel URL. But since your app should also work locally, don't rely on secure-context APIs unless you're okay with them only working when shared.
|
||||
|
||||
---
|
||||
|
||||
## App Lifecycle
|
||||
|
||||
Apps move through these states:
|
||||
|
|
|
|||
|
|
@ -44,17 +44,21 @@ app.get('/', c => {
|
|||
|
||||
## accessing app files
|
||||
|
||||
Always go through the `current` symlink:
|
||||
|
||||
```ts
|
||||
const APPS_DIR = process.env.APPS_DIR!
|
||||
const appPath = join(APPS_DIR, appName)
|
||||
const appPath = join(APPS_DIR, appName, 'current')
|
||||
```
|
||||
|
||||
Not `APPS_DIR/appName` directly.
|
||||
|
||||
## linking to tools
|
||||
|
||||
Use `/tool/:name` URLs to link directly to tools with params:
|
||||
|
||||
```html
|
||||
<a href="/tool/code?app=my-app">
|
||||
<a href="/tool/code?app=my-app&version=20260130-000000">
|
||||
View in Code
|
||||
</a>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
# Rev Webhooks for Toes
|
||||
|
||||
Deploy Toes apps by saving to rev.host — no manual deploy step, no rsync scripts.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
rev save "fix combat" → rev.host → relay (sneaker.toes.space) → toes.local pulls + deploys
|
||||
```
|
||||
|
||||
toes.local can't receive inbound connections (home NAT), so it maintains an outbound connection to a relay — same tunnel infrastructure used by `toes share`.
|
||||
|
||||
## Setup Flow
|
||||
|
||||
1. In the Toes dashboard (or `toes` CLI via SSH), enable rev webhooks for an app
|
||||
2. Toes connects to the relay and gets a stable webhook URL
|
||||
3. Add that URL in rev.host project settings as a webhook endpoint
|
||||
4. Done — `rev save` and `rev merge` now trigger deploys
|
||||
|
||||
## What Toes Does on Webhook
|
||||
|
||||
1. Receives event from relay (repo, ref, timestamp)
|
||||
2. Pulls latest from rev.host (needs a rev auth token stored in Toes env)
|
||||
3. Runs `scripts.predeploy` if defined in package.json (type-check, build, etc.)
|
||||
4. Runs `bun install`
|
||||
5. Restarts the app
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# Enable/disable rev webhooks
|
||||
toes webhook enable [name] # Shows the relay URL to paste into rev.host
|
||||
toes webhook disable [name]
|
||||
|
||||
# Manual trigger (pull latest and deploy now)
|
||||
toes deploy [name]
|
||||
|
||||
# Check webhook status
|
||||
toes webhook status [name]
|
||||
```
|
||||
|
||||
## Settings UI
|
||||
|
||||
App settings page gets a "Rev Webhooks" section:
|
||||
- Toggle to enable/disable
|
||||
- Displays the relay URL (copy button)
|
||||
- Field for rev.host auth token
|
||||
- Last deploy timestamp + status
|
||||
- "Deploy Now" button (manual trigger)
|
||||
|
||||
## Auth
|
||||
|
||||
Toes needs read access to pull from rev.host. Store a rev API token per-app (or globally):
|
||||
|
||||
```bash
|
||||
toes env set -g REV_TOKEN rt_abc123
|
||||
# or per-app
|
||||
toes env set my-app REV_TOKEN rt_abc123
|
||||
```
|
||||
|
||||
## Predeploy Scripts
|
||||
|
||||
Project-specific build steps go in package.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"toes": "bun run --watch index.tsx",
|
||||
"predeploy": "bunx tsc --noEmit && bun build client/main.tsx --outdir dist --minify"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Toes runs `predeploy` after pulling but before restarting. If it exits non-zero, the deploy is aborted and the previous version stays running.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should the relay URL be per-app or per-Toes-instance? (Per-instance with app routing via path seems simpler: `https://sneaker.toes.space/hooks/<instance-id>/<app-name>`)
|
||||
- Webhook secret/signature verification — rev.host should sign payloads so the relay can't be spoofed
|
||||
- Should `toes deploy` work without webhooks enabled? (Just pull from rev.host on demand — useful as a migration path from deploy.sh)
|
||||
- Rollback: `toes rollback [name]` to revert to previous rev version?
|
||||
|
|
@ -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,135 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
##
|
||||
# toes installer
|
||||
# Usage: curl -fsSL https://toes.dev/install | sh
|
||||
#
|
||||
# Installs or updates toes on a Raspberry Pi.
|
||||
# Must be run as the 'toes' user with passwordless sudo.
|
||||
|
||||
RELEASE_URL="https://toes.dev/release/latest.tar.gz"
|
||||
DEST=~/toes
|
||||
APPS_DIR=~/apps
|
||||
DATA_DIR=~/data
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
|
||||
|
||||
quiet() { "$@" > /dev/null 2>&1; }
|
||||
|
||||
info() { echo " ${d}>>${r} $1"; }
|
||||
|
||||
fail() { echo " ${y}ERROR:${r} $1" >&2; exit 1; }
|
||||
|
||||
# ── Preflight ────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo " ${d}╔══════════════════════════════════╗${r}"
|
||||
echo " ${d}║${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
|
||||
echo " ${d}╚══════════════════════════════════╝${r}"
|
||||
echo ""
|
||||
|
||||
[ "$(whoami)" = "toes" ] || fail "Must be run as the 'toes' user."
|
||||
sudo -n true 2>/dev/null || fail "Requires passwordless sudo."
|
||||
|
||||
# ── System packages ──────────────────────────────────────
|
||||
|
||||
info "Updating system packages"
|
||||
quiet sudo apt-get update
|
||||
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
|
||||
|
||||
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
|
||||
info "Setting fish as default shell"
|
||||
quiet sudo chsh -s /usr/bin/fish toes
|
||||
fi
|
||||
|
||||
# ── Bun ──────────────────────────────────────────────────
|
||||
|
||||
BUN="$HOME/.bun/bin/bun"
|
||||
|
||||
if [ ! -x "$BUN" ]; then
|
||||
info "Installing bun"
|
||||
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
|
||||
[ -x "$BUN" ] || fail "bun installation failed."
|
||||
fi
|
||||
|
||||
sudo ln -sf "$BUN" /usr/local/bin/bun
|
||||
|
||||
sudo setcap 'cap_net_bind_service=+ep' "$BUN"
|
||||
|
||||
# ── Download ─────────────────────────────────────────────
|
||||
|
||||
info "Downloading toes"
|
||||
mkdir -p "$DEST"
|
||||
curl -fsSL "$RELEASE_URL" | tar xz --strip-components=1 -C "$DEST"
|
||||
|
||||
# ── Directories ──────────────────────────────────────────
|
||||
|
||||
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
|
||||
|
||||
# ── Dependencies ─────────────────────────────────────────
|
||||
|
||||
cd "$DEST"
|
||||
|
||||
info "Installing dependencies"
|
||||
quiet bun install
|
||||
|
||||
# ── Bundled apps ─────────────────────────────────────────
|
||||
|
||||
REPOS_DIR="$DATA_DIR/repos"
|
||||
mkdir -p "$REPOS_DIR"
|
||||
|
||||
info "Installing bundled apps"
|
||||
pids=()
|
||||
for app_dir in "$DEST"/apps/*/; do
|
||||
app=$(basename "$app_dir")
|
||||
[ -f "$app_dir/package.json" ] || continue
|
||||
echo " $app"
|
||||
(
|
||||
cp -a "$app_dir" "$APPS_DIR/$app"
|
||||
quiet bun install --frozen-lockfile --cwd "$APPS_DIR/$app" || quiet bun install --cwd "$APPS_DIR/$app"
|
||||
) &
|
||||
pids+=("$!")
|
||||
done
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || fail "A bundled app failed to install."
|
||||
done
|
||||
|
||||
# Copy pre-built bare repos for git-based versioning
|
||||
cp -a "$DEST"/dist/repos/*.git "$REPOS_DIR/"
|
||||
|
||||
# ── CLI + SSH ────────────────────────────────────────────
|
||||
|
||||
info "Setting up SSH access"
|
||||
sudo bash "$DEST/scripts/setup-ssh.sh"
|
||||
|
||||
info "Installing CLI"
|
||||
sudo install -m 755 "$DEST/dist/toes" /usr/local/bin/toes
|
||||
|
||||
# ── Systemd ──────────────────────────────────────────────
|
||||
|
||||
info "Installing toes service"
|
||||
sudo install -m 644 "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable toes
|
||||
|
||||
info "Restarting toes"
|
||||
sudo systemctl restart toes
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────
|
||||
|
||||
VERSION=$(grep '"version"' "$DEST/package.json" | head -1 | sed 's/.*"version": *"\(.*\)".*/\1/')
|
||||
|
||||
echo ""
|
||||
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
|
||||
echo " ${d}─────────────────────────────${r}"
|
||||
echo ""
|
||||
echo " Dashboard: ${c}http://$(hostname).local${r}"
|
||||
echo " SSH CLI: ${c}ssh cli@$(hostname).local${r}"
|
||||
echo ""
|
||||
echo " ${d}Grab the CLI:${r}"
|
||||
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"
|
||||
echo ""
|
||||
|
|
@ -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,25 +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" },
|
||||
})
|
||||
}
|
||||
|
||||
if (new URL(req.url).pathname === "/shout") {
|
||||
return Response.redirect(
|
||||
"https://git.nose.space/defunkt/go-shout/raw/branch/main/install.sh",
|
||||
302
|
||||
)
|
||||
}
|
||||
|
||||
return new Response("404 Not Found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
{
|
||||
"exclude": ["apps", "templates"],
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"allowJs": true,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"$*": [
|
||||
"./src/server/*"
|
||||
],
|
||||
"@*": [
|
||||
"./src/shared/*"
|
||||
],
|
||||
"%*": [
|
||||
"./src/lib/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
30
package.json
30
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@because/toes",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.8",
|
||||
"description": "personal web appliance - turn it on and forget about the cloud",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
|
|
@ -15,38 +15,38 @@
|
|||
"toes": "src/cli/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"check": "bun run templates && bunx tsc --noEmit",
|
||||
"check": "bunx tsc --noEmit",
|
||||
"build": "./scripts/build.sh",
|
||||
"release": "./scripts/release.sh",
|
||||
"cli:build": "bun run scripts/build.ts",
|
||||
"cli:build:all": "bun run scripts/build.ts --all",
|
||||
"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",
|
||||
"deploy": "./scripts/deploy.sh",
|
||||
"debug": "DEBUG=1 bun run dev",
|
||||
"dev": "bun run templates && rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
|
||||
"remote:migrate": "bun run scripts/migrate.ts",
|
||||
"remote:deploy": "./scripts/remote-deploy.sh",
|
||||
"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:logs": "./scripts/remote-logs.sh",
|
||||
"remote:restart": "./scripts/remote-restart.sh",
|
||||
"remote:start": "./scripts/remote-start.sh",
|
||||
"remote:stop": "./scripts/remote-stop.sh",
|
||||
"start": "bun run templates && bun run src/server/index.tsx",
|
||||
"templates": "bun run scripts/embed-templates.ts",
|
||||
"start": "bun run src/server/index.tsx",
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/diff": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@because/forge": "^0.0.7",
|
||||
"@because/hype": "^0.0.9",
|
||||
"@because/sneaker": "^0.0.5",
|
||||
"@because/toes": "^0.0.15",
|
||||
"ansis": "^4.2.0",
|
||||
"commander": "14.0.3",
|
||||
"diff": "^8.0.4"
|
||||
"@because/forge": "^0.0.1",
|
||||
"@because/hype": "^0.0.2",
|
||||
"@because/sneaker": "^0.0.3",
|
||||
"commander": "^14.0.3",
|
||||
"diff": "^8.0.3",
|
||||
"kleur": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Pre-builds bare git repos for bundled apps so the install script
|
||||
# doesn't need to run any git commands.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$SCRIPT_DIR/.."
|
||||
APPS_DIR="$ROOT/apps"
|
||||
OUT_DIR="$ROOT/dist/repos"
|
||||
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
for app_dir in "$APPS_DIR"/*/; do
|
||||
app=$(basename "$app_dir")
|
||||
[ -f "$app_dir/package.json" ] || continue
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
tar -C "$app_dir" \
|
||||
--exclude='node_modules' \
|
||||
--exclude='logs' \
|
||||
--exclude='current' \
|
||||
--exclude='[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' \
|
||||
-cf - . | tar -C "$tmp" -xf -
|
||||
|
||||
git -C "$tmp" init -b main -q
|
||||
git -C "$tmp" add -A
|
||||
git -C "$tmp" -c user.name=toes -c user.email=toes@localhost commit -q -m "install"
|
||||
git clone --bare -q "$tmp" "$OUT_DIR/$app.git"
|
||||
git -C "$OUT_DIR/$app.git" config http.receivepack true
|
||||
|
||||
rm -rf "$tmp"
|
||||
echo " $app"
|
||||
done
|
||||
|
||||
echo ">> Bare repos built in dist/repos/"
|
||||
|
|
@ -16,4 +16,3 @@ bun build src/client/index.tsx \
|
|||
|
||||
echo ">> Client bundle created at pub/client/index.js"
|
||||
ls -lh pub/client/index.js
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { join } from 'path'
|
|||
|
||||
const DIST_DIR = join(import.meta.dir, '..', 'dist')
|
||||
const ENTRY_POINT = join(import.meta.dir, '..', 'src', 'cli', 'index.ts')
|
||||
const GIT_SHA = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD']).stdout.toString().trim() || 'unknown'
|
||||
|
||||
interface BuildTarget {
|
||||
arch: string
|
||||
|
|
@ -24,6 +23,47 @@ const TARGETS: BuildTarget[] = [
|
|||
{ os: 'linux', arch: 'x64', name: 'toes-linux-x64' },
|
||||
]
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (!existsSync(DIST_DIR)) {
|
||||
mkdirSync(DIST_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
// Parse command line args
|
||||
const args = process.argv.slice(2)
|
||||
const buildAll = args.includes('--all')
|
||||
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
|
||||
|
||||
async function buildTarget(target: BuildTarget) {
|
||||
console.log(`Building ${target.name}...`)
|
||||
|
||||
const output = join(DIST_DIR, target.name)
|
||||
|
||||
const proc = Bun.spawn([
|
||||
'bun',
|
||||
'build',
|
||||
ENTRY_POINT,
|
||||
'--compile',
|
||||
'--target',
|
||||
`bun-${target.os}-${target.arch}`,
|
||||
'--minify',
|
||||
'--sourcemap=external',
|
||||
'--outfile',
|
||||
output,
|
||||
], {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode === 0) {
|
||||
console.log(`✓ Built ${target.name}`)
|
||||
} else {
|
||||
console.error(`✗ Failed to build ${target.name}`)
|
||||
process.exit(exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildCurrent() {
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
|
|
@ -41,7 +81,6 @@ async function buildCurrent() {
|
|||
'bun',
|
||||
'--minify',
|
||||
'--sourcemap=external',
|
||||
`--define=__GIT_SHA__="${GIT_SHA}"`,
|
||||
'--outfile',
|
||||
output,
|
||||
], {
|
||||
|
|
@ -61,58 +100,6 @@ async function buildCurrent() {
|
|||
}
|
||||
}
|
||||
|
||||
async function buildTarget(target: BuildTarget) {
|
||||
console.log(`Building ${target.name}...`)
|
||||
|
||||
const output = join(DIST_DIR, target.name)
|
||||
|
||||
const proc = Bun.spawn([
|
||||
'bun',
|
||||
'build',
|
||||
ENTRY_POINT,
|
||||
'--compile',
|
||||
'--target',
|
||||
`bun-${target.os}-${target.arch}`,
|
||||
'--minify',
|
||||
'--sourcemap=external',
|
||||
`--define=__GIT_SHA__="${GIT_SHA}"`,
|
||||
'--outfile',
|
||||
output,
|
||||
], {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode === 0) {
|
||||
console.log(`✓ Built ${target.name}`)
|
||||
} else {
|
||||
console.error(`✗ Failed to build ${target.name}`)
|
||||
process.exit(exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Embed template files before compiling
|
||||
const embedProc = Bun.spawn(['bun', 'run', join(import.meta.dir, 'embed-templates.ts')], {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
if (await embedProc.exited !== 0) {
|
||||
console.error('✗ Failed to embed templates')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (!existsSync(DIST_DIR)) {
|
||||
mkdirSync(DIST_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
// Parse command line args
|
||||
const args = process.argv.slice(2)
|
||||
const buildAll = args.includes('--all')
|
||||
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
|
||||
|
||||
// Main build logic
|
||||
if (buildAll) {
|
||||
console.log('Building for all targets...\n')
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@
|
|||
|
||||
# It isn't enough to modify this yet.
|
||||
# You also need to manually update the toes.service file.
|
||||
TOES_USER="${TOES_USER:-toes}"
|
||||
HOST="${HOST:-toes.local}"
|
||||
SSH_HOST="$TOES_USER@$HOST"
|
||||
URL="${URL:-http://$HOST}"
|
||||
DEST="${DEST:-$HOME/toes}"
|
||||
DATA_DIR="${DATA_DIR:-$HOME/data}"
|
||||
APPS_DIR="${APPS_DIR:-$HOME/apps}"
|
||||
HOST="${HOST:-toes@toes.local}"
|
||||
URL="${URL:-http://toes.local}"
|
||||
DEST="${DEST:-~/toes}"
|
||||
DATA_DIR="${DATA_DIR:-~/data}"
|
||||
APPS_DIR="${APPS_DIR:-~/apps}"
|
||||
|
|
|
|||
39
scripts/deploy.sh
Executable file
39
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# Get absolute path of this script's directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$SCRIPT_DIR/.."
|
||||
|
||||
# Load config
|
||||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
git push origin main
|
||||
|
||||
# SSH to target: pull, build, sync apps, restart
|
||||
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
|
||||
set -e
|
||||
|
||||
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
|
||||
|
||||
echo "=> Syncing default apps..."
|
||||
for app_dir in "$DEST"/apps/*/; do
|
||||
app=$(basename "$app_dir")
|
||||
for version_dir in "$app_dir"*/; do
|
||||
[ -d "$version_dir" ] || continue
|
||||
version=$(basename "$version_dir")
|
||||
[ -f "$version_dir/package.json" ] || continue
|
||||
target="$APPS_DIR/$app/$version"
|
||||
mkdir -p "$target"
|
||||
cp -a "$version_dir"/. "$target"/
|
||||
rm -f "$APPS_DIR/$app/current"
|
||||
echo " $app/$version"
|
||||
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
|
||||
done
|
||||
done
|
||||
|
||||
sudo systemctl restart toes.service
|
||||
SCRIPT
|
||||
|
||||
echo "=> Deployed to $HOST"
|
||||
echo "=> Visit $URL"
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
// Generates src/lib/templates.data.ts with embedded template file contents.
|
||||
// Run: bun run templates
|
||||
import { readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { extname, join, relative } from 'path'
|
||||
|
||||
const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.svg'])
|
||||
const TEMPLATES_DIR = join(import.meta.dir, '..', 'templates')
|
||||
|
||||
const binary: Record<string, Record<string, string>> = {}
|
||||
const shared: Record<string, string> = {}
|
||||
const templates: Record<string, Record<string, string>> = {}
|
||||
|
||||
const isBinary = (path: string) =>
|
||||
BINARY_EXTENSIONS.has(extname(path))
|
||||
|
||||
function readDir(dir: string): string[] {
|
||||
const files: string[] = []
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry)
|
||||
if (statSync(path).isDirectory()) {
|
||||
files.push(...readDir(path))
|
||||
} else {
|
||||
files.push(path)
|
||||
}
|
||||
}
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
// First pass: collect shared files (root level)
|
||||
for (const entry of readdirSync(TEMPLATES_DIR).sort()) {
|
||||
const path = join(TEMPLATES_DIR, entry)
|
||||
if (!statSync(path).isDirectory()) {
|
||||
shared[entry] = readFileSync(path, 'utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build template maps with shared files folded in
|
||||
for (const entry of readdirSync(TEMPLATES_DIR).sort()) {
|
||||
const path = join(TEMPLATES_DIR, entry)
|
||||
if (statSync(path).isDirectory()) {
|
||||
templates[entry] = { ...shared }
|
||||
binary[entry] = {}
|
||||
for (const filePath of readDir(path)) {
|
||||
const filename = relative(path, filePath)
|
||||
if (isBinary(filePath)) {
|
||||
binary[entry]![filename] = readFileSync(filePath).toString('base64')
|
||||
} else {
|
||||
templates[entry]![filename] = readFileSync(filePath, 'utf-8')
|
||||
}
|
||||
}
|
||||
if (Object.keys(binary[entry]!).length === 0) {
|
||||
delete binary[entry]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate TypeScript module
|
||||
const lines: string[] = [
|
||||
'// Auto-generated by scripts/embed-templates.ts',
|
||||
'// Run `bun run templates` to regenerate',
|
||||
'',
|
||||
`export const TEMPLATES: Record<string, Record<string, string>> = ${JSON.stringify(templates, null, 2)}`,
|
||||
'',
|
||||
`export const BINARY: Record<string, Record<string, string>> = ${JSON.stringify(binary, null, 2)}`,
|
||||
'',
|
||||
]
|
||||
|
||||
const outPath = join(import.meta.dir, '..', 'src', 'lib', 'templates.data.ts')
|
||||
await Bun.write(outPath, lines.join('\n'))
|
||||
console.log(`✓ Embedded templates → ${relative(join(import.meta.dir, '..'), outPath)}`)
|
||||
|
|
@ -1,9 +1,126 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
##
|
||||
# installs toes on your Raspberry Pi
|
||||
# delegates to the canonical installer at install/install.sh
|
||||
# installs systemd files to keep toes running on your Raspberry Pi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
exec "$(dirname "$0")/../install/install.sh"
|
||||
quiet() { "$@" > /dev/null 2>&1; }
|
||||
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
SERVICE_NAME="toes"
|
||||
SERVICE_FILE="$(dirname "$0")/${SERVICE_NAME}.service"
|
||||
SYSTEMD_PATH="${SYSTEMD_DIR}/${SERVICE_NAME}.service"
|
||||
|
||||
BUN_SYMLINK="/usr/local/bin/bun"
|
||||
BUN_REAL="$HOME/.bun/bin/bun"
|
||||
|
||||
echo ">> Updating system libraries"
|
||||
quiet sudo apt-get update
|
||||
quiet sudo apt-get install -y libcap2-bin
|
||||
quiet sudo apt-get install -y avahi-utils
|
||||
quiet sudo apt-get install -y dnsmasq
|
||||
quiet sudo apt-get install -y fish
|
||||
|
||||
echo ">> Setting fish as default shell for toes user"
|
||||
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
|
||||
quiet sudo chsh -s /usr/bin/fish toes
|
||||
echo "Default shell changed to fish"
|
||||
else
|
||||
echo "fish already set as default shell"
|
||||
fi
|
||||
|
||||
echo ">> Ensuring bun is available in /usr/local/bin"
|
||||
if [ ! -x "$BUN_SYMLINK" ]; then
|
||||
if [ -x "$BUN_REAL" ]; then
|
||||
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
||||
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
||||
else
|
||||
echo ">> Installing bun at $BUN_REAL"
|
||||
quiet sudo apt install unzip
|
||||
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
|
||||
if [ ! -x "$BUN_REAL" ]; then
|
||||
echo "ERROR: bun installation failed - $BUN_REAL not found"
|
||||
exit 1
|
||||
fi
|
||||
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
||||
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
||||
fi
|
||||
else
|
||||
echo "bun already available at $BUN_SYMLINK"
|
||||
fi
|
||||
|
||||
echo ">> Setting CAP_NET_BIND_SERVICE on $BUN_REAL"
|
||||
quiet sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
|
||||
quiet /usr/sbin/getcap "$BUN_REAL" || true
|
||||
|
||||
echo ">> Creating data and apps directories"
|
||||
mkdir -p ~/data
|
||||
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..."
|
||||
cp -r "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
|
||||
if ! (cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1; then
|
||||
echo " WARNING: bun install failed for $app, trying without lockfile..."
|
||||
(cd ~/apps/$app/current && bun install) > /dev/null 2>&1 || echo " ERROR: bun install failed for $app"
|
||||
fi
|
||||
else
|
||||
echo " WARNING: no version directory found for $app, skipping"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ">> Installing dependencies"
|
||||
bun install
|
||||
|
||||
echo ">> Building client bundle"
|
||||
bun run build
|
||||
|
||||
echo ">> Installing toes service"
|
||||
quiet sudo install -m 644 -o root -g root "$SERVICE_FILE" "$SYSTEMD_PATH"
|
||||
|
||||
echo ">> Reloading systemd daemon"
|
||||
quiet sudo systemctl daemon-reload
|
||||
|
||||
echo ">> Enabling $SERVICE_NAME to start at boot"
|
||||
quiet sudo systemctl enable "$SERVICE_NAME"
|
||||
|
||||
echo ">> Starting (or restarting) $SERVICE_NAME"
|
||||
quiet sudo systemctl restart "$SERVICE_NAME"
|
||||
|
||||
echo ">> Enabling kiosk mode"
|
||||
sudo raspi-config nonint do_boot_behaviour B4
|
||||
|
||||
# labwc (older RPi OS / manual installs)
|
||||
mkdir -p ~/.config/labwc
|
||||
cat > ~/.config/labwc/autostart <<'EOF'
|
||||
chromium --noerrdialogs --disable-infobars --kiosk http://localhost
|
||||
EOF
|
||||
# Wayfire (RPi OS Bookworm default)
|
||||
WAYFIRE_CONFIG="$HOME/.config/wayfire.ini"
|
||||
if [ -f "$WAYFIRE_CONFIG" ]; then
|
||||
# Remove existing chromium autostart if present
|
||||
sed -i '/^chromium = /d' "$WAYFIRE_CONFIG"
|
||||
# Add to existing [autostart] section or create it
|
||||
if grep -q '^\[autostart\]' "$WAYFIRE_CONFIG"; then
|
||||
sed -i '/^\[autostart\]/a chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost' "$WAYFIRE_CONFIG"
|
||||
else
|
||||
cat >> "$WAYFIRE_CONFIG" <<'EOF'
|
||||
|
||||
[autostart]
|
||||
chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ">> Done! Rebooting in 5 seconds..."
|
||||
systemctl status "$SERVICE_NAME" --no-pager -l || true
|
||||
sleep 5
|
||||
sudo reboot
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Builds a release tarball with all artifacts pre-built.
|
||||
# The Pi just untars, runs bun install, and starts.
|
||||
#
|
||||
# Usage: bun run release
|
||||
# Output: dist/toes-<version>.tar.gz
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\(.*\)".*/\1/')
|
||||
STAGING="$ROOT/dist/toes-$VERSION"
|
||||
|
||||
echo ">> Building toes $VERSION release"
|
||||
|
||||
# ── Clean ────────────────────────────────────────────────
|
||||
|
||||
rm -rf dist
|
||||
mkdir -p dist
|
||||
|
||||
# ── Client bundle ────────────────────────────────────────
|
||||
|
||||
echo ">> Building client bundle"
|
||||
rm -rf pub/client
|
||||
mkdir -p pub/client
|
||||
bun build src/client/index.tsx \
|
||||
--outfile pub/client/index.js \
|
||||
--target browser \
|
||||
--minify
|
||||
|
||||
# ── Embedded templates ───────────────────────────────────
|
||||
|
||||
echo ">> Embedding templates"
|
||||
bun run scripts/embed-templates.ts
|
||||
|
||||
# ── Bare repos ───────────────────────────────────────────
|
||||
|
||||
echo ">> Building bundled app repos"
|
||||
bash scripts/build-repos.sh
|
||||
|
||||
# ── CLI binary (linux-arm64 for Pi) ──────────────────────
|
||||
|
||||
echo ">> Building CLI for linux-arm64"
|
||||
bun build src/cli/index.ts \
|
||||
--compile \
|
||||
--target bun-linux-arm64 \
|
||||
--minify \
|
||||
--sourcemap=external \
|
||||
--define="__GIT_SHA__=\"$VERSION\"" \
|
||||
--outfile dist/toes-linux-arm64
|
||||
|
||||
# ── Stage release ────────────────────────────────────────
|
||||
|
||||
echo ">> Staging release"
|
||||
mkdir -p "$STAGING"
|
||||
|
||||
# Source (excluding dev artifacts)
|
||||
COPYFILE_DISABLE=1 tar -C "$ROOT" \
|
||||
--no-xattrs \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.git' \
|
||||
--exclude='dist' \
|
||||
--exclude='apps/*/node_modules' \
|
||||
--exclude='apps/*/logs' \
|
||||
--exclude='apps/*/current' \
|
||||
--exclude='apps/*/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' \
|
||||
-cf - . | tar -C "$STAGING" -xf -
|
||||
|
||||
# Pre-built artifacts
|
||||
mkdir -p "$STAGING/pub" "$STAGING/dist"
|
||||
cp -a pub/client "$STAGING/pub/client"
|
||||
cp -a dist/repos "$STAGING/dist/repos"
|
||||
cp dist/toes-linux-arm64 "$STAGING/dist/toes"
|
||||
|
||||
# Generated templates (so the server doesn't need to generate them)
|
||||
cp src/lib/templates.data.ts "$STAGING/src/lib/templates.data.ts"
|
||||
|
||||
# ── Tarball ──────────────────────────────────────────────
|
||||
|
||||
echo ">> Creating tarball"
|
||||
COPYFILE_DISABLE=1 tar -C dist --no-xattrs -czf "dist/toes-$VERSION.tar.gz" "toes-$VERSION"
|
||||
rm -rf "$STAGING"
|
||||
|
||||
SIZE=$(du -h "dist/toes-$VERSION.tar.gz" | cut -f1)
|
||||
echo ">> dist/toes-$VERSION.tar.gz ($SIZE)"
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build a release tarball, upload it to the Pi, and install it.
|
||||
# Usage: bun run remote:deploy
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$SCRIPT_DIR/.."
|
||||
|
||||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
# Build release tarball
|
||||
"$ROOT_DIR/scripts/release.sh"
|
||||
|
||||
# Find the tarball
|
||||
TARBALL=$(ls -1 "$ROOT_DIR"/dist/toes-*.tar.gz 2>/dev/null | head -1)
|
||||
[ -f "$TARBALL" ] || { echo "ERROR: No tarball found in dist/"; exit 1; }
|
||||
FILENAME=$(basename "$TARBALL")
|
||||
|
||||
echo ""
|
||||
echo ">> Uploading $FILENAME to $SSH_HOST"
|
||||
scp "$TARBALL" "$SSH_HOST:/tmp/$FILENAME"
|
||||
|
||||
echo ">> Installing on $SSH_HOST"
|
||||
ssh "$SSH_HOST" bash <<SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
DEST=~/toes
|
||||
APPS_DIR=~/apps
|
||||
DATA_DIR=~/data
|
||||
|
||||
echo ">> Extracting"
|
||||
mkdir -p "\$DEST"
|
||||
tar xzf "/tmp/$FILENAME" --strip-components=1 -C "\$DEST"
|
||||
rm "/tmp/$FILENAME"
|
||||
|
||||
echo ">> Installing dependencies"
|
||||
cd "\$DEST"
|
||||
~/.bun/bin/bun install > /dev/null 2>&1
|
||||
|
||||
# Bundled apps (parallel)
|
||||
REPOS_DIR="\$DATA_DIR/repos"
|
||||
mkdir -p "\$REPOS_DIR" "\$APPS_DIR"
|
||||
|
||||
echo ">> Installing bundled apps"
|
||||
pids=()
|
||||
for app_dir in "\$DEST"/apps/*/; do
|
||||
app=\$(basename "\$app_dir")
|
||||
[ -f "\$app_dir/package.json" ] || continue
|
||||
(
|
||||
cp -a "\$app_dir" "\$APPS_DIR/\$app"
|
||||
~/.bun/bin/bun install --frozen-lockfile --cwd "\$APPS_DIR/\$app" > /dev/null 2>&1 \
|
||||
|| ~/.bun/bin/bun install --cwd "\$APPS_DIR/\$app" > /dev/null 2>&1
|
||||
) &
|
||||
pids+=("\$!")
|
||||
done
|
||||
for pid in "\${pids[@]}"; do wait "\$pid"; done
|
||||
|
||||
echo ">> Installing CLI and repos"
|
||||
cp -a "\$DEST"/dist/repos/*.git "\$REPOS_DIR/" 2>/dev/null || true
|
||||
sudo install -m 755 "\$DEST/dist/toes" /usr/local/bin/toes
|
||||
|
||||
echo ">> Restarting toes"
|
||||
sudo systemctl restart toes
|
||||
echo ">> Done"
|
||||
SCRIPT
|
||||
|
|
@ -9,7 +9,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
|||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
# Run remote install on the target
|
||||
ssh "$SSH_HOST" bash <<'SCRIPT'
|
||||
set -e
|
||||
curl -fsSL https://toes.dev/install | sh
|
||||
SCRIPT
|
||||
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
|||
|
||||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
ssh "$SSH_HOST" "journalctl -u toes -n 100"
|
||||
ssh "$HOST" "journalctl -u toes -n 100"
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
|||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
ssh "$SSH_HOST" "sudo systemctl stop toes.service"
|
||||
ssh "$HOST" "sudo systemctl stop toes.service"
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# setup-ssh.sh - Configure SSH for the toes CLI user
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates a `cli` system user with /usr/local/bin/toes as shell
|
||||
# 2. Suppresses login banner via .hushlogin
|
||||
# 3. Sets an empty password on `cli` for passwordless SSH
|
||||
# 4. Adds a Match block in sshd_config to allow empty passwords for `cli`
|
||||
# 5. Adds /usr/local/bin/toes to /etc/shells
|
||||
# 6. Restarts sshd
|
||||
#
|
||||
# Run as root on the toes machine.
|
||||
# Usage: ssh cli@toes.local
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TOES_SHELL="/usr/local/bin/toes"
|
||||
SSHD_CONFIG="/etc/ssh/sshd_config"
|
||||
|
||||
echo "==> Setting up SSH CLI user for toes"
|
||||
|
||||
# 1. Create cli system user
|
||||
if ! id cli &>/dev/null; then
|
||||
useradd --system --home-dir /home/cli --shell "$TOES_SHELL" --create-home cli
|
||||
echo " Created cli user"
|
||||
else
|
||||
echo " cli user already exists"
|
||||
fi
|
||||
|
||||
# 2. Suppress login banner (MOTD, last login, etc.)
|
||||
touch /home/cli/.hushlogin
|
||||
chown cli:cli /home/cli/.hushlogin 2>/dev/null || true
|
||||
echo " Created .hushlogin"
|
||||
|
||||
# 3. Set empty password
|
||||
passwd -d cli
|
||||
echo " Set empty password on cli"
|
||||
|
||||
# 4. Add Match block for cli user in sshd_config
|
||||
if ! grep -q 'Match User cli' "$SSHD_CONFIG"; then
|
||||
cat >> "$SSHD_CONFIG" <<EOF
|
||||
|
||||
# toes CLI: allow passwordless SSH for the cli user
|
||||
Match User cli
|
||||
PermitEmptyPasswords yes
|
||||
EOF
|
||||
echo " Added Match User cli block to sshd_config"
|
||||
else
|
||||
echo " sshd_config already has Match User cli block"
|
||||
fi
|
||||
|
||||
# 5. Ensure /usr/local/bin/toes is in /etc/shells
|
||||
if ! grep -q "^${TOES_SHELL}$" /etc/shells; then
|
||||
echo "$TOES_SHELL" >> /etc/shells
|
||||
echo " Added $TOES_SHELL to /etc/shells"
|
||||
else
|
||||
echo " $TOES_SHELL already in /etc/shells"
|
||||
fi
|
||||
|
||||
# Warn if toes binary doesn't exist yet
|
||||
if [ ! -f "$TOES_SHELL" ]; then
|
||||
echo " WARNING: $TOES_SHELL does not exist yet"
|
||||
echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL"
|
||||
fi
|
||||
|
||||
# 6. Restart sshd
|
||||
echo " Restarting sshd..."
|
||||
systemctl restart sshd || service ssh restart || true
|
||||
|
||||
echo "==> Done. Connect with: ssh cli@toes.local"
|
||||
15
scripts/wifi-captive.conf
Normal file
15
scripts/wifi-captive.conf
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# dnsmasq config for Toes WiFi setup captive portal
|
||||
# Redirect ALL DNS queries to the hotspot gateway IP
|
||||
|
||||
# Only listen on the hotspot interface
|
||||
interface=wlan0
|
||||
bind-interfaces
|
||||
|
||||
# Resolve everything to our IP (captive portal)
|
||||
address=/#/10.42.0.1
|
||||
|
||||
# Don't use /etc/resolv.conf
|
||||
no-resolv
|
||||
|
||||
# Don't read /etc/hosts
|
||||
no-hosts
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { LogLine } from '@types'
|
||||
import color from 'ansis'
|
||||
import { get, getSignal, handleError, makeUrl, post } from '../http'
|
||||
import color from 'kleur'
|
||||
import { get, handleError, makeUrl, post } from '../http'
|
||||
import { resolveAppName } from '../name'
|
||||
|
||||
interface CronJobSummary {
|
||||
|
|
@ -195,7 +195,7 @@ const printCronLog = (line: LogLine) =>
|
|||
async function tailCronLogs(app: string, grep?: string) {
|
||||
try {
|
||||
const url = makeUrl(`/api/apps/${app}/logs/stream`)
|
||||
const res = await fetch(url, { signal: getSignal() })
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
console.error(`App not found: ${app}`)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import color from 'ansis'
|
||||
import color from 'kleur'
|
||||
import { del, get, handleError, post } from '../http'
|
||||
import { resolveAppName } from '../name'
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ async function globalEnvSet(keyOrKeyValue: string, valueArg?: string) {
|
|||
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(color.bold().cyan('Global Environment Variables'))
|
||||
console.log()
|
||||
if (!vars || vars.length === 0) {
|
||||
console.log(color.gray(' No global environment variables set'))
|
||||
|
|
@ -68,7 +68,7 @@ export async function envList(name: string | undefined, opts: { global?: boolean
|
|||
return
|
||||
}
|
||||
|
||||
console.log(color.bold.cyan(`Environment Variables for ${appName}`))
|
||||
console.log(color.bold().cyan(`Environment Variables for ${appName}`))
|
||||
console.log()
|
||||
|
||||
const appKeys = new Set(vars.map(v => v.key))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export { cronList, cronLog, cronRun, cronStatus } from './cron'
|
|||
export { envList, envRm, envSet } from './env'
|
||||
export { logApp } from './logs'
|
||||
export {
|
||||
getApp,
|
||||
configShow,
|
||||
infoApp,
|
||||
listApps,
|
||||
newApp,
|
||||
|
|
@ -16,4 +16,4 @@ export {
|
|||
unshareApp,
|
||||
} from './manage'
|
||||
export { metricsApp } from './metrics'
|
||||
export { perfToggle } from './perf'
|
||||
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { LogLine } from '@types'
|
||||
import { get, getSignal, handleError, makeUrl } from '../http'
|
||||
import { get, handleError, makeUrl } from '../http'
|
||||
import { resolveAppName } from '../name'
|
||||
|
||||
interface LogOptions {
|
||||
|
|
@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) {
|
|||
export async function tailLogs(name: string, grep?: string) {
|
||||
try {
|
||||
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
||||
const res = await fetch(url, { signal: getSignal() })
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
console.error(`App not found: ${name}`)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import type { App } from '@types'
|
||||
import { generateTemplates, type TemplateType } from '%templates'
|
||||
import color from 'ansis'
|
||||
import { readSyncState } from '%sync'
|
||||
import color from 'kleur'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { basename, join } from 'path'
|
||||
import { buildAppUrl } from '@urls'
|
||||
import { del, get, getManifest, gitUrl, HOST, post } from '../http'
|
||||
import { del, get, getManifest, HOST, post } from '../http'
|
||||
import { confirm, prompt } from '../prompts'
|
||||
import { resolveAppName } from '../name'
|
||||
import { pushApp } from './sync'
|
||||
|
||||
export const STATE_ICONS: Record<string, string> = {
|
||||
error: color.red('●'),
|
||||
|
|
@ -34,6 +36,15 @@ async function waitForState(name: string, target: string, timeout: number): Prom
|
|||
return app?.state
|
||||
}
|
||||
|
||||
export async function configShow() {
|
||||
console.log(`Host: ${color.bold(HOST)}`)
|
||||
|
||||
const syncState = readSyncState(process.cwd())
|
||||
if (syncState) {
|
||||
console.log(`Version: ${color.bold(syncState.version)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function infoApp(arg?: string) {
|
||||
const name = resolveAppName(arg)
|
||||
if (!name) return
|
||||
|
|
@ -173,42 +184,17 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
|
|||
writeFileSync(join(appPath, filename), content)
|
||||
}
|
||||
|
||||
// Initialize git repo and push to server (git push creates the app via the git tool)
|
||||
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: appPath, stdout: 'ignore', stderr: 'ignore' }).exited
|
||||
|
||||
await run(['git', 'init'])
|
||||
await run(['git', 'add', '.'])
|
||||
await run(['git', 'commit', '-m', 'init'])
|
||||
await run(['git', 'remote', 'add', 'toes', gitUrl(appName)])
|
||||
await run(['git', 'push', 'toes', 'main'])
|
||||
process.chdir(appPath)
|
||||
await pushApp()
|
||||
|
||||
console.log(color.green(`✓ Created ${appName}`))
|
||||
console.log()
|
||||
console.log('Next steps:')
|
||||
if (name) {
|
||||
console.log(`\n cd ${name}`)
|
||||
console.log(` cd ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApp(name: string, directory?: string) {
|
||||
const target = directory ?? name
|
||||
|
||||
if (existsSync(target)) {
|
||||
console.error(`Directory already exists: ${target}`)
|
||||
return
|
||||
}
|
||||
|
||||
const url = gitUrl(name)
|
||||
const args = ['git', 'clone', url]
|
||||
if (directory) args.push(directory)
|
||||
const proc = Bun.spawn(args, { stdout: 'inherit', stderr: 'inherit' })
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error(color.red(`Failed to clone ${name}`))
|
||||
return
|
||||
}
|
||||
|
||||
console.log(color.green(`✓ Cloned ${name}`))
|
||||
console.log(`\n cd ${target}\n bun install`)
|
||||
console.log(' bun install')
|
||||
console.log(' bun dev')
|
||||
}
|
||||
|
||||
export async function openApp(arg?: string) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import color from 'ansis'
|
||||
import color from 'kleur'
|
||||
import { get } from '../http'
|
||||
import { resolveAppName } from '../name'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import color from 'ansis'
|
||||
import { get, post } from '../http'
|
||||
|
||||
export async function perfToggle(onOff?: string) {
|
||||
if (onOff && !['on', 'off', 'status'].includes(onOff)) {
|
||||
console.error('Usage: toes perf [on|off|status]')
|
||||
process.exit(1)
|
||||
}
|
||||
const body = onOff && onOff !== 'status' ? { on: onOff === 'on' } : {}
|
||||
const res = onOff === 'status'
|
||||
? await get<{ perfTiming: boolean }>('/api/system/perf')
|
||||
: await post<{ perfTiming: boolean }>('/api/system/perf', body)
|
||||
if (res) {
|
||||
const label = res.perfTiming ? color.green('on') : color.red('off')
|
||||
console.log(`Perf timing ${onOff === 'status' ? 'is ' : ''}${label}`)
|
||||
}
|
||||
}
|
||||
1224
src/cli/commands/sync.ts
Normal file
1224
src/cli/commands/sync.ts
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -1,9 +1,6 @@
|
|||
import type { Manifest } from '@types'
|
||||
import { buildAppUrl } from '@urls'
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
|
||||
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
||||
const signalStore = new AsyncLocalStorage<AbortSignal>()
|
||||
|
||||
const normalizeUrl = (url: string) =>
|
||||
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
|
||||
|
|
@ -21,14 +18,6 @@ export const HOST = process.env.TOES_URL
|
|||
? normalizeUrl(process.env.TOES_URL)
|
||||
: DEFAULT_HOST
|
||||
|
||||
export const gitUrl = (name: string) => `${buildAppUrl('git', HOST)}/${name}`
|
||||
|
||||
export const getSignal = () => signalStore.getStore()
|
||||
|
||||
export function withSignal<T>(signal: AbortSignal, fn: () => T): T {
|
||||
return signalStore.run(signal, fn)
|
||||
}
|
||||
|
||||
export function makeUrl(path: string): string {
|
||||
return `${HOST}${path}`
|
||||
}
|
||||
|
|
@ -48,7 +37,7 @@ export function handleError(error: unknown): void {
|
|||
|
||||
export async function get<T>(url: string): Promise<T | undefined> {
|
||||
try {
|
||||
const res = await fetch(makeUrl(url), { signal: getSignal() })
|
||||
const res = await fetch(makeUrl(url))
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||
|
|
@ -60,13 +49,14 @@ export async function get<T>(url: string): Promise<T | undefined> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> {
|
||||
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
|
||||
try {
|
||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() })
|
||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
||||
if (res.status === 404) return { exists: false }
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
const manifest = await res.json()
|
||||
return { exists: true, manifest }
|
||||
const data = await res.json()
|
||||
const { version, ...manifest } = data
|
||||
return { exists: true, manifest, version }
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
return null
|
||||
|
|
@ -79,7 +69,6 @@ export async function post<T, B = unknown>(url: string, body?: B): Promise<T | u
|
|||
method: 'POST',
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: getSignal(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
|
|
@ -97,7 +86,6 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
|
|||
const res = await fetch(makeUrl(url), {
|
||||
method: 'PUT',
|
||||
body: body as BodyInit,
|
||||
signal: getSignal(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
|
|
@ -114,7 +102,7 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
|
|||
export async function download(url: string): Promise<Buffer | undefined> {
|
||||
try {
|
||||
const fullUrl = makeUrl(url)
|
||||
const res = await fetch(fullUrl, { signal: getSignal() })
|
||||
const res = await fetch(fullUrl)
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||
|
|
@ -130,7 +118,6 @@ export async function del(url: string): Promise<boolean> {
|
|||
try {
|
||||
const res = await fetch(makeUrl(url), {
|
||||
method: 'DELETE',
|
||||
signal: getSignal(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
|
|
|
|||
|
|
@ -1,23 +1,4 @@
|
|||
#!/usr/bin/env bun
|
||||
process.env.FORCE_COLOR = '1'
|
||||
|
||||
import { program } from './setup'
|
||||
|
||||
const isCliUser = process.env.USER === 'cli'
|
||||
const noArgs = process.argv.length <= 2
|
||||
const isTTY = !!process.stdin.isTTY
|
||||
|
||||
// SSH login shell passes commands as: toes -c "command args"
|
||||
// With shebang, argv is [bun, script, -c, cmd]; compiled, it's [toes, -c, cmd]
|
||||
const cIndex = process.argv[1] === '-c' ? 1 : process.argv[2] === '-c' ? 2 : -1
|
||||
const shellExec = cIndex !== -1 ? process.argv.slice(cIndex + 1).join(' ') : null
|
||||
|
||||
if (shellExec) {
|
||||
const tokens = shellExec.split(/\s+/).filter(Boolean)
|
||||
program.parse(['node', 'toes', ...tokens])
|
||||
} else if (isCliUser && noArgs && isTTY) {
|
||||
const { shell } = await import('./shell')
|
||||
await shell()
|
||||
} else {
|
||||
program.parse()
|
||||
}
|
||||
program.parse()
|
||||
|
|
|
|||
153
src/cli/setup.ts
153
src/cli/setup.ts
|
|
@ -1,40 +1,52 @@
|
|||
import { program } from 'commander'
|
||||
|
||||
import color from 'ansis'
|
||||
import color from 'kleur'
|
||||
|
||||
import pkg from '../../package.json'
|
||||
import { SHA } from './sha'
|
||||
import { withPager } from './pager'
|
||||
import {
|
||||
cleanApp,
|
||||
configShow,
|
||||
cronList,
|
||||
cronLog,
|
||||
cronRun,
|
||||
cronStatus,
|
||||
diffApp,
|
||||
envList,
|
||||
envRm,
|
||||
envSet,
|
||||
getApp,
|
||||
historyApp,
|
||||
infoApp,
|
||||
listApps,
|
||||
logApp,
|
||||
metricsApp,
|
||||
newApp,
|
||||
openApp,
|
||||
perfToggle,
|
||||
pullApp,
|
||||
pushApp,
|
||||
renameApp,
|
||||
restartApp,
|
||||
rmApp,
|
||||
shareApp,
|
||||
rollbackApp,
|
||||
stashApp,
|
||||
stashListApp,
|
||||
stashPopApp,
|
||||
startApp,
|
||||
metricsApp,
|
||||
shareApp,
|
||||
statusApp,
|
||||
stopApp,
|
||||
syncApp,
|
||||
unshareApp,
|
||||
versionsApp,
|
||||
} from './commands'
|
||||
|
||||
program
|
||||
.name('toes')
|
||||
.version(`v${pkg.version}-${SHA}`, '-v, --version')
|
||||
.version(`v${pkg.version}`, '-v, --version')
|
||||
.addHelpText('beforeAll', (ctx) => {
|
||||
if (ctx.command === program) {
|
||||
return color.bold.cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
|
||||
return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
|
@ -54,25 +66,17 @@ program
|
|||
// Apps
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.command('list')
|
||||
.helpGroup('Apps:')
|
||||
.description('Show status of all apps, or details for a specific app')
|
||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||
.option('-t, --tools', 'show only tools')
|
||||
.option('-a, --apps', 'show only apps (exclude tools)')
|
||||
.action((name?: string, options?: { apps?: boolean; tools?: boolean }) => {
|
||||
if (name) return infoApp(name)
|
||||
return listApps(options ?? {})
|
||||
})
|
||||
|
||||
program
|
||||
.command('list', { hidden: true })
|
||||
.description('List all apps')
|
||||
.option('-t, --tools', 'show only tools')
|
||||
.option('-a, --apps', 'show only apps (exclude tools)')
|
||||
.action(listApps)
|
||||
|
||||
program
|
||||
.command('info', { hidden: true })
|
||||
.command('info')
|
||||
.helpGroup('Apps:')
|
||||
.description('Show info for an app')
|
||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||
.action(infoApp)
|
||||
|
||||
|
|
@ -89,9 +93,8 @@ program
|
|||
program
|
||||
.command('get')
|
||||
.helpGroup('Apps:')
|
||||
.description('Clone an app from the server')
|
||||
.description('Download an app from server')
|
||||
.argument('<name>', 'app name')
|
||||
.argument('[directory]', 'target directory (defaults to app name)')
|
||||
.action(getApp)
|
||||
|
||||
program
|
||||
|
|
@ -206,8 +209,72 @@ cron
|
|||
.argument('<job>', 'job identifier (app:name)')
|
||||
.action(cronRun)
|
||||
|
||||
// Sync
|
||||
|
||||
program
|
||||
.command('push')
|
||||
.helpGroup('Sync:')
|
||||
.description('Push local changes to server')
|
||||
.option('-f, --force', 'overwrite remote changes')
|
||||
.action(pushApp)
|
||||
|
||||
program
|
||||
.command('pull')
|
||||
.helpGroup('Sync:')
|
||||
.description('Pull changes from server')
|
||||
.option('-f, --force', 'overwrite local changes')
|
||||
.action(pullApp)
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.helpGroup('Sync:')
|
||||
.description('Show what would be pushed/pulled')
|
||||
.action(statusApp)
|
||||
|
||||
program
|
||||
.command('diff')
|
||||
.helpGroup('Sync:')
|
||||
.description('Show diff of changed files')
|
||||
.action(() => withPager(diffApp))
|
||||
|
||||
program
|
||||
.command('sync')
|
||||
.helpGroup('Sync:')
|
||||
.description('Watch and sync changes bidirectionally')
|
||||
.action(syncApp)
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.helpGroup('Sync:')
|
||||
.description('Remove local files not on server')
|
||||
.option('-f, --force', 'skip confirmation')
|
||||
.option('-n, --dry-run', 'show what would be removed')
|
||||
.action(cleanApp)
|
||||
|
||||
const stash = program
|
||||
.command('stash')
|
||||
.helpGroup('Sync:')
|
||||
.description('Stash local changes')
|
||||
.action(stashApp)
|
||||
|
||||
stash
|
||||
.command('pop')
|
||||
.description('Restore stashed changes')
|
||||
.action(stashPopApp)
|
||||
|
||||
stash
|
||||
.command('list')
|
||||
.description('List all stashes')
|
||||
.action(stashListApp)
|
||||
|
||||
// Config
|
||||
|
||||
program
|
||||
.command('config')
|
||||
.helpGroup('Config:')
|
||||
.description('Show current host configuration')
|
||||
.action(configShow)
|
||||
|
||||
const env = program
|
||||
.command('env')
|
||||
.helpGroup('Config:')
|
||||
|
|
@ -234,35 +301,25 @@ env
|
|||
.action(envRm)
|
||||
|
||||
program
|
||||
.command('perf')
|
||||
.command('versions')
|
||||
.helpGroup('Config:')
|
||||
.description('Toggle request timing for proxied app requests')
|
||||
.argument('[on|off|status]', 'enable, disable, or check status (toggles if omitted)')
|
||||
.action(perfToggle)
|
||||
|
||||
// Shell
|
||||
.description('List deployed versions')
|
||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||
.action(versionsApp)
|
||||
|
||||
program
|
||||
.command('shell')
|
||||
.description('Interactive shell')
|
||||
.action(async () => {
|
||||
const { shell } = await import('./shell')
|
||||
await shell()
|
||||
})
|
||||
.command('history')
|
||||
.helpGroup('Config:')
|
||||
.description('Show file changes between versions')
|
||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||
.action(historyApp)
|
||||
|
||||
// Hide and disable commands that don't work over SSH
|
||||
if (process.env.USER === 'cli') {
|
||||
const disabled = ['shell', 'get', 'open']
|
||||
for (const name of disabled) {
|
||||
const cmd = program.commands.find((c) => c.name() === name)
|
||||
if (!cmd) continue
|
||||
cmd.helpInformation = () => ''
|
||||
;(cmd as any)._hidden = true
|
||||
cmd.action(() => {
|
||||
console.error(`"${name}" is not available over SSH`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
}
|
||||
program
|
||||
.command('rollback')
|
||||
.helpGroup('Config:')
|
||||
.description('Rollback to a previous version')
|
||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
||||
.action((name, options) => rollbackApp(name, options.version))
|
||||
|
||||
export { program }
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
declare var __GIT_SHA__: string | undefined
|
||||
|
||||
export const SHA: string = typeof __GIT_SHA__ !== 'undefined' ? __GIT_SHA__ : 'dev'
|
||||
227
src/cli/shell.ts
227
src/cli/shell.ts
|
|
@ -1,227 +0,0 @@
|
|||
import type { App } from '@types'
|
||||
|
||||
import * as readline from 'readline'
|
||||
|
||||
import color from 'ansis'
|
||||
|
||||
import { get, handleError, HOST, withSignal } from './http'
|
||||
import { program } from './setup'
|
||||
import { STATE_ICONS } from './commands/manage'
|
||||
|
||||
let appNamesCache: string[] = []
|
||||
let appNamesCacheTime = 0
|
||||
|
||||
const APP_CACHE_TTL = 5000
|
||||
|
||||
function tokenize(input: string): string[] {
|
||||
const tokens: string[] = []
|
||||
let current = ''
|
||||
let quote: string | null = null
|
||||
|
||||
for (const ch of input) {
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null
|
||||
} else {
|
||||
current += ch
|
||||
}
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
} else if (ch === ' ' || ch === '\t') {
|
||||
if (current) {
|
||||
tokens.push(current)
|
||||
current = ''
|
||||
}
|
||||
} else {
|
||||
current += ch
|
||||
}
|
||||
}
|
||||
if (current) tokens.push(current)
|
||||
return tokens
|
||||
}
|
||||
|
||||
async function fetchAppNames(): Promise<string[]> {
|
||||
const now = Date.now()
|
||||
if (appNamesCache.length > 0 && now - appNamesCacheTime < APP_CACHE_TTL) {
|
||||
return appNamesCache
|
||||
}
|
||||
try {
|
||||
const apps = await get<App[]>('/api/apps')
|
||||
if (apps) {
|
||||
appNamesCache = apps.map(a => a.name)
|
||||
appNamesCacheTime = now
|
||||
}
|
||||
} catch {
|
||||
// use stale cache
|
||||
}
|
||||
return appNamesCache
|
||||
}
|
||||
|
||||
function getCommandNames(): string[] {
|
||||
return program.commands
|
||||
.filter((cmd) => !(cmd as any)._hidden)
|
||||
.map((cmd) => cmd.name())
|
||||
}
|
||||
|
||||
async function printBanner(): Promise<void> {
|
||||
const apps = await get<App[]>('/api/apps')
|
||||
if (!apps) {
|
||||
console.log(color.bold.cyan(' \u{1F43E} Toes') + ` ${HOST}`)
|
||||
console.log()
|
||||
return
|
||||
}
|
||||
|
||||
// Cache app names from banner fetch
|
||||
appNamesCache = apps.map(a => a.name)
|
||||
appNamesCacheTime = Date.now()
|
||||
|
||||
const visibleApps = apps.filter(a => !a.tool)
|
||||
|
||||
console.log()
|
||||
console.log(color.bold.cyan(' \u{1F43E} Toes') + ` ${HOST}`)
|
||||
console.log()
|
||||
|
||||
// App status line
|
||||
const parts = visibleApps.map(a => {
|
||||
const icon = STATE_ICONS[a.state] ?? '\u25CB'
|
||||
return `${icon} ${a.name}`
|
||||
})
|
||||
if (parts.length > 0) {
|
||||
console.log(' ' + parts.join(' '))
|
||||
console.log()
|
||||
}
|
||||
|
||||
const running = visibleApps.filter(a => a.state === 'running').length
|
||||
const stopped = visibleApps.filter(a => a.state !== 'running').length
|
||||
const summary = []
|
||||
if (running) summary.push(`${running} running`)
|
||||
if (stopped) summary.push(`${stopped} stopped`)
|
||||
if (summary.length > 0) {
|
||||
console.log(color.gray(` ${summary.join(', ')} \u2014 type "help" for commands`))
|
||||
} else {
|
||||
console.log(color.gray(' no apps \u2014 type "help" for commands'))
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
export async function shell(): Promise<void> {
|
||||
await printBanner()
|
||||
|
||||
// Configure Commander to throw instead of exiting
|
||||
program.exitOverride()
|
||||
program.configureOutput({
|
||||
writeOut: (str: string) => process.stdout.write(str),
|
||||
writeErr: (str: string) => process.stderr.write(str),
|
||||
})
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
prompt: color.cyan('toes> '),
|
||||
completer: (line: string, callback: (err: null, result: [string[], string]) => void) => {
|
||||
const tokens = tokenize(line)
|
||||
const trailing = line.endsWith(' ')
|
||||
|
||||
if (tokens.length === 0 || (tokens.length === 1 && !trailing)) {
|
||||
// Complete command names
|
||||
const partial = tokens[0] ?? ''
|
||||
const commands = getCommandNames()
|
||||
const hits = commands.filter(c => c.startsWith(partial))
|
||||
callback(null, [hits, partial])
|
||||
} else {
|
||||
// Complete app names
|
||||
const partial = trailing ? '' : (tokens[tokens.length - 1] ?? '')
|
||||
const names = appNamesCache
|
||||
const hits = names.filter(n => n.startsWith(partial))
|
||||
callback(null, [hits, partial])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Refresh app names cache in background for tab completion
|
||||
fetchAppNames()
|
||||
|
||||
let activeAbort: AbortController | null = null
|
||||
|
||||
rl.on('SIGINT', () => {
|
||||
if (activeAbort) {
|
||||
activeAbort.abort()
|
||||
activeAbort = null
|
||||
console.log()
|
||||
rl.prompt()
|
||||
} else {
|
||||
// Clear current line
|
||||
rl.write(null, { ctrl: true, name: 'u' })
|
||||
console.log()
|
||||
rl.prompt()
|
||||
}
|
||||
})
|
||||
|
||||
rl.prompt()
|
||||
|
||||
for await (const line of rl) {
|
||||
const input = line.trim()
|
||||
|
||||
if (!input) {
|
||||
rl.prompt()
|
||||
continue
|
||||
}
|
||||
|
||||
if (input === 'exit' || input === 'quit') {
|
||||
break
|
||||
}
|
||||
|
||||
if (input === 'clear') {
|
||||
console.clear()
|
||||
rl.prompt()
|
||||
continue
|
||||
}
|
||||
|
||||
if (input === 'help') {
|
||||
program.outputHelp()
|
||||
rl.prompt()
|
||||
continue
|
||||
}
|
||||
|
||||
const tokens = tokenize(input)
|
||||
|
||||
// Set up AbortController for this command
|
||||
activeAbort = new AbortController()
|
||||
const signal = activeAbort.signal
|
||||
|
||||
// Pause readline so commands can use their own prompts
|
||||
rl.pause()
|
||||
|
||||
try {
|
||||
await withSignal(signal, () => program.parseAsync(['node', 'toes', ...tokens]))
|
||||
} catch (err: unknown) {
|
||||
// Commander throws on exitOverride — suppress help/version exits
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
const code = (err as { code: string }).code
|
||||
if (code === 'commander.helpDisplayed' || code === 'commander.version') {
|
||||
// Already printed, just continue
|
||||
} else if (code === 'commander.unknownCommand') {
|
||||
console.error(`Unknown command: ${tokens[0]}`)
|
||||
} else {
|
||||
// Other Commander errors (missing arg, etc.)
|
||||
// Commander already printed the error message
|
||||
}
|
||||
} else if (signal.aborted) {
|
||||
// Command was cancelled by Ctrl+C
|
||||
} else {
|
||||
handleError(err)
|
||||
}
|
||||
} finally {
|
||||
activeAbort = null
|
||||
}
|
||||
|
||||
// Refresh app names cache after commands that might change state
|
||||
fetchAppNames()
|
||||
|
||||
rl.resume()
|
||||
rl.prompt()
|
||||
}
|
||||
|
||||
rl.close()
|
||||
console.log()
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
const ESC = /\x1b\[([0-9;]*)m/g
|
||||
|
||||
const STYLES: Record<number, string> = {
|
||||
1: 'font-weight:bold',
|
||||
2: 'opacity:0.7',
|
||||
30: 'color:#666',
|
||||
31: 'color:#f87171',
|
||||
32: 'color:#4ade80',
|
||||
33: 'color:#facc15',
|
||||
34: 'color:#60a5fa',
|
||||
35: 'color:#c084fc',
|
||||
36: 'color:#22d3ee',
|
||||
37: 'color:#e5e5e5',
|
||||
90: 'color:#999',
|
||||
91: 'color:#fca5a5',
|
||||
92: 'color:#86efac',
|
||||
93: 'color:#fde047',
|
||||
94: 'color:#93c5fd',
|
||||
95: 'color:#d8b4fe',
|
||||
96: 'color:#67e8f9',
|
||||
97: 'color:#fff',
|
||||
}
|
||||
|
||||
const escape = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
export const stripAnsi = (s: string) =>
|
||||
s.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
|
||||
export function ansiToHtml(text: string): string {
|
||||
if (!text.includes('\x1b')) return escape(text)
|
||||
|
||||
ESC.lastIndex = 0
|
||||
let result = ''
|
||||
let last = 0
|
||||
let open = false
|
||||
const styles: string[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = ESC.exec(text)) !== null) {
|
||||
result += escape(text.slice(last, match.index))
|
||||
last = match.index + match[0].length
|
||||
|
||||
const codes = match[1] ? match[1].split(';').map(Number) : [0]
|
||||
|
||||
for (const code of codes) {
|
||||
if (code === 0) {
|
||||
styles.length = 0
|
||||
if (open) { result += '</span>'; open = false }
|
||||
} else if (code === 39) {
|
||||
const filtered = styles.filter(s => !s.startsWith('color:'))
|
||||
styles.length = 0
|
||||
styles.push(...filtered)
|
||||
if (open) { result += '</span>'; open = false }
|
||||
} else if (STYLES[code]) {
|
||||
styles.push(STYLES[code])
|
||||
}
|
||||
}
|
||||
|
||||
if (styles.length) {
|
||||
if (open) result += '</span>'
|
||||
result += `<span style="${styles.join(';')}">`
|
||||
open = true
|
||||
}
|
||||
}
|
||||
|
||||
result += escape(text.slice(last))
|
||||
if (open) result += '</span>'
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,29 +1,32 @@
|
|||
import type { ConnectResult, WifiNetwork, WifiStatus } from '../shared/types'
|
||||
|
||||
export const connectToWifi = (ssid: string, password?: string): Promise<ConnectResult> =>
|
||||
fetch('/api/wifi/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ssid, password }),
|
||||
}).then(r => r.json())
|
||||
|
||||
export const getLogDates = (name: string): Promise<string[]> =>
|
||||
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
|
||||
|
||||
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
||||
|
||||
export const getSystemInfo = (): Promise<{ version: string, sha: string, uptime: number }> =>
|
||||
fetch('/api/system/info').then(r => r.json())
|
||||
export const getWifiStatus = (): Promise<WifiStatus & { setupMode: boolean, url: string }> =>
|
||||
fetch('/api/wifi/status').then(r => r.json())
|
||||
|
||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||
|
||||
export const scanWifiNetworks = (): Promise<WifiNetwork[]> =>
|
||||
fetch('/api/wifi/scan').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 applyUpdate = () =>
|
||||
fetch('/api/system/update', { method: 'POST' }).then(r => { if (!r.ok) throw new Error('update failed'); return r.json() })
|
||||
|
||||
export const checkForUpdate = (): Promise<{ available: boolean, current: string, latest: string, commits: string[] }> =>
|
||||
fetch('/api/system/update').then(r => { if (!r.ok) throw new Error('check failed'); return r.json() })
|
||||
|
||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||
|
||||
export const restartServer = () =>
|
||||
fetch('/api/system/restart', { method: 'POST' }).then(r => { if (!r.ok) throw new Error('restart failed'); return r.json() })
|
||||
|
||||
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||
|
||||
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
|
||||
|
||||
export const unshareApp = (name: string) =>
|
||||
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ import { define } from '@because/forge'
|
|||
import type { App } from '../../shared/types'
|
||||
import { buildAppUrl } from '../../shared/urls'
|
||||
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
|
||||
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
|
||||
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||
import { apps, getSelectedTab, isNarrow } from '../state'
|
||||
import {
|
||||
ActionBar,
|
||||
AppSelectorChevron,
|
||||
Button,
|
||||
ClickableAppName,
|
||||
HamburgerButton,
|
||||
HamburgerLine,
|
||||
HeaderActions,
|
||||
InfoLabel,
|
||||
InfoRow,
|
||||
|
|
@ -45,26 +44,25 @@ const OpenEmojiPicker = define('OpenEmojiPicker', {
|
|||
})
|
||||
|
||||
export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||
// Find tools that show on app pages (apps !== false)
|
||||
const tools = apps.filter(a => a.tool && a.apps !== false)
|
||||
// Find all tools
|
||||
const tools = apps.filter(a => a.tool)
|
||||
const selectedTab = getSelectedTab(app.name)
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<MainHeader>
|
||||
<MainTitle>
|
||||
{isNarrow && (
|
||||
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
|
||||
<HamburgerLine />
|
||||
<HamburgerLine />
|
||||
<HamburgerLine />
|
||||
</HamburgerButton>
|
||||
)}
|
||||
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
|
||||
|
||||
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
||||
{isNarrow && (
|
||||
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
|
||||
▼
|
||||
</AppSelectorChevron>
|
||||
)}
|
||||
</MainTitle>
|
||||
<HeaderActions>
|
||||
{(!app.tool || app.share) && (
|
||||
{!app.tool && (
|
||||
app.tunnelUrl
|
||||
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
|
||||
: app.tunnelEnabled
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { CSSProperties } from 'hono/jsx'
|
|||
import {
|
||||
apps,
|
||||
selectedApp,
|
||||
setSelectedApp,
|
||||
setSidebarSection,
|
||||
sidebarSection,
|
||||
} from '../state'
|
||||
|
|
@ -16,13 +17,19 @@ import {
|
|||
interface AppSelectorProps {
|
||||
render: () => void
|
||||
onSelect?: () => void
|
||||
onDashboard?: () => void
|
||||
collapsed?: boolean
|
||||
large?: boolean
|
||||
switcherStyle?: CSSProperties
|
||||
listStyle?: CSSProperties
|
||||
}
|
||||
|
||||
export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
|
||||
export function AppSelector({ render, onSelect, onDashboard, collapsed, switcherStyle, listStyle }: AppSelectorProps) {
|
||||
const selectApp = (name: string) => {
|
||||
setSelectedApp(name)
|
||||
onSelect?.()
|
||||
render()
|
||||
}
|
||||
|
||||
const switchSection = (section: 'apps' | 'tools') => {
|
||||
setSidebarSection(section)
|
||||
render()
|
||||
|
|
@ -36,18 +43,18 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
|
|||
<>
|
||||
{!collapsed && toolApps.length > 0 && (
|
||||
<SectionSwitcher style={switcherStyle}>
|
||||
<SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
|
||||
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
|
||||
Apps
|
||||
</SectionTab>
|
||||
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
|
||||
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
|
||||
Tools
|
||||
</SectionTab>
|
||||
</SectionSwitcher>
|
||||
)}
|
||||
<AppList style={listStyle}>
|
||||
{collapsed && (
|
||||
{collapsed && onDashboard && (
|
||||
<AppItem
|
||||
href="/"
|
||||
onClick={onDashboard}
|
||||
selected={!selectedApp ? true : undefined}
|
||||
style={{ justifyContent: 'center', padding: '10px 12px' }}
|
||||
title="Toes"
|
||||
|
|
@ -58,9 +65,7 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
|
|||
{activeApps.map(app => (
|
||||
<AppItem
|
||||
key={app.name}
|
||||
href={`/app/${app.name}`}
|
||||
onClick={onSelect}
|
||||
large={large || undefined}
|
||||
onClick={() => selectApp(app.name)}
|
||||
selected={app.name === selectedApp ? true : undefined}
|
||||
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
|
||||
title={collapsed ? app.name : undefined}
|
||||
|
|
@ -69,7 +74,7 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
|
|||
<span style={{ fontSize: 18 }}>{app.icon}</span>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
|
||||
<span style={{ fontSize: 14 }}>{app.icon}</span>
|
||||
{app.name}
|
||||
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,47 +1,13 @@
|
|||
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 { apps, currentView, isNarrow, selectedApp, setupMode } from '../state'
|
||||
import { Layout } from '../styles'
|
||||
import { AppDetail } from './AppDetail'
|
||||
import { AppSelector } from './AppSelector'
|
||||
import { DashboardLanding } from './DashboardLanding'
|
||||
import { Modal } from './modal'
|
||||
import { SettingsPage } from './SettingsPage'
|
||||
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} />
|
||||
|
|
@ -52,8 +18,9 @@ export function Dashboard({ render }: { render: () => void }) {
|
|||
return (
|
||||
<Layout>
|
||||
<Styles />
|
||||
{!isNarrow && <Sidebar render={render} />}
|
||||
{!isNarrow && !setupMode && <Sidebar render={render} />}
|
||||
<MainContent render={render} />
|
||||
<Modal />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,36 @@
|
|||
import { useEffect } from 'hono/jsx'
|
||||
import { navigate } from '../router'
|
||||
import { apps, dashboardTab, isNarrow, setMobileSidebar } from '../state'
|
||||
import { openAppSelectorModal } from '../modals'
|
||||
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
|
||||
import {
|
||||
HamburgerButton,
|
||||
HamburgerLine,
|
||||
AppSelectorChevron,
|
||||
DashboardContainer,
|
||||
DashboardHeader,
|
||||
DashboardTitle,
|
||||
Section,
|
||||
SettingsGear,
|
||||
Tab,
|
||||
TabBar,
|
||||
TabContent,
|
||||
StatusDot,
|
||||
StatusDotLink,
|
||||
StatusDotsRow,
|
||||
} from '../styles'
|
||||
import { theme } from '../themes'
|
||||
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
|
||||
import { Urls } from './Urls'
|
||||
import { initVitals } from './Vitals'
|
||||
import { update } from '../update'
|
||||
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
|
||||
import { Vitals, initVitals } from './Vitals'
|
||||
|
||||
let activeTooltip: string | null = null
|
||||
|
||||
export function DashboardLanding({ render }: { render: () => void }) {
|
||||
useEffect(() => {
|
||||
initUnifiedLogs()
|
||||
initVitals()
|
||||
if (dashboardTab === 'logs') scrollLogsToBottom()
|
||||
}, [])
|
||||
|
||||
const narrow = isNarrow || undefined
|
||||
const dashboardTools = apps.filter(a => a.tool && a.dashboard)
|
||||
|
||||
const openSettings = () => {
|
||||
navigate('/settings')
|
||||
setSelectedApp(null)
|
||||
setCurrentView('settings')
|
||||
render()
|
||||
}
|
||||
|
||||
const switchTab = (tab: string) => {
|
||||
navigate(tab === 'urls' ? '/' : `/${tab}`)
|
||||
if (tab === 'logs') scrollLogsToBottom()
|
||||
}
|
||||
|
||||
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
|
||||
|
||||
return (
|
||||
<DashboardContainer narrow={narrow} relative>
|
||||
<SettingsGear
|
||||
|
|
@ -48,68 +40,43 @@ export function DashboardLanding({ render }: { render: () => void }) {
|
|||
>
|
||||
⚙️
|
||||
</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
|
||||
{isNarrow && (
|
||||
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
|
||||
▼
|
||||
</AppSelectorChevron>
|
||||
)}
|
||||
</DashboardTitle>
|
||||
</DashboardHeader>
|
||||
|
||||
<TabBar centered>
|
||||
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>🔗 URLs</Tab>
|
||||
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>📋 Logs</Tab>
|
||||
{dashboardTools.map(tool => {
|
||||
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
|
||||
return (
|
||||
<Tab
|
||||
key={tool.name}
|
||||
active={dashboardTab === tool.name || undefined}
|
||||
onClick={() => switchTab(tool.name)}
|
||||
>
|
||||
{tool.icon} {titlecase(toolName)}
|
||||
</Tab>
|
||||
)
|
||||
})}
|
||||
</TabBar>
|
||||
<StatusDotsRow>
|
||||
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
|
||||
<StatusDotLink
|
||||
key={app.name}
|
||||
data-tooltip={app.name}
|
||||
tooltipVisible={activeTooltip === app.name || undefined}
|
||||
onClick={(e: Event) => {
|
||||
e.preventDefault()
|
||||
if (isNarrow && activeTooltip !== app.name) {
|
||||
activeTooltip = app.name
|
||||
render()
|
||||
return
|
||||
}
|
||||
activeTooltip = null
|
||||
setSelectedApp(app.name)
|
||||
update()
|
||||
}}
|
||||
>
|
||||
<StatusDot state={app.state} data-app={app.name} />
|
||||
</StatusDotLink>
|
||||
))}
|
||||
</StatusDotsRow>
|
||||
|
||||
<TabContent active={dashboardTab === 'urls' || undefined}>
|
||||
<Urls render={render} />
|
||||
</TabContent>
|
||||
<Vitals />
|
||||
|
||||
<TabContent active={dashboardTab === 'logs' || undefined}>
|
||||
<UnifiedLogs />
|
||||
</TabContent>
|
||||
|
||||
{dashboardTools.map(tool => {
|
||||
const isSelected = dashboardTab === tool.name
|
||||
return (
|
||||
<TabContent key={tool.name} active={isSelected || undefined}>
|
||||
<Section>
|
||||
{tool.state !== 'running' && (
|
||||
<p style={{ color: theme('colors-textFaint') }}>
|
||||
Tool is {tool.state}
|
||||
</p>
|
||||
)}
|
||||
{tool.state === 'running' && (
|
||||
<div
|
||||
data-tool-target={isSelected ? tool.name : undefined}
|
||||
style={{ width: '100%', height: '600px' }}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</TabContent>
|
||||
)
|
||||
})}
|
||||
<UnifiedLogs />
|
||||
</DashboardContainer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user