Compare commits

...

11 Commits

Author SHA1 Message Date
Chris Wanstrath
8a6e95a500 sys -> root 2025-10-02 11:11:56 -07:00
ea7d538618 wheee 2025-10-01 22:19:52 -07:00
bab54e74d7 fix bug 2025-10-01 22:19:35 -07:00
c8704cf8fd fix js imports 2025-10-01 22:16:38 -07:00
8bfb6f2105 note 2025-10-01 21:58:27 -07:00
4276363a37 projects overhaul 2025-10-01 21:57:29 -07:00
dda1cc1f21 some stuff 2025-10-01 21:39:43 -07:00
e88627693c new project structure 2025-10-01 20:44:00 -07:00
f9792eb31c start moving to new project structure 2025-10-01 20:43:58 -07:00
89d850b55f remember your "mode" 2025-10-01 19:17:44 -07:00
07840aabd8 frontend message dispatch 2025-10-01 18:56:34 -07:00
61 changed files with 320 additions and 273 deletions

View File

@ -22,6 +22,8 @@ And to make sure DNS is working:
## Local Dev ## Local Dev
Running the server will create `~/nose` for you to play with.
bun install bun install
bun dev bun dev
open localhost:3000 open localhost:3000
@ -79,10 +81,10 @@ https://wakamaifondue.com/
- [x] public tunnel for your NOSE webapps - [x] public tunnel for your NOSE webapps
- [x] public tunnel lives through reboots - [x] public tunnel lives through reboots
- [ ] tunnel to the terminal - [ ] tunnel to the terminal
- [ ] remember your "mode" - [x] remember your "mode"
- [ ] `nose` CLI - [ ] `nose` CLI
- [ ] status bar on terminal UX - [ ] status bar on terminal UX
- [ ] "project"-based rehaul - [x] "project"-based rehaul
- [x] self updating NOSE server - [x] self updating NOSE server
- [x] `pub/` static hosting in webapps - [x] `pub/` static hosting in webapps
- [x] upload files to projects - [x] upload files to projects

View File

@ -5,7 +5,6 @@ import { readdirSync } from "fs"
import { join, extname } from "path" import { join, extname } from "path"
import type { CommandOutput } from "@/shared/types" import type { CommandOutput } from "@/shared/types"
import { NOSE_WWW } from "@/config"
import { isBinaryFile } from "@/utils" import { isBinaryFile } from "@/utils"
import { projectName, projectDir } from "@/project" import { projectName, projectDir } from "@/project"
import { sessionGet } from "@/session" import { sessionGet } from "@/session"
@ -21,10 +20,6 @@ export default async function (path: string) {
files.push(file.name) files.push(file.name)
} }
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
if (!files.includes(path)) if (!files.includes(path))
return { error: `file not found: ${path}` } return { error: `file not found: ${path}` }

View File

@ -4,7 +4,6 @@ import { readdirSync } from "fs"
import { join, extname } from "path" import { join, extname } from "path"
import type { CommandOutput } from "@/shared/types" import type { CommandOutput } from "@/shared/types"
import { NOSE_WWW } from "@/config"
import { isBinaryFile } from "@/utils" import { isBinaryFile } from "@/utils"
import { countChar } from "@/shared/utils" import { countChar } from "@/shared/utils"
import { projectName, projectDir } from "@/project" import { projectName, projectDir } from "@/project"
@ -20,10 +19,6 @@ export default async function (path: string) {
files.push(file.name) files.push(file.name)
} }
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
if (files.includes(path)) if (files.includes(path))
return await readFile(join(root, path)) return await readFile(join(root, path))
else else

View File

@ -2,7 +2,7 @@
// //
// Show some debugging information. // Show some debugging information.
import { NOSE_STARTED, NOSE_SYS_BIN, NOSE_DIR, GIT_SHA } from "@/config" import { NOSE_STARTED, NOSE_ROOT_BIN, NOSE_BIN, NOSE_DATA, NOSE_DIR, GIT_SHA } from "@/config"
import { highlightToHTML } from "../lib/highlight" import { highlightToHTML } from "../lib/highlight"
export default function () { export default function () {
@ -14,7 +14,9 @@ export default function () {
`USER=${valueOrNone(process.env.USER)}`, `USER=${valueOrNone(process.env.USER)}`,
`PWD=${valueOrNone(process.env.PWD)}`, `PWD=${valueOrNone(process.env.PWD)}`,
`NOSE_STARTED=${NOSE_STARTED}`, `NOSE_STARTED=${NOSE_STARTED}`,
`NOSE_SYS_BIN="${NOSE_SYS_BIN}"`, `NOSE_BIN="${NOSE_BIN}"`,
`NOSE_ROOT_BIN="${NOSE_ROOT_BIN}"`,
`NOSE_DATA="${NOSE_DATA}"`,
`NOSE_DIR="${NOSE_DIR}"`, `NOSE_DIR="${NOSE_DIR}"`,
`GIT_SHA="${GIT_SHA}"`, `GIT_SHA="${GIT_SHA}"`,
].join("\n")) ].join("\n"))

View File

@ -2,13 +2,13 @@
import { readdirSync } from "fs" import { readdirSync } from "fs"
import { join } from "path" import { join } from "path"
import { NOSE_SYS_BIN } from "@/config" import { NOSE_BIN } from "@/config"
export default async function () { export default async function () {
let games = await Promise.all(readdirSync(NOSE_SYS_BIN, { withFileTypes: true }).map(async file => { let games = await Promise.all(readdirSync(NOSE_BIN, { withFileTypes: true }).map(async file => {
if (!file.isFile()) return if (!file.isFile()) return
const code = await Bun.file(join(NOSE_SYS_BIN, file.name)).text() const code = await Bun.file(join(NOSE_BIN, file.name)).text()
if (/^export const game\s*=\s*true\s*;?\s*$/m.test(code)) if (/^export const game\s*=\s*true\s*;?\s*$/m.test(code))
return file.name.replace(".tsx", "").replace(".ts", "") return file.name.replace(".tsx", "").replace(".ts", "")

View File

@ -1,12 +1,10 @@
// Look around. // Look around.
import { readdirSync } from "fs" import { readdirSync } from "fs"
import { NOSE_WWW } from "@/config"
import { projectName, projectDir } from "@/project" import { projectName, projectDir } from "@/project"
import { sessionGet } from "@/session" import { sessionGet } from "@/session"
export default function () { export default function () {
const project = projectName()
const root = sessionGet("cwd") || projectDir() const root = sessionGet("cwd") || projectDir()
let files: string[] = [] let files: string[] = []
@ -15,10 +13,6 @@ export default function () {
files.push(file.isDirectory() ? `${file.name}/` : file.name) files.push(file.isDirectory() ? `${file.name}/` : file.name)
} }
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
return <> return <>
{root !== projectDir() && <a href="#cd ..;ls">..</a>} {root !== projectDir() && <a href="#cd ..;ls">..</a>}
{files.map(file => {files.map(file =>

View File

@ -4,23 +4,21 @@
import { mkdirSync, writeFileSync } from "fs" import { mkdirSync, writeFileSync } from "fs"
import { join } from "path" import { join } from "path"
import { apps } from "@/webapp" import { projects } from "@/project"
import { NOSE_WWW } from "@/config" import { NOSE_DIR } from "@/config"
import { isDir } from "@/utils"
import load from "./load" import load from "./load"
export default function (project: string) { export default function (project: string) {
if (!project) throw "usage: new <project name>" if (!project) throw "usage: new <project name>"
if (apps().includes(project)) throw `${project} already exists` if (projects().includes(project)) throw `${project} already exists`
if (!isDir(NOSE_WWW)) throw `no www dir! make one in a real shell:\n$ mkdir -p ${NOSE_WWW}` const dir = join(NOSE_DIR, project, "bin")
mkdirSync(dir, { recursive: true })
mkdirSync(join(NOSE_WWW, project)) writeFileSync(join(dir, `index.ts`), `export default (c: Context) =>\n "Hello, world!"`)
writeFileSync(join(NOSE_WWW, project, `index.ts`), `export default (c: Context) =>\n "Hello, world!"`)
load(project) load(project)
return `created ${project}` return `Created ${project}`
} }

View File

@ -1,10 +1,7 @@
// Print the currently loaded project. // Print the currently loaded project.
import { sessionGet } from "@/session" import { projectName } from "@/project"
export default function () { export default function () {
const state = sessionGet() return projectName()
if (!state) return { error: "no state" }
return state?.project || "none"
} }

View File

@ -1,13 +1,10 @@
// Show the projects on this NOSEputer. // Show the projects on this NOSEputer.
import { apps } from "@/webapp" import { projects, projectName } from "@/project"
import { sessionGet } from "@/session"
export default function () { export default function () {
const state = sessionGet() const project = projectName()
if (!state) return { error: "no state" }
return <> return <>
{apps().map(app => <a href={`#load ${app}`} class={app === state.project ? "magenta" : ""}>{app}</a>)} {projects().map(app => <a href={`#load ${app}`} class={app === project ? "magenta" : ""}>{app}</a>)}
</> </>
} }

View File

@ -1,6 +1,6 @@
// Reboot the whole computer! Careful!
export default async function reboot() { export default async function reboot() {
setTimeout(async () => await Bun.$`reboot`, 1000) setTimeout(async () => await Bun.$`reboot`, 1000)
console.log("REBOOTING...")
return { return {
text: "Rebooting... This will take about 10 seconds.", text: "Rebooting... This will take about 10 seconds.",

View File

@ -1,6 +1,6 @@
// Restart the NOSE server.
export default function restart() { export default function restart() {
setTimeout(() => process.exit(), 1000) setTimeout(() => process.exit(), 1000)
console.log("RESTARTING...")
return { return {
text: "Restarting... This will take a second or two.", text: "Restarting... This will take a second or two.",

View File

@ -65,9 +65,6 @@ export function draw(game: GameContext) {
const offsetX = (game.width - boardW) / 2 const offsetX = (game.width - boardW) / 2
const offsetY = (game.height - boardH) / 2 const offsetY = (game.height - boardH) / 2
console.log("X", offsetX)
console.log("Y", offsetY)
const c = game.ctx const c = game.ctx
c.save() c.save()
c.translate(offsetX, offsetY) c.translate(offsetX, offsetY)

View File

@ -1,3 +1,4 @@
// The git sha for the running server.
import { GIT_SHA } from "@/config" import { GIT_SHA } from "@/config"
export default function () { export default function () {
return GIT_SHA return GIT_SHA

3
nose/chris/bin/chris.ts Normal file
View File

@ -0,0 +1,3 @@
export default function () {
return "chris works!"
}

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

3
nose/corey/bin/corey.ts Normal file
View File

@ -0,0 +1,3 @@
export default function () {
return "corey works!"
}

View File

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

3
nose/hello/bin/hello.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function () {
return <h1>HELLO!</h1>
}

2
nose/root/bin/root.ts Normal file
View File

@ -0,0 +1,2 @@
export default () =>
"root online"

View File

@ -1,5 +1,5 @@
//// ////
// Manages the commands on disk, in NOSE_SYS_BIN and NOSE_BIN // Manages the commands on disk, in NOSE_ROOT_BIN and NOSE_BIN
import { Glob } from "bun" import { Glob } from "bun"
import { watch } from "fs" import { watch } from "fs"
@ -7,34 +7,49 @@ import { join } from "path"
import { isFile } from "./utils" import { isFile } from "./utils"
import { sendAll } from "./websocket" import { sendAll } from "./websocket"
import { expectDir } from "./utils" import { expectDir } from "./utils"
import { NOSE_SYS_BIN, NOSE_BIN } from "./config" import { unique } from "./shared/utils"
import { projectBin, projectName } from "./project"
import { DEFAULT_PROJECT, NOSE_DIR, NOSE_ROOT_BIN, NOSE_BIN } from "./config"
export function initCommands() { export function initCommands() {
startWatchers() startWatchers()
} }
export async function commands(): Promise<string[]> { export async function commands(project = DEFAULT_PROJECT): Promise<string[]> {
return (await findCommands(NOSE_SYS_BIN)).concat(await findCommands(NOSE_BIN)) let cmds = (await findCommands(NOSE_BIN))
.concat(await findCommands(NOSE_ROOT_BIN))
if (project !== DEFAULT_PROJECT)
cmds = cmds.concat(await findCommands(projectBin()))
return unique(cmds).sort()
} }
export async function findCommands(path: string): Promise<string[]> { export async function findCommands(path: string): Promise<string[]> {
const glob = new Glob("**/*.{ts,tsx}") const glob = new Glob("**/*.{ts,tsx}")
let list: string[] = [] let list: string[] = []
for await (const file of glob.scan(path)) { for await (const file of glob.scan(path))
list.push(file.replace(".tsx", "").replace(".ts", "")) list.push(file.replace(".tsx", "").replace(".ts", ""))
}
return list return list
} }
export function commandPath(cmd: string): string | undefined { export function commandPath(cmd: string): string | undefined {
return [ let paths = [
join(NOSE_SYS_BIN, cmd + ".ts"),
join(NOSE_SYS_BIN, cmd + ".tsx"),
join(NOSE_BIN, cmd + ".ts"), join(NOSE_BIN, cmd + ".ts"),
join(NOSE_BIN, cmd + ".tsx") join(NOSE_BIN, cmd + ".tsx"),
].find((path: string) => isFile(path)) join(NOSE_ROOT_BIN, cmd + ".ts"),
join(NOSE_ROOT_BIN, cmd + ".tsx"),
]
if (projectName() !== DEFAULT_PROJECT)
paths = paths.concat(
join(projectBin(), cmd + ".ts"),
join(projectBin(), cmd + ".tsx"),
)
return paths.find((path: string) => isFile(path))
} }
export function commandExists(cmd: string): boolean { export function commandExists(cmd: string): boolean {
@ -53,16 +68,17 @@ export async function loadCommandModule(cmd: string) {
return await import(path + "?t+" + Date.now()) return await import(path + "?t+" + Date.now())
} }
let sysCmdWatcher let noseDirWatcher
let usrCmdWatcher let binCmdWatcher
function startWatchers() { function startWatchers() {
if (!expectDir(NOSE_BIN)) return if (!expectDir(NOSE_BIN)) return
if (!expectDir(NOSE_ROOT_BIN)) return
sysCmdWatcher = watch(NOSE_SYS_BIN, async (event, filename) => binCmdWatcher = watch(NOSE_BIN, async (event, filename) => {
sendAll({ type: "commands", data: await commands() })
)
usrCmdWatcher = watch(NOSE_BIN, async (event, filename) => {
sendAll({ type: "commands", data: await commands() }) sendAll({ type: "commands", data: await commands() })
}) })
noseDirWatcher = watch(NOSE_DIR, async (event, filename) =>
sendAll({ type: "commands", data: await commands() })
)
} }

View File

@ -4,13 +4,12 @@ import { untilde } from "./utils"
export const NOSE_ICON = ` ͡° ͜ʖ ͡°` export const NOSE_ICON = ` ͡° ͜ʖ ͡°`
export const NOSE_SYS_BIN = resolve("./bin") export const NOSE_BIN = resolve("./bin")
export const NOSE_DATA = resolve("./data")
export const NOSE_DIR = resolve(untilde(process.env.NOSE_DIR || "./nose")) export const NOSE_DIR = resolve(untilde(process.env.NOSE_DIR || "./nose"))
export const NOSE_BIN = join(NOSE_DIR, "bin") export const DEFAULT_PROJECT = "root"
export const NOSE_WWW = join(NOSE_DIR, "www") export const NOSE_ROOT_BIN = join(NOSE_DIR, DEFAULT_PROJECT, "bin")
export const NOSE_DATA = resolve("./data")
export const NOSE_STARTED = Date.now() export const NOSE_STARTED = Date.now()
export const GIT_SHA = (await $`git rev-parse --short HEAD`.text()).trim() export const GIT_SHA = (await $`git rev-parse --short HEAD`.text()).trim()

View File

@ -73,6 +73,7 @@
a { a {
color: var(--cyan); color: var(--cyan);
display: inline-block;
} }
a:visited { a:visited {

View File

@ -5,6 +5,7 @@ import { basename } from "path"
import type { Message } from "./shared/types" import type { Message } from "./shared/types"
import { runCommand } from "./shell" import { runCommand } from "./shell"
import { send } from "./websocket" import { send } from "./websocket"
import { setState } from "./state"
export async function dispatchMessage(ws: any, msg: Message) { export async function dispatchMessage(ws: any, msg: Message) {
switch (msg.type) { switch (msg.type) {
@ -14,6 +15,9 @@ export async function dispatchMessage(ws: any, msg: Message) {
case "save-file": case "save-file":
await saveFileMessage(ws, msg); break await saveFileMessage(ws, msg); break
case "ui:mode":
setState("ui:mode", msg.data); break
default: default:
send(ws, { type: "error", data: `unknown message: ${msg.type}` }) send(ws, { type: "error", data: `unknown message: ${msg.type}` })
} }

View File

@ -4,7 +4,7 @@
import { watch } from "fs" import { watch } from "fs"
import { apps } from "./webapp" import { apps } from "./webapp"
import { expectDir } from "./utils" import { expectDir } from "./utils"
import { NOSE_WWW } from "./config" import { NOSE_DIR } from "./config"
import { expectShellCmd } from "./utils" import { expectShellCmd } from "./utils"
export const dnsEntries: Record<string, any> = {} export const dnsEntries: Record<string, any> = {}
@ -47,11 +47,11 @@ export function publishAppDNS(app: string) {
return dnsEntries[app] return dnsEntries[app]
} }
let wwwWatcher let dnsWatcher
function startWatcher() { function startWatcher() {
if (!expectDir(NOSE_WWW)) return if (!expectDir(NOSE_DIR)) return
wwwWatcher = watch(NOSE_WWW, (event, filename) => { dnsWatcher = watch(NOSE_DIR, (event, filename) => {
const www = apps() const www = apps()
www.forEach(publishAppDNS) www.forEach(publishAppDNS)
for (const name in dnsEntries) for (const name in dnsEntries)

View File

@ -58,7 +58,6 @@ export function routes(def: Record<string, Handler>): Hono {
const method = parts[0] || "GET" const method = parts[0] || "GET"
const path = parts[1] || "/" const path = parts[1] || "/"
console.log(method, path, def[key])
//@ts-ignore //@ts-ignore
app.on(method, path, async c => toResponse(await def[key](c))) app.on(method, path, async c => toResponse(await def[key](c)))
} }

View File

@ -17,7 +17,7 @@ export const Layout: FC = async ({ children, title }) => (
</head> </head>
<body data-mode="tall"> <body data-mode="tall">
<main> <main>
<div id="content"> <div id="content" style="display:none">
{children} {children}
</div> </div>
</main> </main>

View File

@ -1,20 +1,30 @@
//// ////
// temporary hack for browser commands // temporary hack for browser commands
import { scrollback } from "./dom.js" import type { CommandOutput } from "../shared/types"
import { resize } from "./resize.js" import { scrollback, content } from "./dom"
import { autoScroll } from "./scrollback.js" import { resize } from "./resize"
import { sessionId } from "./session.js" import { autoScroll } from "./scrollback"
import { sessionId } from "./session"
import { send } from "./websocket"
export const commands: string[] = [] export const commands: string[] = []
export const browserCommands: Record<string, () => any> = { export const browserCommands: Record<string, (...args: string[]) => void | Promise<void> | CommandOutput> = {
"browser-session": () => sessionId, "browser-session": () => sessionId,
clear: () => scrollback.innerHTML = "", clear: () => scrollback.innerHTML = "",
commands: () => commands.join(" "), commands: () => {
return { html: "<div>" + commands.map(cmd => `<a href="#help ${cmd}">${cmd}</a>`).join("") + "</div>" }
},
fullscreen: () => document.body.requestFullscreen(), fullscreen: () => document.body.requestFullscreen(),
mode: () => { mode: (mode?: string) => {
document.body.dataset.mode = document.body.dataset.mode === "tall" ? "cinema" : "tall" if (!mode) {
mode = document.body.dataset.mode === "tall" ? "cinema" : "tall"
send({ type: "ui:mode", data: mode })
}
content.style.display = ""
document.body.dataset.mode = mode
resize() resize()
autoScroll() autoScroll()
}, },
@ -26,4 +36,5 @@ export function cacheCommands(cmds: string[]) {
commands.push(...cmds) commands.push(...cmds)
commands.push(...Object.keys(browserCommands)) commands.push(...Object.keys(browserCommands))
commands.sort() commands.sort()
console.log("CMDS", commands)
} }

View File

@ -1,8 +1,8 @@
//// ////
// tab completion // tab completion
import { cmdInput } from "./dom.js" import { cmdInput } from "./dom"
import { commands } from "./commands.js" import { commands } from "./commands"
export function initCompletion() { export function initCompletion() {
cmdInput.addEventListener("keydown", handleCompletion) cmdInput.addEventListener("keydown", handleCompletion)

View File

@ -1,7 +1,7 @@
//// ////
// Blinking c64 cursor // Blinking c64 cursor
import { cmdInput, $ } from "./dom.js" import { cmdInput, $ } from "./dom"
const cursor = "Û" const cursor = "Û"
let cmdCursor: HTMLTextAreaElement let cmdCursor: HTMLTextAreaElement

32
src/js/dispatch.ts Normal file
View File

@ -0,0 +1,32 @@
import type { Message } from "@/shared/types"
import { cacheCommands } from "./commands"
import { handleOutput } from "./scrollback"
import { handleStreamStart, handleStreamAppend, handleStreamReplace, handleStreamEnd } from "./stream"
import { handleGameStart } from "./game"
import { browserCommands } from "./commands"
// message received from server
export async function dispatchMessage(msg: Message) {
switch (msg.type) {
case "output":
handleOutput(msg); break
case "commands":
cacheCommands(msg.data as string[]); break
case "error":
console.error(msg.data); break
case "stream:start":
handleStreamStart(msg); break
case "stream:end":
handleStreamEnd(msg); break
case "stream:append":
handleStreamAppend(msg); break
case "stream:replace":
handleStreamReplace(msg); break
case "game:start":
await handleGameStart(msg); break
case "ui:mode":
browserCommands.mode?.(msg.data as string); break
default:
console.error("unknown message type", msg)
}
}

View File

@ -2,6 +2,7 @@
// DOM helpers and cached elements // DOM helpers and cached elements
// elements we know will be there... right? // elements we know will be there... right?
export const content = $("content") as HTMLDivElement
export const cmdLine = $("command-line") as HTMLDivElement export const cmdLine = $("command-line") as HTMLDivElement
export const cmdInput = $("command-textbox") as HTMLTextAreaElement export const cmdInput = $("command-textbox") as HTMLTextAreaElement
export const scrollback = $("scrollback") as HTMLUListElement export const scrollback = $("scrollback") as HTMLUListElement

View File

@ -1,3 +1,7 @@
////
// Hack... works with the `upload` command.
// Dragging a file to the terminal fills in the <input type="file"/> on screen.
export function initDrop() { export function initDrop() {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.body.addEventListener(eventName, preventDefaults, false); document.body.addEventListener(eventName, preventDefaults, false);

View File

@ -1,6 +1,6 @@
import { scrollback } from "./dom.js" import { scrollback } from "./dom"
import { send } from "./websocket.js" import { send } from "./websocket"
import { focusInput } from "./focus.js" import { focusInput } from "./focus"
const INDENT_SIZE = 2 const INDENT_SIZE = 2

View File

@ -1,11 +1,11 @@
//// ////
// We try to keep the command textbox focused at all times. // We try to keep the command textbox focused at all times.
import { cmdInput } from "./dom.js" import { cmdInput } from "./dom"
export function initFocus() { export function initFocus() {
window.addEventListener("click", focusHandler) window.addEventListener("click", focusHandler)
focusInput() setTimeout(() => focusInput(), 10)
} }
export function focusInput() { export function focusInput() {

View File

@ -1,10 +1,10 @@
//// ////
// All forms are submitted via ajax. // All forms are submitted via ajax.
import type { CommandResult, CommandOutput } from "../shared/types.js" import type { CommandResult, CommandOutput } from "../shared/types"
import { sessionId } from "./session.js" import { sessionId } from "./session"
import { setStatus, replaceOutput } from "./scrollback.js" import { setStatus, replaceOutput } from "./scrollback"
import { focusInput } from "./focus.js" import { focusInput } from "./focus"
export function initForm() { export function initForm() {
document.addEventListener("submit", submitHandler) document.addEventListener("submit", submitHandler)

View File

@ -1,10 +1,10 @@
import type { Message } from "../shared/types.js" import type { Message } from "../shared/types"
import { GameContext, type InputState } from "../shared/game.js" import { GameContext, type InputState } from "../shared/game"
import { focusInput } from "./focus.js" import { focusInput } from "./focus"
import { $$ } from "./dom.js" import { $$ } from "./dom"
import { randomId } from "../shared/utils.js" import { randomId } from "../shared/utils"
import { setStatus, addOutput, insert } from "./scrollback.js" import { setStatus, addOutput, insert } from "./scrollback"
import { browserCommands } from "./commands.js" import { browserCommands } from "./commands"
const FPS = 30 const FPS = 30
const HEIGHT = 540 const HEIGHT = 540

View File

@ -1,7 +1,7 @@
//// ////
// Command input history storage and navigation. // Command input history storage and navigation.
import { cmdInput, cmdLine } from "./dom.js" import { cmdInput, cmdLine } from "./dom"
const history: string[] = ["one", "two", "three"] const history: string[] = ["one", "two", "three"]
let idx = -1 let idx = -1

View File

@ -1,11 +1,11 @@
import { runCommand } from "./shell.js" import { runCommand } from "./shell"
import { focusInput } from "./focus.js" import { focusInput } from "./focus"
export function initHyperlink() { export function initHyperlink() {
window.addEventListener("click", handleClick) window.addEventListener("click", handleClick)
} }
function handleClick(e: MouseEvent) { async function handleClick(e: MouseEvent) {
const target = e.target const target = e.target
if (!(target instanceof HTMLElement)) return if (!(target instanceof HTMLElement)) return
@ -18,7 +18,7 @@ function handleClick(e: MouseEvent) {
if (href.startsWith("#")) { if (href.startsWith("#")) {
e.preventDefault() e.preventDefault()
runCommand(href.slice(1)) await runCommand(href.slice(1))
focusInput() focusInput()
} }
} }

View File

@ -1,17 +1,16 @@
//// ////
// Terminal input is handled by a <textarea> and friends. // Terminal input is handled by a <textarea> and friends.
import { cmdInput, cmdLine } from "./dom.js" import { cmdInput, cmdLine } from "./dom"
import { runCommand } from "./shell.js" import { runCommand } from "./shell"
import { resetHistory } from "./history.js" import { resetHistory } from "./history"
import { countChar } from "../shared/utils.js"
export function initInput() { export function initInput() {
cmdInput.addEventListener("keydown", inputHandler) cmdInput.addEventListener("keydown", inputHandler)
cmdInput.addEventListener("paste", pasteHandler) cmdInput.addEventListener("paste", pasteHandler)
} }
function inputHandler(event: KeyboardEvent) { async function inputHandler(event: KeyboardEvent) {
const target = event.target as HTMLElement const target = event.target as HTMLElement
if (target?.id !== cmdInput.id) return if (target?.id !== cmdInput.id) return
@ -25,7 +24,7 @@ function inputHandler(event: KeyboardEvent) {
cmdLine.dataset.extended = "true" cmdLine.dataset.extended = "true"
} else if (event.key === "Enter") { } else if (event.key === "Enter") {
event.preventDefault() event.preventDefault()
runCommand(cmdInput.value) await runCommand(cmdInput.value)
clearInput() clearInput()
} }

View File

@ -1,17 +1,17 @@
import { initCompletion } from "./completion.js" import { initCompletion } from "./completion"
import { initCursor } from "./cursor.js" import { initCursor } from "./cursor"
import { initDrop } from "./drop.js" import { initDrop } from "./drop"
import { initEditor } from "./editor.js" import { initEditor } from "./editor"
import { initFocus } from "./focus.js" import { initFocus } from "./focus"
import { initForm } from "./form.js" import { initForm } from "./form"
import { initGamepad } from "./gamepad.js" import { initGamepad } from "./gamepad"
import { initHistory } from "./history.js" import { initHistory } from "./history"
import { initHyperlink } from "./hyperlink.js" import { initHyperlink } from "./hyperlink"
import { initInput } from "./input.js" import { initInput } from "./input"
import { initResize } from "./resize.js" import { initResize } from "./resize"
import { initScrollback } from "./scrollback.js" import { initScrollback } from "./scrollback"
import { startVramCounter } from "./vram.js" import { startVramCounter } from "./vram"
import { startConnection } from "./websocket.js" import { startConnection } from "./websocket"
initCompletion() initCompletion()
initCursor() initCursor()

View File

@ -2,9 +2,9 @@
// The scrollback shows your history of interacting with the shell. // The scrollback shows your history of interacting with the shell.
// input, output, etc // input, output, etc
import type { CommandOutput } from "../shared/types.js" import type { Message, CommandOutput, CommandResult } from "../shared/types"
import { scrollback, cmdInput, $$ } from "./dom.js" import { scrollback, cmdInput, $$ } from "./dom"
import { randomId } from "../shared/utils.js" import { randomId } from "../shared/utils"
type InputStatus = "waiting" | "streaming" | "ok" | "error" type InputStatus = "waiting" | "streaming" | "ok" | "error"
@ -142,3 +142,9 @@ function handleInputClick(e: MouseEvent) {
cmdInput.value = target.textContent cmdInput.value = target.textContent
} }
} }
export function handleOutput(msg: Message) {
const result = msg.data as CommandResult
setStatus(msg.id!, result.status)
addOutput(msg.id!, result.output)
}

View File

@ -2,6 +2,6 @@
// Each browser tab is a shell session. This means you can run multiple sessions // Each browser tab is a shell session. This means you can run multiple sessions
// in the same browser. // in the same browser.
import { randomId } from "../shared/utils.js" import { randomId } from "../shared/utils"
export const sessionId = randomId() export const sessionId = randomId()

View File

@ -1,19 +1,17 @@
//// ////
// The shell runs on the server and processes input, returning output. // The shell runs on the server and processes input, returning output.
import type { Message, CommandResult, CommandOutput } from "../shared/types.js" import { addInput, setStatus, addOutput } from "./scrollback"
import { addInput, setStatus, addOutput, appendOutput, replaceOutput } from "./scrollback.js" import { send } from "./websocket"
import { send } from "./websocket.js" import { randomId } from "../shared/utils"
import { randomId } from "../shared/utils.js" import { addToHistory } from "./history"
import { addToHistory } from "./history.js" import { browserCommands } from "./commands"
import { browserCommands, cacheCommands } from "./commands.js"
import { handleGameStart } from "./game.js"
export function runCommand(input: string) { export async function runCommand(input: string) {
if (!input.trim()) return if (!input.trim()) return
if (input.includes(";")) { if (input.includes(";")) {
input.split(";").forEach(cmd => runCommand(cmd.trim())) input.split(";").forEach(async cmd => await runCommand(cmd.trim()))
return return
} }
@ -22,68 +20,14 @@ export function runCommand(input: string) {
addToHistory(input) addToHistory(input)
addInput(id, input) addInput(id, input)
const [cmd = "", ..._args] = input.split(" ") const [cmd = "", ...args] = input.split(" ")
if (browserCommands[cmd]) { if (browserCommands[cmd]) {
const result = browserCommands[cmd]() const result = await browserCommands[cmd](...args)
if (typeof result === "string") if (result) addOutput(id, result)
addOutput(id, result)
setStatus(id, "ok") setStatus(id, "ok")
} else { } else {
send({ id, type: "input", data: input }) send({ id, type: "input", data: input })
} }
} }
// message received from server
export async function handleMessage(msg: Message) {
switch (msg.type) {
case "output":
handleOutput(msg); break
case "commands":
cacheCommands(msg.data as string[]); break
case "error":
console.error(msg.data); break
case "stream:start":
handleStreamStart(msg); break
case "stream:end":
handleStreamEnd(msg); break
case "stream:append":
handleStreamAppend(msg); break
case "stream:replace":
handleStreamReplace(msg); break
case "game:start":
await handleGameStart(msg); break
default:
console.error("unknown message type", msg)
}
}
function handleOutput(msg: Message) {
const result = msg.data as CommandResult
setStatus(msg.id!, result.status)
addOutput(msg.id!, result.output)
}
function handleStreamStart(msg: Message) {
const id = msg.id!
const status = document.querySelector(`[data-id="${id}"].input .status`)
if (!status) return
addOutput(id, msg.data as CommandOutput)
status.classList.remove("yellow")
status.classList.add("purple")
}
function handleStreamAppend(msg: Message) {
appendOutput(msg.id!, msg.data as CommandOutput)
}
function handleStreamReplace(msg: Message) {
replaceOutput(msg.id!, msg.data as CommandOutput)
}
function handleStreamEnd(_msg: Message) {
}

25
src/js/stream.ts Normal file
View File

@ -0,0 +1,25 @@
import type { Message, CommandOutput } from "@/shared/types"
import { addOutput, appendOutput, replaceOutput } from "./scrollback"
export function handleStreamStart(msg: Message) {
const id = msg.id!
const status = document.querySelector(`[data-id="${id}"].input .status`)
if (!status) return
addOutput(id, msg.data as CommandOutput)
status.classList.remove("yellow")
status.classList.add("purple")
}
export function handleStreamAppend(msg: Message) {
appendOutput(msg.id!, msg.data as CommandOutput)
}
export function handleStreamReplace(msg: Message) {
replaceOutput(msg.id!, msg.data as CommandOutput)
}
export function handleStreamEnd(_msg: Message) {
}

View File

@ -1,7 +1,7 @@
//// ////
// Fun vram counter at startup. // Fun vram counter at startup.
import { $ } from "./dom.js" import { $ } from "./dom"
const vramCounter = $("vram-size")! const vramCounter = $("vram-size")!

View File

@ -1,10 +1,10 @@
//// ////
// The terminal communicates with the shell via websockets. // The terminal communicates with the shell via websockets.
import type { Message } from "../shared/types.js" import type { Message } from "../shared/types"
import { sessionId } from "./session.js" import { sessionId } from "./session"
import { handleMessage } from "./shell.js" import { dispatchMessage } from "./dispatch"
import { addErrorMessage } from "./scrollback.js" import { addErrorMessage } from "./scrollback"
const MAX_RETRIES = 5 const MAX_RETRIES = 5
let retries = 0 let retries = 0
@ -15,7 +15,7 @@ let ws: WebSocket | null = null
// open our websocket connection // open our websocket connection
export function startConnection() { export function startConnection() {
const url = new URL('/ws', location.href) const url = new URL(`/ws?session=${sessionId}`, location.href)
url.protocol = url.protocol.replace('http', 'ws') url.protocol = url.protocol.replace('http', 'ws')
ws = new WebSocket(url) ws = new WebSocket(url)
@ -45,7 +45,7 @@ export function send(msg: Message) {
async function receive(e: MessageEvent) { async function receive(e: MessageEvent) {
const data = JSON.parse(e.data) as Message const data = JSON.parse(e.data) as Message
console.log("<- receive", data) console.log("<- receive", data)
await handleMessage(data) await dispatchMessage(data)
} }
// close it... plz don't do this, though // close it... plz don't do this, though

View File

@ -1,27 +1,37 @@
//// ////
// Helpers for working with projects in the CLI. // Helpers for working with projects in the CLI.
import { join } from "path"
import { readdirSync, type Dirent } from "fs" import { readdirSync, type Dirent } from "fs"
import { sessionGet } from "./session" import { sessionGet } from "./session"
import { appDir } from "./webapp" import { DEFAULT_PROJECT, NOSE_DIR } from "./config"
import { isDir } from "./utils"
export function projectName(): string { export function projectName(): string {
const state = sessionGet() const state = sessionGet()
if (!state) throw "no state" if (!state) throw "no state"
const project = state.project return state.project || DEFAULT_PROJECT
if (!project) throw "no project loaded"
return project
} }
export function projectDir(): string { export function projects(): string[] {
const root = appDir(projectName()) return readdirSync(NOSE_DIR, { withFileTypes: true })
if (!root) throw "error loading project" .filter(file => file.isDirectory())
.map(dir => dir.name)
.sort()
}
export function projectDir(name = projectName()): string {
const root = join(NOSE_DIR, name)
if (!isDir(root))
throw `no project found at ${root}`
return root return root
} }
export function projectFiles(): Dirent[] { export function projectBin(name = projectName()): string {
return readdirSync(projectDir(), { recursive: true, withFileTypes: true }) return join(projectDir(), "bin")
}
export function projectFiles(name = projectName()): Dirent[] {
return readdirSync(projectDir(name), { recursive: true, withFileTypes: true })
} }

View File

@ -7,7 +7,7 @@ import { prettyJSON } from "hono/pretty-json"
import color from "kleur" import color from "kleur"
import type { Message } from "./shared/types" import type { Message } from "./shared/types"
import { NOSE_ICON, NOSE_BIN, NOSE_WWW, NOSE_DATA, NOSE_DIR } from "./config" import { NOSE_ICON, NOSE_BIN, NOSE_DATA, NOSE_DIR, NOSE_ROOT_BIN } from "./config"
import { transpile, isFile, tilde, isDir } from "./utils" import { transpile, isFile, tilde, isDir } from "./utils"
import { serveApp } from "./webapp" import { serveApp } from "./webapp"
import { commands, commandPath, loadCommandModule } from "./commands" import { commands, commandPath, loadCommandModule } from "./commands"
@ -16,6 +16,7 @@ import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocke
import { initSneakers, disconnectSneakers } from "./sneaker" import { initSneakers, disconnectSneakers } from "./sneaker"
import { dispatchMessage } from "./dispatch" import { dispatchMessage } from "./dispatch"
import { fatal } from "./fatal" import { fatal } from "./fatal"
import { getState } from "./state"
import { Layout } from "./html/layout" import { Layout } from "./html/layout"
import { Terminal } from "./html/terminal" import { Terminal } from "./html/terminal"
@ -126,11 +127,16 @@ app.get("/", c => c.html(<Layout><Terminal /></Layout>))
// websocket // websocket
// //
app.get("/ws", upgradeWebSocket(async c => { app.get("/ws", c => {
return { const _sessionId = c.req.query("session")
return upgradeWebSocket(c, {
async onOpen(_e, ws) { async onOpen(_e, ws) {
addWebsocket(ws) addWebsocket(ws)
send(ws, { type: "commands", data: await commands() }) send(ws, { type: "commands", data: await commands() })
const mode = getState("ui:mode")
if (mode) send(ws, { type: "ui:mode", data: mode })
}, },
async onMessage(event, ws) { async onMessage(event, ws) {
let data: Message | undefined let data: Message | undefined
@ -148,8 +154,8 @@ app.get("/ws", upgradeWebSocket(async c => {
await dispatchMessage(ws, data) await dispatchMessage(ws, data)
}, },
onClose: (event, ws) => removeWebsocket(ws) onClose: (event, ws) => removeWebsocket(ws)
} })
})) })
// //
// hot reload mode cleanup // hot reload mode cleanup
@ -187,10 +193,10 @@ if (process.env.NODE_ENV === "production") {
// //
console.log(color.cyan(NOSE_ICON)) console.log(color.cyan(NOSE_ICON))
console.log(color.blue("NOSE_DATA:"), color.yellow(tilde(NOSE_DATA))) console.log(color.blue(" NOSE_BIN:"), color.yellow(tilde(NOSE_BIN)))
console.log(color.blue("NOSE_DIR:"), color.yellow(tilde(NOSE_DIR))) console.log(color.blue(" NOSE_DATA:"), color.yellow(tilde(NOSE_DATA)))
console.log(color.blue("NOSE_BIN:"), color.yellow(tilde(NOSE_BIN))) console.log(color.blue(" NOSE_DIR:"), color.yellow(tilde(NOSE_DIR)))
console.log(color.blue("NOSE_WWW:"), color.yellow(tilde(NOSE_WWW))) console.log(color.blue("NOSE_ROOT_BIN:"), color.yellow(tilde(NOSE_ROOT_BIN)))
await initNoseDir() await initNoseDir()
initCommands() initCommands()

View File

@ -15,9 +15,16 @@ export type Session = {
const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<Session> } const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<Session> }
export const ALS = g.__thread ??= new AsyncLocalStorage<Session>() export const ALS = g.__thread ??= new AsyncLocalStorage<Session>()
const sessions: Map<string, Session> = new Map()
export async function sessionRun(sessionId: string, fn: () => void | Promise<void>) {
const state = sessionStore(sessionId)
return await ALS.run(state, async () => fn())
}
export function sessionGet(key?: keyof Session): Session | any | undefined { export function sessionGet(key?: keyof Session): Session | any | undefined {
const store = ALS.getStore() const store = ALS.getStore()
if (!store) return if (!store) throw "sessionGet() called outside of ALS.run"
if (key) return store[key] if (key) return store[key]
@ -26,6 +33,18 @@ export function sessionGet(key?: keyof Session): Session | any | undefined {
export function sessionSet(key: keyof Session, value: any) { export function sessionSet(key: keyof Session, value: any) {
const store = ALS.getStore() const store = ALS.getStore()
if (!store) return if (!store) throw "sessionSet() called outside of ALS.run"
store[key] = value store[key] = value
} }
export function sessionStore(sessionId: string, taskId?: string, ws?: any): Session {
let state = sessions.get(sessionId)
if (!state) {
state = { sessionId: sessionId, project: "" }
sessions.set(sessionId, state)
}
if (taskId)
state.taskId = taskId
if (ws) state.ws = ws
return state
}

View File

@ -8,6 +8,7 @@ export type Message = {
export type MessageType = "error" | "input" | "output" | "commands" | "save-file" export type MessageType = "error" | "input" | "output" | "commands" | "save-file"
| "game:start" | "game:start"
| "stream:start" | "stream:end" | "stream:append" | "stream:replace" | "stream:start" | "stream:end" | "stream:append" | "stream:replace"
| "ui:mode"
export type CommandOutput = string | string[] export type CommandOutput = string | string[]
| { text: string, script?: string } | { text: string, script?: string }

View File

@ -34,3 +34,8 @@ export function randomIndex<T>(list: T[]): number | undefined {
if (!list.length) return if (!list.length) return
return rng(0, list.length - 1) return rng(0, list.length - 1)
} }
// unique([1,1,2,2,3,3]) #=> [1,2,3]
export function unique<T>(array: T[]): T[] {
return [...new Set(array)]
}

View File

@ -2,21 +2,14 @@
// Runs commands and such on the server. // Runs commands and such on the server.
// This is the "shell" - the "terminal" is the browser UI. // This is the "shell" - the "terminal" is the browser UI.
import type { CommandResult, CommandOutput } from "./shared/types" import type { CommandResult } from "./shared/types"
import type { Session } from "./session" import { sessionStore } from "./session"
import { commandExists, loadCommandModule } from "./commands" import { commandExists, loadCommandModule } from "./commands"
import { ALS } from "./session" import { ALS } from "./session"
const sessions: Map<string, Session> = new Map()
export async function runCommand(sessionId: string, taskId: string, input: string, ws?: any): Promise<CommandResult> { export async function runCommand(sessionId: string, taskId: string, input: string, ws?: any): Promise<CommandResult> {
const [cmd = "", ...args] = input.split(" ") const [cmd = "", ...args] = input.split(" ")
if (!commandExists(cmd))
return { status: "error", output: `${cmd} not found` }
return runCommandFn({ sessionId, taskId, ws }, async () => exec(cmd, args)) return runCommandFn({ sessionId, taskId, ws }, async () => exec(cmd, args))
} }
export async function runCommandFn( export async function runCommandFn(
@ -24,7 +17,7 @@ export async function runCommandFn(
fn: () => Promise<CommandResult> fn: () => Promise<CommandResult>
): Promise<CommandResult> { ): Promise<CommandResult> {
try { try {
const state = getState(sessionId, taskId, ws) const state = sessionStore(sessionId, taskId, ws)
return processExecOutput(await ALS.run(state, async () => fn())) return processExecOutput(await ALS.run(state, async () => fn()))
} catch (err) { } catch (err) {
return { status: "error", output: errorMessage(err) } return { status: "error", output: errorMessage(err) }
@ -32,6 +25,9 @@ export async function runCommandFn(
} }
async function exec(cmd: string, args: string[]): Promise<CommandResult> { async function exec(cmd: string, args: string[]): Promise<CommandResult> {
if (!commandExists(cmd))
return { status: "error", output: `${cmd} not found` }
const module = await loadCommandModule(cmd) const module = await loadCommandModule(cmd)
if (module?.game) if (module?.game)
@ -67,18 +63,6 @@ export function processExecOutput(output: string | any): CommandResult {
} }
} }
function getState(sessionId: string, taskId?: string, ws?: any): Session {
let state = sessions.get(sessionId)
if (!state) {
state = { sessionId: sessionId, project: "" }
sessions.set(sessionId, state)
}
if (taskId)
state.taskId = taskId
if (ws) state.ws = ws
return state
}
function errorMessage(error: Error | any): string { function errorMessage(error: Error | any): string {
if (!(error instanceof Error)) if (!(error instanceof Error))
return String(error) return String(error)

View File

@ -31,7 +31,6 @@ export function expectDir(path: string): boolean {
export async function expectShellCmd(cmd: string): Promise<boolean> { export async function expectShellCmd(cmd: string): Promise<boolean> {
try { try {
await $`which ${cmd}` await $`which ${cmd}`
console.log("WHICH", cmd)
return true return true
} catch { } catch {
setFatal(`Missing critical dependency: avahi-publish`) setFatal(`Missing critical dependency: avahi-publish`)

View File

@ -4,10 +4,10 @@
import type { Child } from "hono/jsx" import type { Child } from "hono/jsx"
import { type Context, Hono } from "hono" import { type Context, Hono } from "hono"
import { renderToString } from "hono/jsx/dom/server" import { renderToString } from "hono/jsx/dom/server"
import { join, dirname } from "path" import { join } from "path"
import { readdirSync } from "fs" import { readdirSync } from "fs"
import { NOSE_WWW } from "./config" import { NOSE_DIR } from "./config"
import { isFile, isDir } from "./utils" import { isFile, isDir } from "./utils"
export type Handler = (r: Context) => string | Child | Response | Promise<Response> export type Handler = (r: Context) => string | Child | Response | Promise<Response>
@ -35,39 +35,32 @@ export async function serveApp(c: Context, subdomain: string): Promise<Response>
export function apps(): string[] { export function apps(): string[] {
const apps: string[] = [] const apps: string[] = []
for (const entry of readdirSync(NOSE_WWW)) for (const entry of readdirSync(NOSE_DIR))
apps.push(entry.replace(/\.tsx?/, "")) if (isApp(entry))
apps.push(entry)
return apps.sort() return apps.sort()
} }
function isApp(name: string): boolean {
return isFile(join(NOSE_DIR, name, "index.ts"))
|| isFile(join(NOSE_DIR, name, "index.tsx"))
|| isDir(join(NOSE_DIR, name, "pub"))
}
export function appDir(name: string): string | undefined { export function appDir(name: string): string | undefined {
const path = [ if (isApp(name))
`${name}.ts`, return join(NOSE_DIR, name)
`${name}.tsx`,
name
]
.map(path => join(NOSE_WWW, path))
.flat()
.filter(path => /\.tsx?$/.test(path) ? isFile(path) : isDir(path))[0]
if (!path) return
return /\.tsx?$/.test(path) ? dirname(path) : path
} }
async function findApp(name: string): Promise<App | undefined> { async function findApp(name: string): Promise<App | undefined> {
const paths = [ const paths = [
`${name}.ts`,
`${name}.tsx`,
join(name, "index.ts"), join(name, "index.ts"),
join(name, "index.tsx") join(name, "index.tsx")
] ]
let app
for (const path of paths) { for (const path of paths) {
app = await loadApp(join(NOSE_WWW, path)) const app = await loadApp(join(NOSE_DIR, path))
if (app) return app if (app) return app
} }
} }