Compare commits

..

4 Commits

Author SHA1 Message Date
baa3712fa2 Add getApp command and gitUrl helper 2026-03-01 14:57:39 -08:00
c0b48c03da update docs 2026-03-01 14:38:24 -08:00
d0290433f2 suuuuure 2026-03-01 13:35:39 -08:00
56db56976b re-do the whole thing on git 2026-03-01 13:29:01 -08:00
69 changed files with 362 additions and 1852 deletions

View File

@ -47,8 +47,8 @@ Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
### Server (`src/server/`) ### 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). - `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create), `DELETE /:name`, `PUT /:name/rename`, `PUT /:name/icon`. - `api/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/sync.ts` -- File sync protocol: manifest comparison, push/pull with hash-based diffing. - `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app reload (triggered by git tool after deploy), file watch SSE.
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps. - `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. - `shell.tsx` -- Minimal HTML shell for the SPA.
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY). - `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
@ -79,23 +79,22 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
CLI commands: CLI commands:
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm` - **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron` - **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`, `share`, `unshare`
- **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash` - **Config**: `env`
- **Config**: `config`, `env`, `versions`, `history`, `rollback`
### Shared (`src/shared/`) ### Shared (`src/shared/`)
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser). Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo` - `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`
- `gitignore.ts` -- `.toesignore` pattern matching - `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
### Lib (`src/lib/`) ### Lib (`src/lib/`)
Server-side code shared between CLI and server. Can use Node APIs. Server-side code shared between CLI and server. Can use Node APIs.
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa) - `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
- `sync.ts` -- Manifest generation, hash computation - `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
### Tools Package (`src/tools/`) ### Tools Package (`src/tools/`)
@ -126,7 +125,7 @@ Tools are apps with `"toes": { "tool": true }` in package.json. From the server'
### Versioning ### Versioning
Apps live at `APPS_DIR/<name>/` with timestamped version directories and a `current` symlink. Push creates a new version; rollback moves the symlink. Apps use git for version control. Each app has a bare git repo at `DATA_DIR/repos/<name>.git`. Deploying is a `git push` to the server's git tool, which extracts HEAD into `APPS_DIR/<name>/` and reloads the app. History, diffing, and rollback use standard git commands.
### Environment Variables ### Environment Variables
@ -215,4 +214,4 @@ function start(app: App): void {
## Writing Apps and Tools ## Writing Apps and Tools
See `docs/CLAUDE.md` for the guide to writing toes apps and tools. See `docs/GUIDE.md` for the guide to writing toes apps and tools.

View File

@ -691,7 +691,7 @@ watch(APPS_DIR, { recursive: true }, (_event, filename) => {
debounceTimer = setTimeout(rediscover, 100) debounceTimer = setTimeout(rediscover, 100)
}) })
on(['app:activate', 'app:delete'], (event) => { on(['app:reload', 'app:delete'], (event) => {
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`) console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
rediscover() rediscover()
}) })

View File

@ -2,7 +2,7 @@ import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge' import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme, on } from '@because/toes/tools' import { baseStyles, ToolScript, theme, on } from '@because/toes/tools'
import { mkdirSync } from 'fs' import { mkdirSync } from 'fs'
import { mkdir, readdir, readlink, rm, stat } from 'fs/promises' import { mkdir, readdir, rename, rm, stat } from 'fs/promises'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import type { Child } from 'hono/jsx' import type { Child } from 'hono/jsx'
@ -10,7 +10,6 @@ const APPS_DIR = process.env.APPS_DIR!
const DATA_DIR = process.env.DATA_DIR! const DATA_DIR = process.env.DATA_DIR!
const TOES_URL = process.env.TOES_URL! const TOES_URL = process.env.TOES_URL!
const MAX_VERSIONS = 5
const REPOS_DIR = resolve(DATA_DIR, 'repos') const REPOS_DIR = resolve(DATA_DIR, 'repos')
const VALID_NAME = /^[a-zA-Z0-9_-]+$/ const VALID_NAME = /^[a-zA-Z0-9_-]+$/
@ -120,74 +119,42 @@ interface RepoListPageProps {
const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`) const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)
const timestamp = () => {
const [date, time] = new Date().toISOString().slice(0, 19).split('T')
return `${date.replaceAll('-', '')}-${time.replaceAll(':', '')}`
}
// resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal // resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal
const validRepoName = (name: string) => const validRepoName = (name: string) =>
VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name) VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)
async function activateApp(name: string, version: string): Promise<string | null> { async function activateApp(name: string): Promise<string | null> {
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/activate?version=${version}`, { const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, {
method: 'POST', method: 'POST',
}) })
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
const msg = (body as Record<string, string>).error ?? `activate returned ${res.status}` const msg = (body as Record<string, string>).error ?? `reload returned ${res.status}`
console.error(`Activate failed for ${name}@${version}:`, msg) console.error(`Reload failed for ${name}:`, msg)
return msg return msg
} }
return null return null
} }
async function cleanOldVersions(appDir: string): Promise<void> { async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> {
if (!(await dirExists(appDir))) return
// Read the current symlink target so we never delete the active version
let current: string | null = null
try {
const target = await readlink(join(appDir, 'current'))
current = target.split('/').pop() ?? null
} catch {}
const entries = await readdir(appDir, { withFileTypes: true })
const versions = entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => e.name)
.sort()
if (versions.length <= MAX_VERSIONS) return
const toRemove = versions
.slice(0, versions.length - MAX_VERSIONS)
.filter(v => v !== current)
for (const dir of toRemove) {
await rm(join(appDir, dir), { recursive: true, force: true })
}
}
async function deploy(repoName: string): Promise<{ ok: boolean; error?: string; version?: string }> {
const bare = repoPath(repoName) const bare = repoPath(repoName)
if (!(await hasCommits(bare))) { if (!(await hasCommits(bare))) {
return { ok: false, error: 'No commits in repository' } return { ok: false, error: 'No commits in repository' }
} }
const ts = timestamp() // Validate in a temp dir before touching the real app dir
const appDir = join(APPS_DIR, repoName) const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`)
const versionDir = join(appDir, ts) await rm(tmpDir, { recursive: true, force: true })
await mkdir(tmpDir, { recursive: true })
await mkdir(versionDir, { recursive: true }) // Extract HEAD into the temp directory
// Extract HEAD into the version directory — no shell, pipe git archive into tar
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], { const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
}) })
const tar = Bun.spawn(['tar', '-x', '-C', versionDir], { const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], {
stdin: archive.stdout, stdin: archive.stdout,
stdout: 'ignore', stdout: 'ignore',
stderr: 'pipe', stderr: 'pipe',
@ -202,32 +169,37 @@ async function deploy(repoName: string): Promise<{ ok: boolean; error?: string;
]) ])
if (archiveExit !== 0 || tarExit !== 0) { if (archiveExit !== 0 || tarExit !== 0) {
await rm(versionDir, { recursive: true, force: true }) await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` } return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
} }
// Verify package.json with scripts.toes exists // Verify package.json with scripts.toes exists
const pkgPath = join(versionDir, 'package.json') const pkgPath = join(tmpDir, 'package.json')
if (!(await Bun.file(pkgPath).exists())) { if (!(await Bun.file(pkgPath).exists())) {
await rm(versionDir, { recursive: true, force: true }) await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'No package.json found in repository' } return { ok: false, error: 'No package.json found in repository' }
} }
try { try {
const pkg = JSON.parse(await Bun.file(pkgPath).text()) const pkg = JSON.parse(await Bun.file(pkgPath).text())
if (!pkg.scripts?.toes) { if (!pkg.scripts?.toes) {
await rm(versionDir, { recursive: true, force: true }) await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'package.json missing scripts.toes entry' } return { ok: false, error: 'package.json missing scripts.toes entry' }
} }
} catch { } catch {
await rm(versionDir, { recursive: true, force: true }) await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'Invalid package.json' } return { ok: false, error: 'Invalid package.json' }
} }
// Clean up old versions beyond MAX_VERSIONS // Stop the app before swapping directories
await cleanOldVersions(appDir) await stopIfRunning(repoName)
return { ok: true, version: ts } // 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. // Bun.file().exists() is for files only — it returns false for directories.
@ -400,6 +372,28 @@ function serviceHeader(service: string): Uint8Array {
return new TextEncoder().encode(header) return new TextEncoder().encode(header)
} }
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> { async function withDeployLock<T>(repo: string, fn: () => Promise<T>): Promise<T> {
const prev = deployLocks.get(repo) ?? Promise.resolve() const prev = deployLocks.get(repo) ?? Promise.resolve()
const { promise: lock, resolve: release } = Promise.withResolvers<void>() const { promise: lock, resolve: release } = Promise.withResolvers<void>()
@ -608,13 +602,13 @@ app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'],
const deployError = await withDeployLock(repoParam, async () => { const deployError = await withDeployLock(repoParam, async () => {
try { try {
const result = await deploy(repoParam) const result = await deploy(repoParam)
if (result.ok && result.version) { if (result.ok) {
const err = await activateApp(repoParam, result.version) const err = await activateApp(repoParam)
if (err) { if (err) {
console.error(`Activate failed for ${repoParam}: ${err}`) console.error(`Reload failed for ${repoParam}: ${err}`)
return `Deploy succeeded but activation failed: ${err}` return `Deploy succeeded but reload failed: ${err}`
} }
console.log(`Deployed ${repoParam}@${result.version}`) console.log(`Deployed ${repoParam}`)
return null return null
} }
console.error(`Deploy failed for ${repoParam}: ${result.error}`) console.error(`Deploy failed for ${repoParam}: ${result.error}`)

View File

@ -6,10 +6,8 @@ An app is an HTTP server that runs on its assigned port.
``` ```
apps/<name>/ apps/<name>/
<timestamp>/ # YYYYMMDD-HHMMSS
package.json package.json
index.tsx index.tsx
current -> <timestamp> # symlink to active version
``` ```
**package.json** must have `scripts.toes`: **package.json** must have `scripts.toes`:

View File

@ -9,7 +9,7 @@ The cron tool discovers jobs from all apps and runs them automatically.
Add a file to `cron/` in any app: Add a file to `cron/` in any app:
```ts ```ts
// apps/my-app/current/cron/daily-cleanup.ts // apps/my-app/cron/daily-cleanup.ts
export const schedule = "day" export const schedule = "day"
export default async function() { export default async function() {
@ -73,7 +73,7 @@ Jobs track:
## discovery ## discovery
The cron tool: The cron tool:
1. Scans `APPS_DIR/*/current/cron/*.ts` 1. Scans `APPS_DIR/*/cron/*.ts`
2. Imports each file to read `schedule` 2. Imports each file to read `schedule`
3. Validates the schedule 3. Validates the schedule
4. Registers with croner 4. Registers with croner

View File

@ -19,13 +19,11 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
- [CLI Reference](#cli-reference) - [CLI Reference](#cli-reference)
- [App Management](#app-management) - [App Management](#app-management)
- [Lifecycle](#lifecycle) - [Lifecycle](#lifecycle)
- [Syncing Code](#syncing-code) - [Deploying Code](#deploying-code)
- [Environment Variables](#environment-variables) - [Environment Variables](#environment-variables)
- [Versioning](#versioning)
- [Cron Jobs](#cron-jobs-1) - [Cron Jobs](#cron-jobs-1)
- [Metrics](#metrics) - [Metrics](#metrics)
- [Sharing](#sharing) - [Sharing](#sharing)
- [Configuration](#configuration)
- [Environment Variables](#environment-variables-1) - [Environment Variables](#environment-variables-1)
- [Health Checks](#health-checks) - [Health Checks](#health-checks)
- [App Lifecycle](#app-lifecycle) - [App Lifecycle](#app-lifecycle)
@ -40,7 +38,7 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
# Install the CLI # Install the CLI
curl -fsSL http://toes.local/install | bash curl -fsSL http://toes.local/install | bash
# Create a new app # Create a new app (scaffolds, inits git, and pushes to server)
toes new my-app toes new my-app
# Enter the directory, install deps, and develop locally # Enter the directory, install deps, and develop locally
@ -48,8 +46,9 @@ cd my-app
bun install bun install
bun dev bun dev
# Push to the server # Deploy changes (standard git)
toes push git add . && git commit -m "my changes"
git push toes main
# Open in browser # Open in browser
toes open toes open
@ -57,7 +56,7 @@ toes open
Your app is now running at `http://my-app.toes.local`. Your app is now running at `http://my-app.toes.local`.
> **Tip:** Add `.toes` to your `.gitignore`. This file tracks local sync state and shouldn't be committed. `toes new` automatically sets up a `toes` git remote pointing at the server. Pushing to it triggers a deploy.
--- ---
@ -85,8 +84,8 @@ A generated SSR app looks like this:
``` ```
my-app/ my-app/
.gitignore # Files to exclude from sync and deploy
.npmrc # Points to the private registry .npmrc # Points to the private registry
.toesignore # Files to exclude from sync (like .gitignore)
package.json # Must have scripts.toes package.json # Must have scripts.toes
tsconfig.json # TypeScript config tsconfig.json # TypeScript config
index.tsx # Entry point (re-exports from src/server) index.tsx # Entry point (re-exports from src/server)
@ -457,13 +456,11 @@ app.get('/', c => {
const appName = c.req.query('app') const appName = c.req.query('app')
if (!appName) return c.html(<p>No app selected</p>) if (!appName) return c.html(<p>No app selected</p>)
const appPath = join(APPS_DIR, appName, 'current') const appPath = join(APPS_DIR, appName)
// Read files from appPath... // Read files from appPath...
}) })
``` ```
Always go through the `current` symlink — never access version directories directly.
**Calling the Toes API:** **Calling the Toes API:**
```tsx ```tsx
@ -522,19 +519,21 @@ toes new my-app --bare # Minimal template
toes new my-app --spa # SPA template toes new my-app --spa # SPA template
``` ```
Creates the app locally, then pushes it to the server. If run without a name, scaffolds the current directory. Scaffolds the app locally, initializes a git repo with a `toes` remote pointing at the server, and pushes. The git push triggers a deploy. If run without a name, scaffolds the current directory.
**`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime). **`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime).
**`toes get <name>`** — Download an app from the server to your local machine. **`toes get <name>`** — Clone an app from the server to your local machine.
```bash ```bash
toes get my-app # Creates ./my-app/ with all files toes get my-app # Clones into ./my-app/
cd my-app cd my-app
bun install bun install
bun dev # Develop locally 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 open [name]`** — Open a running app in your browser.
**`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation. **`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation.
@ -562,54 +561,38 @@ toes logs my-app -f -g error # Follow and filter
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months). Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
### Syncing Code ### Deploying Code
Toes uses a manifest-based sync protocol. Each file is tracked by SHA-256 hash. The server stores versioned snapshots with timestamps. 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 push`** — Push local changes to the server.
```bash ```bash
toes push # Push changes (fails if server changed) # Make changes, commit, and deploy
toes push --force # Overwrite server changes git add .
git commit -m "update homepage"
git push toes main
``` ```
Creates a new version on the server, uploads changed files, deletes removed files, then activates the new version. The app auto-restarts. 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
**`toes pull`** — Pull changes from the server. Use standard git commands for history, diffing, and rollback:
```bash ```bash
toes pull # Pull changes (fails if you have local changes) git log # View deploy history
toes pull --force # Overwrite local changes git diff HEAD~1 # See what changed
git revert HEAD # Undo last deploy
git push toes main # Deploy the revert
``` ```
**`toes status`** — Show what would be pushed or pulled. To clone an existing app from the server:
```bash ```bash
toes status git clone http://git.toes.local/my-app
# Changes to push: cd my-app
# * index.tsx bun install
# + new-file.ts bun dev # Develop locally
# - 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 ### Environment Variables
@ -640,26 +623,12 @@ toes env rm -g API_KEY # Remove global var
### Versioning ### Versioning
Every push creates a timestamped version. The server keeps the last 5 versions. Every `git push toes main` creates a new deploy. Version history is managed through git.
**`toes versions [name]`** — List deployed versions.
```bash ```bash
toes versions my-app git log --oneline # List deploys
# Versions for my-app: git revert HEAD # Undo last change
# git push toes main # Deploy the revert
# → 20260219-143022 2/19/2026, 2:30:22 PM (current)
# 20260218-091500 2/18/2026, 9:15:00 AM
# 20260217-160845 2/17/2026, 4:08:45 PM
```
**`toes history [name]`** — Show file changes between versions.
**`toes rollback [name]`** — Rollback to a previous version.
```bash
toes rollback my-app # Interactive version picker
toes rollback my-app -v 20260218-091500 # Rollback to specific version
``` ```
### Cron Jobs ### Cron Jobs
@ -717,10 +686,6 @@ toes share my-app
**`toes unshare [name]`** — Stop sharing an app. **`toes unshare [name]`** — Stop sharing an app.
### Configuration
**`toes config`** — Show the current server URL and sync state.
--- ---
## Environment Variables ## Environment Variables

View File

@ -44,21 +44,17 @@ app.get('/', c => {
## accessing app files ## accessing app files
Always go through the `current` symlink:
```ts ```ts
const APPS_DIR = process.env.APPS_DIR! const APPS_DIR = process.env.APPS_DIR!
const appPath = join(APPS_DIR, appName, 'current') const appPath = join(APPS_DIR, appName)
``` ```
Not `APPS_DIR/appName` directly.
## linking to tools ## linking to tools
Use `/tool/:name` URLs to link directly to tools with params: Use `/tool/:name` URLs to link directly to tools with params:
```html ```html
<a href="/tool/code?app=my-app&version=20260130-000000"> <a href="/tool/code?app=my-app">
View in Code View in Code
</a> </a>
``` ```

View File

@ -26,6 +26,7 @@
"debug": "DEBUG=1 bun run dev", "debug": "DEBUG=1 bun run dev",
"dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx", "dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
"remote:deploy": "./scripts/deploy.sh", "remote:deploy": "./scripts/deploy.sh",
"remote:migrate": "bun run scripts/migrate.ts",
"remote:install": "./scripts/remote-install.sh", "remote:install": "./scripts/remote-install.sh",
"remote:logs": "./scripts/remote-logs.sh", "remote:logs": "./scripts/remote-logs.sh",
"remote:restart": "./scripts/remote-restart.sh", "remote:restart": "./scripts/remote-restart.sh",

View File

@ -16,23 +16,46 @@ set -e
DEST="${DEST:-$HOME/toes}" DEST="${DEST:-$HOME/toes}"
APPS_DIR="${APPS_DIR:-$HOME/apps}" APPS_DIR="${APPS_DIR:-$HOME/apps}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
REPOS_DIR="$DATA_DIR/repos"
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
echo "=> Migrating apps to flat structure..."
bun run scripts/migrate.ts
echo "=> Syncing default apps..." echo "=> Syncing default apps..."
for app_dir in "$DEST"/apps/*/; do for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir") app=$(basename "$app_dir")
for version_dir in "$app_dir"*/; do [ -f "$app_dir/package.json" ] || continue
[ -d "$version_dir" ] || continue target="$APPS_DIR/$app"
version=$(basename "$version_dir")
[ -f "$version_dir/package.json" ] || continue
target="$APPS_DIR/$app/$version"
mkdir -p "$target" mkdir -p "$target"
cp -a "$version_dir"/. "$target"/ cp -a "$app_dir"/. "$target"/
rm -f "$APPS_DIR/$app/current" echo " $app"
echo " $app/$version"
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install) (cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
done done
echo "=> Initializing bare repos for shipped apps..."
mkdir -p "$REPOS_DIR"
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
bare="$REPOS_DIR/$app.git"
if [ ! -d "$bare" ]; then
git init --bare "$bare" > /dev/null
git -C "$bare" symbolic-ref HEAD refs/heads/main
git -C "$bare" config http.receivepack true
fi
tmp=$(mktemp -d)
cp -a "$app_dir"/. "$tmp"/
git -C "$tmp" init -b main > /dev/null 2>&1
git -C "$tmp" add -A > /dev/null
git -C "$tmp" -c user.name=toes -c user.email=toes@localhost commit -m "deploy" > /dev/null 2>&1
git -C "$tmp" push --force "$bare" main > /dev/null 2>&1
rm -rf "$tmp"
echo " $app"
done done
sudo systemctl restart toes.service sudo systemctl restart toes.service

111
scripts/migrate.ts Normal file
View File

@ -0,0 +1,111 @@
#!/usr/bin/env bun
// Migration script: converts apps from versioned directory structure
// (apps/<name>/<timestamp>/ with `current` symlink) to flat structure (apps/<name>/).
//
// Usage: bun run remote:migrate
//
// What it does:
// 1. Scans APPS_DIR for apps with a `current` symlink
// 2. Resolves the symlink to find the active version directory
// 3. Moves files from the active version into the app root (preserving node_modules/logs)
// 4. Removes old version directories and the symlink
//
// Safe to run multiple times -- skips apps already in flat structure.
import { existsSync, mkdirSync, readdirSync, renameSync, lstatSync, readlinkSync, rmSync } from 'fs'
import { join, resolve } from 'path'
const APPS_DIR = process.env.APPS_DIR ?? join(process.env.HOME!, 'apps')
const VERSION_RE = /^\d{8}-\d{6}$/
if (!existsSync(APPS_DIR)) {
console.error(`APPS_DIR does not exist: ${APPS_DIR}`)
process.exit(1)
}
const apps = readdirSync(APPS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
let migrated = 0
let skipped = 0
for (const name of apps) {
const appDir = join(APPS_DIR, name)
const currentLink = join(appDir, 'current')
// Already flat -- has package.json at root with no current symlink
if (!existsSync(currentLink)) {
if (existsSync(join(appDir, 'package.json'))) {
skipped++
}
continue
}
// Verify it's actually a symlink
let stat
try {
stat = lstatSync(currentLink)
} catch {
continue
}
if (!stat.isSymbolicLink()) {
console.warn(` [skip] ${name}: 'current' exists but is not a symlink`)
skipped++
continue
}
// Resolve the symlink to get the active version directory
const target = readlinkSync(currentLink)
const activeDir = resolve(appDir, target)
if (!existsSync(activeDir)) {
console.warn(` [skip] ${name}: symlink target does not exist: ${target}`)
skipped++
continue
}
console.log(` [migrate] ${name} (active version: ${target})`)
// Collect all version directories and other entries
const entries = readdirSync(appDir, { withFileTypes: true })
const versionDirs = entries
.filter(e => e.isDirectory() && VERSION_RE.test(e.name) && e.name !== target)
.map(e => e.name)
// Remove old (non-active) version directories first
for (const ver of versionDirs) {
const verPath = join(appDir, ver)
rmSync(verPath, { recursive: true, force: true })
console.log(` removed old version: ${ver}`)
}
// Remove the current symlink
rmSync(currentLink)
// Move files from active version directory into app root
const activeEntries = readdirSync(activeDir)
for (const entry of activeEntries) {
const src = join(activeDir, entry)
const dest = join(appDir, entry)
// Skip if destination already exists (e.g. node_modules, logs at root level)
if (existsSync(dest)) {
console.log(` skip (exists): ${entry}`)
continue
}
renameSync(src, dest)
}
// Clean up the now-empty active version directory
rmSync(activeDir, { recursive: true, force: true })
migrated++
console.log(` done`)
}
console.log()
console.log(`Migration complete: ${migrated} migrated, ${skipped} skipped`)

View File

@ -2,7 +2,7 @@ export { cronList, cronLog, cronRun, cronStatus } from './cron'
export { envList, envRm, envSet } from './env' export { envList, envRm, envSet } from './env'
export { logApp } from './logs' export { logApp } from './logs'
export { export {
configShow, getApp,
infoApp, infoApp,
listApps, listApps,
newApp, newApp,
@ -16,4 +16,3 @@ export {
unshareApp, unshareApp,
} from './manage' } from './manage'
export { metricsApp } from './metrics' export { metricsApp } from './metrics'
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'

View File

@ -1,14 +1,12 @@
import type { App } from '@types' import type { App } from '@types'
import { generateTemplates, type TemplateType } from '%templates' import { generateTemplates, type TemplateType } from '%templates'
import { readSyncState } from '%sync'
import color from 'kleur' import color from 'kleur'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { basename, join } from 'path' import { basename, join } from 'path'
import { buildAppUrl } from '@urls' import { buildAppUrl } from '@urls'
import { del, get, getManifest, HOST, post } from '../http' import { del, get, getManifest, gitUrl, HOST, post } from '../http'
import { confirm, prompt } from '../prompts' import { confirm, prompt } from '../prompts'
import { resolveAppName } from '../name' import { resolveAppName } from '../name'
import { pushApp } from './sync'
export const STATE_ICONS: Record<string, string> = { export const STATE_ICONS: Record<string, string> = {
error: color.red('●'), error: color.red('●'),
@ -36,15 +34,6 @@ async function waitForState(name: string, target: string, timeout: number): Prom
return app?.state 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) { export async function infoApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return if (!name) return
@ -184,17 +173,42 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
writeFileSync(join(appPath, filename), content) writeFileSync(join(appPath, filename), content)
} }
process.chdir(appPath) // Initialize git repo and push to server (git push creates the app via the git tool)
await pushApp() 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'])
console.log(color.green(`✓ Created ${appName}`)) console.log(color.green(`✓ Created ${appName}`))
console.log()
console.log('Next steps:')
if (name) { if (name) {
console.log(` cd ${name}`) console.log(`\n cd ${name}`)
} }
console.log(' bun install') }
console.log(' bun dev')
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`)
} }
export async function openApp(arg?: string) { export async function openApp(arg?: string) {

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import type { Manifest } from '@types' import type { Manifest } from '@types'
import { buildAppUrl } from '@urls'
import { AsyncLocalStorage } from 'node:async_hooks' import { AsyncLocalStorage } from 'node:async_hooks'
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local' const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
@ -20,6 +21,8 @@ export const HOST = process.env.TOES_URL
? normalizeUrl(process.env.TOES_URL) ? normalizeUrl(process.env.TOES_URL)
: DEFAULT_HOST : DEFAULT_HOST
export const gitUrl = (name: string) => `${buildAppUrl('git', HOST)}/${name}`
export const getSignal = () => signalStore.getStore() export const getSignal = () => signalStore.getStore()
export function withSignal<T>(signal: AbortSignal, fn: () => T): T { export function withSignal<T>(signal: AbortSignal, fn: () => T): T {
@ -57,14 +60,13 @@ export async function get<T>(url: string): Promise<T | undefined> {
} }
} }
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> { export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> {
try { try {
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() }) const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() })
if (res.status === 404) return { exists: false } if (res.status === 404) return { exists: false }
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const data = await res.json() const manifest = await res.json()
const { version, ...manifest } = data return { exists: true, manifest }
return { exists: true, manifest, version }
} catch (error) { } catch (error) {
handleError(error) handleError(error)
return null return null

View File

@ -3,42 +3,28 @@ import { program } from 'commander'
import color from 'kleur' import color from 'kleur'
import pkg from '../../package.json' import pkg from '../../package.json'
import { withPager } from './pager'
import { import {
cleanApp,
configShow,
cronList, cronList,
cronLog, cronLog,
cronRun, cronRun,
cronStatus, cronStatus,
diffApp,
envList, envList,
envRm, envRm,
envSet, envSet,
getApp, getApp,
historyApp,
infoApp, infoApp,
listApps, listApps,
logApp, logApp,
newApp, newApp,
openApp, openApp,
pullApp,
pushApp,
renameApp, renameApp,
restartApp, restartApp,
rmApp, rmApp,
rollbackApp, shareApp,
stashApp,
stashListApp,
stashPopApp,
startApp, startApp,
metricsApp, metricsApp,
shareApp,
statusApp,
stopApp, stopApp,
syncApp,
unshareApp, unshareApp,
versionsApp,
} from './commands' } from './commands'
program program
@ -93,8 +79,9 @@ program
program program
.command('get') .command('get')
.helpGroup('Apps:') .helpGroup('Apps:')
.description('Download an app from server') .description('Clone an app from the server')
.argument('<name>', 'app name') .argument('<name>', 'app name')
.argument('[directory]', 'target directory (defaults to app name)')
.action(getApp) .action(getApp)
program program
@ -209,72 +196,8 @@ cron
.argument('<job>', 'job identifier (app:name)') .argument('<job>', 'job identifier (app:name)')
.action(cronRun) .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 // Config
program
.command('config')
.helpGroup('Config:')
.description('Show current host configuration')
.action(configShow)
const env = program const env = program
.command('env') .command('env')
.helpGroup('Config:') .helpGroup('Config:')
@ -300,28 +223,6 @@ env
.option('-g, --global', 'remove a global variable') .option('-g, --global', 'remove a global variable')
.action(envRm) .action(envRm)
program
.command('versions')
.helpGroup('Config:')
.description('List deployed versions')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(versionsApp)
program
.command('history')
.helpGroup('Config:')
.description('Show file changes between versions')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(historyApp)
program
.command('rollback')
.helpGroup('Config:')
.description('Rollback to a previous version')
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
.action((name, options) => rollbackApp(name, options.version))
// Shell // Shell
program program

View File

@ -59,8 +59,8 @@ async function fetchAppNames(): Promise<string[]> {
function getCommandNames(): string[] { function getCommandNames(): string[] {
return program.commands return program.commands
.filter((cmd: { _hidden?: boolean }) => !cmd._hidden) .filter((cmd) => !(cmd as any)._hidden)
.map((cmd: { name: () => string }) => cmd.name()) .map((cmd) => cmd.name())
} }
async function printBanner(): Promise<void> { async function printBanner(): Promise<void> {

View File

@ -3,27 +3,9 @@ export type { FileInfo, Manifest } from '@types'
import type { FileInfo, Manifest } from '@types' import type { FileInfo, Manifest } from '@types'
import { loadGitignore } from '@gitignore' import { loadGitignore } from '@gitignore'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs' import { readdirSync, readFileSync, statSync } from 'fs'
import { join, relative } from 'path' import { join, relative } from 'path'
export interface SyncState {
version: string
}
export function readSyncState(appPath: string): SyncState | null {
const filePath = join(appPath, '.toes')
if (!existsSync(filePath)) return null
try {
return JSON.parse(readFileSync(filePath, 'utf-8'))
} catch {
return null
}
}
export function writeSyncState(appPath: string, state: SyncState): void {
writeFileSync(join(appPath, '.toes'), JSON.stringify(state, null, 2))
}
export function computeHash(content: Buffer | string): string { export function computeHash(content: Buffer | string): string {
return createHash('sha256').update(content).digest('hex') return createHash('sha256').update(content).digest('hex')
} }

View File

@ -1,17 +1,14 @@
import { APPS_DIR, TOES_DIR, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps' import { APPS_DIR, TOES_DIR, TOES_URL, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
import { buildAppUrl } from '@urls'
import { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels' import { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels'
import type { App as BackendApp } from '$apps' import type { App as BackendApp } from '$apps'
import type { App as SharedApp } from '@types' import type { App as SharedApp } from '@types'
import { generateTemplates, type TemplateType } from '%templates' import { generateTemplates, type TemplateType } from '%templates'
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { dirname, join } from 'path' import { dirname, join } from 'path'
const timestamp = () => { const gitUrl = (name: string) => `${buildAppUrl('git', TOES_URL)}/${name}`
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
}
const router = Hype.router() const router = Hype.router()
@ -131,14 +128,12 @@ router.post('/', async c => {
const template = body.template ?? 'ssr' const template = body.template ?? 'ssr'
const templates = generateTemplates(name, template, { tool: body.tool }) const templates = generateTemplates(name, template, { tool: body.tool })
// Create versioned directory structure // Write templates to a temp dir, init git, and push to the git tool.
const ts = timestamp() // The git push triggers deploy + activate which registers and starts the app.
const versionPath = join(appPath, ts) const tmpDir = join(APPS_DIR, `.${name}-init-tmp`)
const currentPath = join(appPath, 'current') try {
// Create directories and write files into version directory
for (const [filename, content] of Object.entries(templates)) { for (const [filename, content] of Object.entries(templates)) {
const fullPath = join(versionPath, filename) const fullPath = join(tmpDir, filename)
const dir = dirname(fullPath) const dir = dirname(fullPath)
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }) mkdirSync(dir, { recursive: true })
@ -146,13 +141,22 @@ router.post('/', async c => {
writeFileSync(fullPath, content) writeFileSync(fullPath, content)
} }
// Create current symlink const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: tmpDir, stdout: 'ignore', stderr: 'ignore' }).exited
symlinkSync(ts, currentPath)
// Register and start the app await run(['git', 'init'])
registerApp(name) await run(['git', 'add', '.'])
await run(['git', 'commit', '-m', 'init'])
await run(['git', 'remote', 'add', 'toes', gitUrl(name)])
const exitCode = await run(['git', 'push', 'toes', 'main'])
if (exitCode !== 0) {
return c.json({ ok: false, error: 'Failed to push to git' }, 500)
}
return c.json({ ok: true, name }) return c.json({ ok: true, name })
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
}) })
router.sse('/:app/logs/stream', (send, c) => { router.sse('/:app/logs/stream', (send, c) => {

View File

@ -1,12 +1,10 @@
import { APPS_DIR, allApps, emit, registerApp, removeApp, restartApp, startApp } from '$apps' import { APPS_DIR, allApps, emit, registerApp, removeApp, restartApp, startApp } from '$apps'
import { computeHash, generateManifest } from '../sync' import { computeHash, generateManifest } from '../sync'
import { loadGitignore } from '@gitignore' import { loadGitignore } from '@gitignore'
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, watch, writeFileSync } from 'fs' import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
const MAX_VERSIONS = 5
interface FileChangeEvent { interface FileChangeEvent {
hash?: string hash?: string
path: string path: string
@ -14,7 +12,6 @@ interface FileChangeEvent {
} }
function safePath(base: string, ...parts: string[]): string | null { function safePath(base: string, ...parts: string[]): string | null {
// Resolve base to canonical path (follows symlinks) if it exists
const canonicalBase = existsSync(base) ? realpathSync(base) : base const canonicalBase = existsSync(base) ? realpathSync(base) : base
const resolved = join(canonicalBase, ...parts) const resolved = join(canonicalBase, ...parts)
@ -29,129 +26,18 @@ const router = Hype.router()
router.get('/apps', c => c.json(allApps().map(a => a.name))) router.get('/apps', c => c.json(allApps().map(a => a.name)))
router.get('/apps/:app/versions', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const appDir = safePath(APPS_DIR, appName)
if (!appDir) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404)
const currentLink = join(appDir, 'current')
const currentVersion = existsSync(currentLink)
? realpathSync(currentLink).split('/').pop()
: null
const entries = readdirSync(appDir, { withFileTypes: true })
const versions = entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => e.name)
.sort()
.reverse() // Newest first
return c.json({ versions, current: currentVersion })
})
router.get('/apps/:app/history', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const appDir = safePath(APPS_DIR, appName)
if (!appDir) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404)
const currentLink = join(appDir, 'current')
const currentVersion = existsSync(currentLink)
? realpathSync(currentLink).split('/').pop()
: null
const entries = readdirSync(appDir, { withFileTypes: true })
const versions = entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => e.name)
.sort()
.reverse() // Newest first
// Generate manifests for each version
const manifests = new Map<string, Record<string, { hash: string }>>()
for (const version of versions) {
const versionPath = join(appDir, version)
const manifest = generateManifest(versionPath, appName)
manifests.set(version, manifest.files)
}
// Diff consecutive pairs
const history = versions.map((version, i) => {
const current = version === currentVersion
const files = manifests.get(version)!
const olderVersion = versions[i + 1]
const olderFiles = olderVersion ? manifests.get(olderVersion)! : {}
const added: string[] = []
const modified: string[] = []
const deleted: string[] = []
// Files in this version
for (const [path, info] of Object.entries(files)) {
const older = olderFiles[path]
if (!older) {
added.push(path)
} else if (older.hash !== info.hash) {
modified.push(path)
}
}
// Files removed in this version
for (const path of Object.keys(olderFiles)) {
if (!files[path]) {
deleted.push(path)
}
}
// Detect renames: added + deleted files with matching hashes
const renamed: string[] = []
const deletedByHash = new Map<string, string>()
for (const path of deleted) {
deletedByHash.set(olderFiles[path]!.hash, path)
}
const matchedAdded = new Set<string>()
const matchedDeleted = new Set<string>()
for (const path of added) {
const oldPath = deletedByHash.get(files[path]!.hash)
if (oldPath && !matchedDeleted.has(oldPath)) {
renamed.push(`${oldPath}${path}`)
matchedAdded.add(path)
matchedDeleted.add(oldPath)
}
}
return {
version,
current,
added: added.filter(f => !matchedAdded.has(f)).sort(),
modified: modified.sort(),
deleted: deleted.filter(f => !matchedDeleted.has(f)).sort(),
renamed: renamed.sort(),
}
})
return c.json({ history })
})
router.get('/apps/:app/manifest', c => { router.get('/apps/:app/manifest', c => {
const appName = c.req.param('app') const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404) if (!appName) return c.json({ error: 'App not found' }, 404)
const appPath = join(APPS_DIR, appName, 'current') const appPath = join(APPS_DIR, appName)
const safeAppPath = safePath(APPS_DIR, appName) const safeAppPath = safePath(APPS_DIR, appName)
if (!safeAppPath) return c.json({ error: 'Invalid path' }, 400) if (!safeAppPath) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404) if (!existsSync(join(appPath, 'package.json'))) return c.json({ error: 'App not found' }, 404)
const version = realpathSync(appPath).split('/').pop()
const manifest = generateManifest(appPath, appName) const manifest = generateManifest(appPath, appName)
return c.json({ ...manifest, version }) return c.json(manifest)
}) })
router.get('/apps/:app/files/:path{.+}', c => { router.get('/apps/:app/files/:path{.+}', c => {
@ -160,7 +46,7 @@ router.get('/apps/:app/files/:path{.+}', c => {
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
const basePath = join(APPS_DIR, appName, 'current') const basePath = join(APPS_DIR, appName)
const fullPath = safePath(basePath, filePath) const fullPath = safePath(basePath, filePath)
if (!fullPath) return c.json({ error: 'Invalid path' }, 400) if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
@ -175,14 +61,10 @@ router.get('/apps/:app/files/:path{.+}', c => {
router.put('/apps/:app/files/:path{.+}', async c => { router.put('/apps/:app/files/:path{.+}', async c => {
const appName = c.req.param('app') const appName = c.req.param('app')
const filePath = c.req.param('path') const filePath = c.req.param('path')
const version = c.req.query('version') // Optional version parameter
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
// Determine base path: specific version or current const basePath = join(APPS_DIR, appName)
const basePath = version
? join(APPS_DIR, appName, version)
: join(APPS_DIR, appName, 'current')
const fullPath = safePath(basePath, filePath) const fullPath = safePath(basePath, filePath)
if (!fullPath) return c.json({ error: 'Invalid path' }, 400) if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
@ -216,13 +98,10 @@ router.delete('/apps/:app', c => {
router.delete('/apps/:app/files/:path{.+}', c => { router.delete('/apps/:app/files/:path{.+}', c => {
const appName = c.req.param('app') const appName = c.req.param('app')
const filePath = c.req.param('path') const filePath = c.req.param('path')
const version = c.req.query('version')
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
const basePath = version const basePath = join(APPS_DIR, appName)
? join(APPS_DIR, appName, version)
: join(APPS_DIR, appName, 'current')
const fullPath = safePath(basePath, filePath) const fullPath = safePath(basePath, filePath)
if (!fullPath) return c.json({ error: 'Invalid path' }, 400) if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
@ -232,105 +111,11 @@ router.delete('/apps/:app/files/:path{.+}', c => {
return c.json({ ok: true }) return c.json({ ok: true })
}) })
router.post('/apps/:app/deploy', c => { router.post('/apps/:app/reload', async c => {
const appName = c.req.param('app') const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App name required' }, 400) if (!appName) return c.json({ error: 'App name required' }, 400)
const appDir = join(APPS_DIR, appName) emit({ type: 'app:reload', app: appName })
// Generate timestamp: YYYYMMDD-HHMMSS format
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
const newVersion = join(appDir, timestamp)
const currentLink = join(appDir, 'current')
try {
// 1. If current exists, copy it to new timestamp
const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null
if (currentReal) {
cpSync(currentReal, newVersion, {
recursive: true,
filter: (src) => !src.split('/').includes('node_modules'),
})
} else {
// First deployment - create directory
mkdirSync(newVersion, { recursive: true })
}
return c.json({ ok: true, version: timestamp })
} catch (e) {
return c.json({ error: `Failed to create deployment: ${e instanceof Error ? e.message : String(e)}` }, 500)
}
})
router.post('/apps/:app/activate', async c => {
const appName = c.req.param('app')
const version = c.req.query('version')
if (!appName) return c.json({ error: 'App name required' }, 400)
if (!version) return c.json({ error: 'Version required' }, 400)
const appDir = join(APPS_DIR, appName)
const versionDir = join(appDir, version)
const currentLink = join(appDir, 'current')
if (!existsSync(versionDir)) {
return c.json({ error: 'Version not found' }, 404)
}
try {
// Atomic symlink update
const tempLink = join(appDir, '.current.tmp')
// Remove temp link if it exists from previous failed attempt
if (existsSync(tempLink)) {
unlinkSync(tempLink)
}
// Create new symlink pointing to version directory (relative)
symlinkSync(version, tempLink, 'dir')
// Atomic rename over old symlink/directory
renameSync(tempLink, currentLink)
// Clean up old versions
try {
const entries = readdirSync(appDir, { withFileTypes: true })
// Get all timestamp directories (exclude current, .current.tmp, etc)
const versionDirs = entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => e.name)
.sort()
.reverse() // Newest first
// Keep most recent, delete the rest
const toDelete = versionDirs.slice(MAX_VERSIONS)
for (const dir of toDelete) {
const dirPath = join(appDir, dir)
rmSync(dirPath, { recursive: true, force: true })
console.log(`Cleaned up old version: ${dir}`)
}
// Delete node_modules from old kept versions (not the active one)
const toKeep = versionDirs.slice(0, MAX_VERSIONS)
for (const dir of toKeep) {
if (dir === version) continue
const nm = join(appDir, dir, 'node_modules')
if (existsSync(nm)) {
rmSync(nm, { recursive: true, force: true })
console.log(`Removed node_modules from old version: ${dir}`)
}
}
} catch (e) {
// Log but don't fail activation if cleanup fails
console.error(`Failed to clean up old versions: ${e}`)
}
emit({ type: 'app:activate', app: appName, version })
// Register new app or restart existing // Register new app or restart existing
const app = allApps().find(a => a.name === appName) const app = allApps().find(a => a.name === appName)
@ -345,35 +130,29 @@ router.post('/apps/:app/activate', async c => {
return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500) return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500)
} }
} else if (app.state === 'stopped' || app.state === 'invalid') { } else if (app.state === 'stopped' || app.state === 'invalid') {
// App not running (possibly due to error) - try to start it // App not running - try to start it
startApp(appName) startApp(appName)
} }
return c.json({ ok: true }) return c.json({ ok: true })
} catch (e) {
return c.json({ error: `Failed to activate version: ${e instanceof Error ? e.message : String(e)}` }, 500)
}
}) })
router.sse('/apps/:app/watch', (send, c) => { router.sse('/apps/:app/watch', (send, c) => {
const appName = c.req.param('app') const appName = c.req.param('app')
const appPath = join(APPS_DIR, appName, 'current') const appPath = join(APPS_DIR, appName)
const safeAppPath = safePath(APPS_DIR, appName) const safeAppPath = safePath(APPS_DIR, appName)
if (!safeAppPath || !existsSync(appPath)) return if (!safeAppPath || !existsSync(appPath)) return
// Resolve to canonical path for consistent watch events const gitignore = loadGitignore(appPath)
const canonicalPath = realpathSync(appPath)
const gitignore = loadGitignore(canonicalPath)
let debounceTimer: Timer | null = null let debounceTimer: Timer | null = null
const pendingChanges = new Map<string, 'change' | 'delete'>() const pendingChanges = new Map<string, 'change' | 'delete'>()
const watcher = watch(canonicalPath, { recursive: true }, (_event, filename) => { const watcher = watch(appPath, { recursive: true }, (_event, filename) => {
if (!filename || gitignore.shouldExclude(filename)) return if (!filename || gitignore.shouldExclude(filename)) return
const fullPath = join(canonicalPath, filename) const fullPath = join(appPath, filename)
const type = existsSync(fullPath) ? 'change' : 'delete' const type = existsSync(fullPath) ? 'change' : 'delete'
pendingChanges.set(filename, type) pendingChanges.set(filename, type)
@ -383,7 +162,7 @@ router.sse('/apps/:app/watch', (send, c) => {
const evt: FileChangeEvent = { type: changeType, path } const evt: FileChangeEvent = { type: changeType, path }
if (changeType === 'change') { if (changeType === 'change') {
try { try {
const content = readFileSync(join(canonicalPath, path)) const content = readFileSync(join(appPath, path))
evt.hash = computeHash(content) evt.hash = computeHash(content)
} catch { } catch {
continue // File was deleted between check and read continue // File was deleted between check and read

View File

@ -3,7 +3,7 @@ import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events'
import type { Subprocess } from 'bun' import type { Subprocess } from 'bun'
import { DEFAULT_EMOJI } from '@types' import { DEFAULT_EMOJI } from '@types'
import { buildAppUrl, toSubdomain } from '@urls' import { buildAppUrl, toSubdomain } from '@urls'
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs' import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'
import { LOCAL_HOST } from '%config' import { LOCAL_HOST } from '%config'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { loadAppEnv } from '../tools/env' import { loadAppEnv } from '../tools/env'
@ -108,7 +108,6 @@ export async function initApps() {
initPortPool() initPortPool()
setupShutdownHandlers() setupShutdownHandlers()
rotateLogs() rotateLogs()
createAppSymlinks()
discoverApps() discoverApps()
runApps() runApps()
} }
@ -339,42 +338,11 @@ export const update = () => {
function allAppDirs() { function allAppDirs() {
return readdirSync(APPS_DIR, { withFileTypes: true }) return readdirSync(APPS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory() && existsSync(join(APPS_DIR, e.name, 'current'))) .filter(e => e.isDirectory() && existsSync(join(APPS_DIR, e.name, 'package.json')))
.map(e => e.name) .map(e => e.name)
.sort() .sort()
} }
function createAppSymlinks() {
for (const app of readdirSync(APPS_DIR, { withFileTypes: true })) {
if (!app.isDirectory()) continue
const appDir = join(APPS_DIR, app.name)
const currentPath = join(appDir, 'current')
if (existsSync(currentPath)) continue
// Find valid version directories
const versions = readdirSync(appDir, { withFileTypes: true })
.filter(e => {
if (!e.isDirectory()) return false
const pkgPath = join(appDir, e.name, 'package.json')
if (!existsSync(pkgPath)) return false
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
return !!pkg.scripts?.toes
} catch {
return false
}
})
.map(e => e.name)
.sort()
.reverse()
const latest = versions[0]
if (latest) {
symlinkSync(latest, currentPath)
}
}
}
function discoverApps() { function discoverApps() {
for (const dir of allAppDirs()) { for (const dir of allAppDirs()) {
const { pkg, error } = loadApp(dir) const { pkg, error } = loadApp(dir)
@ -553,7 +521,7 @@ function markAsRunning(app: App, port: number) {
function loadApp(dir: string): LoadResult { function loadApp(dir: string): LoadResult {
try { try {
const pkgPath = join(APPS_DIR, dir, 'current', 'package.json') const pkgPath = join(APPS_DIR, dir, 'package.json')
const file = readFileSync(pkgPath, 'utf-8') const file = readFileSync(pkgPath, 'utf-8')
try { try {
@ -637,9 +605,7 @@ async function runApp(dir: string, port: number) {
} }
}, STARTUP_TIMEOUT) }, STARTUP_TIMEOUT)
// Resolve symlink to actual timestamp directory const cwd = join(APPS_DIR, dir)
const currentLink = join(APPS_DIR, dir, 'current')
const cwd = realpathSync(currentLink)
const needsInstall = !existsSync(join(cwd, 'node_modules')) const needsInstall = !existsSync(join(cwd, 'node_modules'))
if (needsInstall) info(app, 'Installing dependencies...') if (needsInstall) info(app, 'Installing dependencies...')
@ -769,7 +735,7 @@ async function runApp(dir: string, port: number) {
} }
function saveApp(dir: string, pkg: any) { function saveApp(dir: string, pkg: any) {
const path = join(APPS_DIR, dir, 'current', 'package.json') const path = join(APPS_DIR, dir, 'package.json')
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n') writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
} }

View File

@ -1,4 +1,4 @@
export type ToesEventType = 'app:activate' | 'app:create' | 'app:delete' | 'app:start' | 'app:stop' export type ToesEventType = 'app:create' | 'app:delete' | 'app:reload' | 'app:start' | 'app:stop'
interface BaseEvent { interface BaseEvent {
app: string app: string
@ -6,9 +6,9 @@ interface BaseEvent {
} }
export type ToesEvent = export type ToesEvent =
| BaseEvent & { type: 'app:activate'; version: string }
| BaseEvent & { type: 'app:create' } | BaseEvent & { type: 'app:create' }
| BaseEvent & { type: 'app:delete' } | BaseEvent & { type: 'app:delete' }
| BaseEvent & { type: 'app:reload' }
| BaseEvent & { type: 'app:start' } | BaseEvent & { type: 'app:start' }
| BaseEvent & { type: 'app:stop' } | BaseEvent & { type: 'app:stop' }