8.9 KiB
| description | globs | alwaysApply |
|---|---|---|
| NOSE Pluto - A Commodore 64-inspired web terminal with Bun-based shell | *.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json | 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 ofnode <file>orts-node <file>bun testinstead ofjestorvitestbun build <file.html|file.ts|file.css>instead ofwebpackoresbuildbun installinstead ofnpm installoryarn installorpnpm installbun run <script>instead ofnpm run <script>- Bun automatically loads .env, so don't use dotenv
Bun APIs
Bun.serve()supports WebSockets, HTTPS, and routes (don't useexpress)bun:sqlitefor SQLite (don't usebetter-sqlite3)Bun.redisfor Redis (don't useioredis)Bun.sqlfor Postgres (don't usepgorpostgres.js)WebSocketis built-in (don't usews)- Prefer
Bun.fileovernode:fs's readFile/writeFile Bun.$\ls`` instead of execa
Key Concepts
Commands
Commands are TypeScript files in bin/ directories that export a default function:
// 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):
~/nose/<project>/bin/- project-specific commands~/nose/root/bin/- global user commands (NOSE_ROOT_BIN)./bin/- built-in system commands (NOSE_BIN)
Documentation: Commands use // comments at the top of the file for inline help:
// 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 commandsindex.tsorindex.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
defaultHono app for dynamic routes - Export a
defaultfunction that returns a Response, string, or JSX - Serve static files from
pub/directory
// ~/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:
/// <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:
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
# 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 toproductionfor production modeNO_DNS- Disable DNS features in productionBUN_HOT- Enable hot reload (set bybun dev)
Important Patterns
Path Aliases
Use @/ for imports from src/ when writing commands:
import { sessionGet } from "@/session"
import { commands } from "@/commands"
import type { Message } from "@/shared/types"
Command Patterns
Simple text command:
export default function () {
return "Hello, world!"
}
HTML output:
export default function () {
return { html: "<h1>Hello</h1>" }
}
JSX output (auto-converted):
export default function () {
return <h1>Hello</h1>
}
Client-side script:
export default function () {
return {
text: "Running script...",
script: "alert('Hello from browser!')"
}
}
WebSocket Messages
Messages between client and server use typed message objects:
// 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:
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
helpcommand extracts documentation from//comments in commands