320 lines
8.9 KiB
Markdown
320 lines
8.9 KiB
Markdown
---
|
||
description: NOSE Pluto - A Commodore 64-inspired web terminal with Bun-based shell
|
||
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
|
||
alwaysApply: true
|
||
---
|
||
|
||
# NOSE Pluto
|
||
|
||
NOSE Pluto is a web-based terminal inspired by the Commodore 64, running on a Bun-based shell server. It hosts Val Town-style web apps for your home network, runs custom commands written in TypeScript, and lives on a Raspberry Pi 5.
|
||
|
||
## Architecture Overview
|
||
|
||
- **Server**: Bun + Hono serving a C64-inspired terminal over WebSockets
|
||
- **Client**: Browser-based terminal UI with virtual 960×540 (16:9) screen that scales
|
||
- **Commands**: TypeScript files that run on the server and return output to the client
|
||
- **Projects**: Multi-project system with isolated commands, webapps, and state
|
||
- **Webapps**: Subdomain-based apps (e.g., `myapp.nose-pluto.local`)
|
||
- **Games**: PICO-8-style game engine with `init()`, `update()`, `draw()` API
|
||
- **Session**: AsyncLocalStorage-based session management (1 browser tab = 1 session)
|
||
|
||
## Technology Stack
|
||
|
||
Use Bun exclusively instead of Node.js:
|
||
|
||
- `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||
- `bun test` instead of `jest` or `vitest`
|
||
- `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||
- `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||
- `bun run <script>` instead of `npm run <script>`
|
||
- Bun automatically loads .env, so don't use dotenv
|
||
|
||
### Bun APIs
|
||
|
||
- `Bun.serve()` supports WebSockets, HTTPS, and routes (don't use `express`)
|
||
- `bun:sqlite` for SQLite (don't use `better-sqlite3`)
|
||
- `Bun.redis` for Redis (don't use `ioredis`)
|
||
- `Bun.sql` for Postgres (don't use `pg` or `postgres.js`)
|
||
- `WebSocket` is built-in (don't use `ws`)
|
||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||
- `Bun.$\`ls\`` instead of execa
|
||
|
||
## Key Concepts
|
||
|
||
### Commands
|
||
|
||
Commands are TypeScript files in `bin/` directories that export a `default` function:
|
||
|
||
```ts
|
||
// bin/hello.ts
|
||
export default async function (name: string): Promise<string> {
|
||
return `Hello, ${name}!`
|
||
}
|
||
```
|
||
|
||
Commands can return:
|
||
- `string` - plain text output
|
||
- `{ text: string, script?: string }` - text with optional client-side JS
|
||
- `{ html: string, script?: string }` - HTML with optional client-side JS
|
||
- `{ script: string }` - client-side JS only
|
||
- `{ error: string }` - error message
|
||
- `{ game: string }` - launch a game
|
||
|
||
Commands can also `throw` to display an error.
|
||
|
||
**Command Locations** (searched in order):
|
||
1. `~/nose/<project>/bin/` - project-specific commands
|
||
2. `~/nose/root/bin/` - global user commands (NOSE_ROOT_BIN)
|
||
3. `./bin/` - built-in system commands (NOSE_BIN)
|
||
|
||
**Documentation**: Commands use `//` comments at the top of the file for inline help:
|
||
|
||
```ts
|
||
// This command does something cool.
|
||
// Usage: mycmd <arg1> <arg2>
|
||
|
||
export default async function (arg1: string, arg2: string) {
|
||
// ...
|
||
}
|
||
```
|
||
|
||
Use `/// <reference lib="dom" />` on the first line to mark commands that run in the browser.
|
||
|
||
### Projects
|
||
|
||
Projects live in `~/nose/<project-name>/` and have:
|
||
- `bin/` - project-specific commands
|
||
- `index.ts` or `index.tsx` - webapp entry point (optional)
|
||
- `pub/` - static files served at subdomain (optional)
|
||
|
||
The `root` project is special - it's the default project and its `bin/` contains global commands.
|
||
|
||
**Project Structure**:
|
||
```
|
||
~/nose/
|
||
root/ # default project
|
||
bin/ # global commands
|
||
myproject/ # custom project
|
||
bin/ # project commands
|
||
index.ts # webapp (served at myproject.nose-pluto.local)
|
||
pub/ # static files
|
||
```
|
||
|
||
### Webapps
|
||
|
||
Webapps are served on subdomains (e.g., `myapp.nose-pluto.local`). They can:
|
||
- Export a `default` Hono app for dynamic routes
|
||
- Export a `default` function that returns a Response, string, or JSX
|
||
- Serve static files from `pub/` directory
|
||
|
||
```ts
|
||
// ~/nose/myapp/index.ts
|
||
import { Hono } from "hono"
|
||
|
||
const app = new Hono()
|
||
|
||
app.get("/", c => c.text("Hello from myapp!"))
|
||
|
||
export default app
|
||
```
|
||
|
||
Static files in `pub/` are served automatically (e.g., `pub/index.html` → `myapp.nose-pluto.local/`).
|
||
|
||
### Games
|
||
|
||
Games are commands with `export const game = true` and three exports:
|
||
|
||
```ts
|
||
/// <reference lib="dom" />
|
||
export const game = true
|
||
|
||
import type { GameContext, InputState } from "@/shared/game"
|
||
|
||
export function init() {
|
||
// Initialize game state
|
||
}
|
||
|
||
export function update(delta: number, input: InputState) {
|
||
// Update game logic (60 FPS)
|
||
}
|
||
|
||
export function draw(game: GameContext) {
|
||
// Draw to 960×540 canvas
|
||
game.clear()
|
||
game.rectfill(0, 0, 100, 100, "red")
|
||
game.text("Score: 0", 10, 10, "white")
|
||
}
|
||
```
|
||
|
||
**GameContext API**: `clear()`, `text()`, `centerText()`, `rect()`, `rectfill()`, `circ()`, `circfill()`, `line()`, `oval()`, `ovalfill()`, `rrect()`, `rrectfill()`, `trianglefill()`, `polygonfill()`
|
||
|
||
**InputState**: Tracks keyboard/gamepad with `pressed`, `prevPressed`, `justPressed`, `justReleased` Sets.
|
||
|
||
### Session Management
|
||
|
||
Sessions use AsyncLocalStorage for context isolation:
|
||
|
||
```ts
|
||
import { sessionGet, sessionSet } from "@/session"
|
||
|
||
// Get session data
|
||
const project = sessionGet("project")
|
||
const cwd = sessionGet("cwd")
|
||
|
||
// Set session data (auto-syncs to client)
|
||
sessionSet("project", "myproject")
|
||
sessionSet("cwd", "/some/path")
|
||
```
|
||
|
||
Session keys: `sessionId`, `taskId`, `project`, `cwd`, `ws`
|
||
|
||
## File Structure
|
||
|
||
```
|
||
src/
|
||
server.tsx # Main Hono server
|
||
websocket.ts # WebSocket connection management
|
||
dispatch.ts # Client→server message router
|
||
shell.ts # Command execution (server-side)
|
||
commands.ts # Command discovery and loading
|
||
session.ts # Session management (AsyncLocalStorage)
|
||
project.ts # Project helpers
|
||
webapp.ts # Webapp hosting
|
||
state.ts # Persistent state storage
|
||
config.ts # Environment configuration
|
||
|
||
html/
|
||
layout.tsx # Base HTML layout
|
||
terminal.tsx # Terminal UI components
|
||
|
||
js/
|
||
main.ts # Client entry point
|
||
websocket.ts # WebSocket client
|
||
shell.ts # Command execution (client-side)
|
||
scrollback.ts # Terminal scrollback buffer
|
||
input.ts # Input handling
|
||
completion.ts # Tab completion
|
||
history.ts # Command history
|
||
editor.ts # File editor
|
||
game.ts # Game engine client
|
||
browser.ts # Embedded browser
|
||
|
||
shared/
|
||
types.ts # Shared TypeScript types
|
||
game.ts # Game engine types and context
|
||
utils.ts # Shared utilities
|
||
|
||
bin/ # Built-in commands
|
||
public/ # Static assets (CSS, fonts, images)
|
||
```
|
||
|
||
## Development Workflow
|
||
|
||
```bash
|
||
# Local development with hot reload
|
||
bun dev
|
||
|
||
# Production mode (no DNS)
|
||
bun prod-nodns
|
||
|
||
# Production mode (with DNS)
|
||
bun prod
|
||
|
||
# Type checking
|
||
bun check
|
||
|
||
# Deploy to Raspberry Pi
|
||
bun deploy
|
||
```
|
||
|
||
**Environment Variables**:
|
||
- `NOSE_DIR` - Directory for projects (default: `~/nose`)
|
||
- `NODE_ENV` - Set to `production` for production mode
|
||
- `NO_DNS` - Disable DNS features in production
|
||
- `BUN_HOT` - Enable hot reload (set by `bun dev`)
|
||
|
||
## Important Patterns
|
||
|
||
### Path Aliases
|
||
|
||
Use `@/` for imports from `src/` when writing commands:
|
||
|
||
```ts
|
||
import { sessionGet } from "@/session"
|
||
import { commands } from "@/commands"
|
||
import type { Message } from "@/shared/types"
|
||
```
|
||
|
||
### Command Patterns
|
||
|
||
**Simple text command**:
|
||
```ts
|
||
export default function () {
|
||
return "Hello, world!"
|
||
}
|
||
```
|
||
|
||
**HTML output**:
|
||
```ts
|
||
export default function () {
|
||
return { html: "<h1>Hello</h1>" }
|
||
}
|
||
```
|
||
|
||
**JSX output** (auto-converted):
|
||
```tsx
|
||
export default function () {
|
||
return <h1>Hello</h1>
|
||
}
|
||
```
|
||
|
||
**Client-side script**:
|
||
```ts
|
||
export default function () {
|
||
return {
|
||
text: "Running script...",
|
||
script: "alert('Hello from browser!')"
|
||
}
|
||
}
|
||
```
|
||
|
||
### WebSocket Messages
|
||
|
||
Messages between client and server use typed message objects:
|
||
|
||
```ts
|
||
// Client → Server
|
||
{ type: "input", id: "abc123", session: "0", data: "ls" }
|
||
{ type: "save-file", id: "/path/to/file", session: "0", data: "content" }
|
||
{ type: "session:update", data: { project: "myproject" } }
|
||
|
||
// Server → Client
|
||
{ type: "output", id: "abc123", data: { status: "ok", output: "result" } }
|
||
{ type: "commands", data: ["ls", "cd", "help"] }
|
||
{ type: "apps", data: ["myapp", "otherapp"] }
|
||
{ type: "game:start", id: "abc123", data: "snake" }
|
||
{ type: "session:start", data: { NOSE_DIR, project, cwd, mode, hostname } }
|
||
```
|
||
|
||
## Testing
|
||
|
||
Use `bun test`:
|
||
|
||
```ts
|
||
import { test, expect } from "bun:test"
|
||
|
||
test("command output", () => {
|
||
expect(1).toBe(1)
|
||
})
|
||
```
|
||
|
||
## Notes
|
||
|
||
- Virtual screen size is fixed at 960×540 (16:9) in "cinema" mode and scales to fit the display
|
||
- Virtual screen size is fixed at 960x100%vh in "tall" mode and scales to fit the display
|
||
- Font: C64 Pro Mono (see `public/vendor/`)
|
||
- Commands are hot-reloaded when files change
|
||
- WebSocket connections are managed per-session (1 tab = 1 session)
|
||
- Session state persists across reconnects
|
||
- The `help` command extracts documentation from `//` comments in commands
|