Compare commits

...

12 Commits

Author SHA1 Message Date
Chris Wanstrath
f06425f7ff fatal dep error 2025-10-01 11:29:57 -07:00
Chris Wanstrath
14645980af try to show error page on fatal errors 2025-10-01 11:22:28 -07:00
Chris Wanstrath
3ac1ba4f23 give nose reboot/restart access 2025-10-01 11:21:05 -07:00
Chris Wanstrath
66084337fa don't forget 2025-10-01 10:16:49 -07:00
Chris Wanstrath
4beb091ab2 build on deploy 2025-10-01 10:16:42 -07:00
Chris Wanstrath
7e3f7d8170 put git sha in ther 2025-10-01 10:14:15 -07:00
Chris Wanstrath
1d6a7bc0c1 bundle js 2025-10-01 10:12:05 -07:00
b411ce7013 choppin 2025-09-30 23:17:45 -07:00
0ba73b6dac simplify 2025-09-30 22:49:01 -07:00
6701c31a3a goals 2025-09-30 22:36:32 -07:00
3ce1bce5f0 upload command 2025-09-30 22:35:11 -07:00
329d36a878 /source/ 2025-09-30 19:19:18 -07:00
36 changed files with 1520 additions and 93 deletions

View File

@ -56,6 +56,13 @@ https://wakamaifondue.com/
- [x] public tunnel lives through reboots - [x] public tunnel lives through reboots
- [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
- [ ] "project"-based rehaul
- [ ] game/bin/www cartridges - [ ] game/bin/www cartridges
- [ ] upload files to projects - [-] pico8-style games
- [ ] pico8-style games - [x] init/update/draw
- [x] simple drawing api
- [x] gamepad support
- [ ] sounds
- [ ] maps
- [ ] etc

View File

@ -3,9 +3,10 @@
// Show some debugging information. // Show some debugging information.
import { NOSE_STARTED, NOSE_SYS_BIN, NOSE_DIR, GIT_SHA } from "@/config" import { NOSE_STARTED, NOSE_SYS_BIN, NOSE_DIR, GIT_SHA } from "@/config"
import { highlightToHTML } from "../lib/highlight"
export default function () { export default function () {
return [ return highlightToHTML([
`NODE_ENV=${process.env.NODE_ENV || "(none)"}`, `NODE_ENV=${process.env.NODE_ENV || "(none)"}`,
`BUN_HOT=${process.env.BUN_HOT || "(none)"}`, `BUN_HOT=${process.env.BUN_HOT || "(none)"}`,
`PORT=${process.env.PORT || "(none)"}`, `PORT=${process.env.PORT || "(none)"}`,
@ -15,5 +16,5 @@ export default function () {
`NOSE_SYS_BIN=${NOSE_SYS_BIN}`, `NOSE_SYS_BIN=${NOSE_SYS_BIN}`,
`NOSE_DIR=${NOSE_DIR}`, `NOSE_DIR=${NOSE_DIR}`,
`GIT_SHA=${GIT_SHA.slice(0, 8)}`, `GIT_SHA=${GIT_SHA.slice(0, 8)}`,
].join("\n") ].join("\n"))
} }

View File

@ -1,15 +1,15 @@
// Load a project so you can work on it. // Load a project so you can work on it.
import { apps } from "@/webapp" import { apps } from "@/webapp"
import { sessionGet } from "@/session" import { sessionGet, sessionSet } from "@/session"
export default function (project: string) { export default function (project: string) {
const state = sessionGet() const state = sessionGet()
if (!project) throw `usage: load <project name>` if (!project) throw `usage: load <project name>`
if (state && apps().includes(project)) { if (state && apps().includes(project)) {
state.project = project sessionSet("project", project)
state.cwd = "" sessionSet("cwd", "")
} else { } else {
return { error: `failed to load ${project}` } return { error: `failed to load ${project}` }
} }

11
bin/reboot.ts Normal file
View File

@ -0,0 +1,11 @@
export default async function reboot() {
setTimeout(async () => await Bun.$`reboot`, 1000)
console.log("REBOOTING...")
return {
text: "Rebooting... This will take about 10 seconds.",
script: `setTimeout(() => window.location.reload(), 10000)`
}
}
export const GET = reboot

11
bin/restart.ts Normal file
View File

@ -0,0 +1,11 @@
export default function restart() {
setTimeout(() => process.exit(), 1000)
console.log("RESTARTING...")
return {
text: "Restarting... This will take a second or two.",
script: `setTimeout(() => window.location.reload(), 3000)`
}
}
export const GET = restart

6
bin/session.tsx Normal file
View File

@ -0,0 +1,6 @@
import { sessionGet } from "@/session"
import { highlightToHTML } from "../lib/highlight"
export default function () {
return highlightToHTML(JSON.stringify(sessionGet(), null, 2))
}

View File

@ -1,6 +1,7 @@
import { NOSE_DATA } from "@/config"
import { join } from "path" import { join } from "path"
import { NOSE_DATA } from "@/config"
import { highlightToHTML } from "../lib/highlight"
export default async function () { export default async function () {
return JSON.parse(await Bun.file(join(NOSE_DATA, "state.json")).text()) return highlightToHTML(await Bun.file(join(NOSE_DATA, "state.json")).text())
} }

View File

@ -1,6 +1,7 @@
// Update NOSE itself and restart. // Update NOSE itself and restart.
import { $ } from "bun" import { $ } from "bun"
import restart from "./restart"
export default async function () { export default async function () {
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
@ -13,10 +14,6 @@ export default async function () {
if (/up to date/.test(out)) { if (/up to date/.test(out)) {
return "Up to date." return "Up to date."
} else { } else {
setTimeout(() => process.exit(), 1000) return restart()
return {
text: "Reloading in 3 seconds...",
script: `setTimeout(() => window.location.reload(), 3000)`
}
} }
} }

34
bin/upload.tsx Normal file
View File

@ -0,0 +1,34 @@
import { join } from "path"
import { projectDir } from "@/project"
import { sessionGet } from "@/session"
export default function () {
const project = sessionGet("project")
if (!project) return { error: "No project loaded" }
return <>
<form method="post" action="/upload">
<input type="file" name="file" required={true} />
<br />
<br />
<input type="submit" value="Upload" />
</form>
</>
}
export async function POST(c: Context) {
const cwd = sessionGet("cwd") || projectDir()
if (!cwd) throw "No project loaded"
const form = await c.req.formData()
const file = form.get("file")
if (file && file instanceof File) {
const arrayBuffer = await file.arrayBuffer()
await Bun.write(join(cwd, file.name), arrayBuffer)
return `Uploaded ${file.name}`
}
return { error: "No file received" }
}

View File

@ -42,6 +42,10 @@ export function highlight(code: string): string {
return `<style> .string { color: #C62828; } .number { color: #C4A000; } .keyword { color: #7C3AED; } .comment { color: #E91E63; } </style>` + tokens.map(t => tokenToHTML(t)).join("") return `<style> .string { color: #C62828; } .number { color: #C4A000; } .keyword { color: #7C3AED; } .comment { color: #E91E63; } </style>` + tokens.map(t => tokenToHTML(t)).join("")
} }
export function highlightToHTML(code: string): { html: string } {
return { html: `<div style='white-space: pre;'>${highlight(code)}</div>` }
}
export function tokenize(src: string): Program { export function tokenize(src: string): Program {
const tokens: Token[] = [] const tokens: Token[] = []
let i = 0 let i = 0

View File

@ -4,12 +4,16 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "bun src/server.tsx",
"runner": "env NODE_ENV=production bun run src/runner.ts",
"prod": "env NODE_ENV=production bun src/server.tsx",
"dev": "env BUN_HOT=1 bun --hot src/server.tsx", "dev": "env BUN_HOT=1 bun --hot src/server.tsx",
"start": "bun src/server.tsx",
"prod": "env NODE_ENV=production bun src/server.tsx",
"build": "./scripts/build.sh",
"runner": "env NODE_ENV=production bun run src/runner.ts",
"deploy": "./scripts/deploy.sh", "deploy": "./scripts/deploy.sh",
"push": "./scripts/deploy.sh", "push": "./scripts/deploy.sh",
"remote:install": "./scripts/remote-install.sh", "remote:install": "./scripts/remote-install.sh",
"remote:start": "./scripts/remote-start.sh", "remote:start": "./scripts/remote-start.sh",
"remote:stop": "./scripts/remote-stop.sh", "remote:stop": "./scripts/remote-stop.sh",

1113
public/bundle.js Normal file

File diff suppressed because it is too large Load Diff

8
scripts/build.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
SHA=$(git rev-parse --short HEAD)
bun build ./src/js/main.js \
--outfile ./public/bundle.js \
--target browser
printf "////\n// version: %s\n\n" "$SHA" | cat - ./public/bundle.js > ./public/bundle.tmp.js
mv ./public/bundle.tmp.js ./public/bundle.js

View File

@ -9,6 +9,14 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
# Make sure we're up-to-date # Make sure we're up-to-date
if [ -n "$(git status --porcelain)" ]; then
echo "=> You have unsaved (git) changes"
exit 1
fi
bun run build
git commit -am "update build.js"
git push origin main git push origin main
git push gitea main git push gitea main

View File

@ -50,6 +50,10 @@ sudo systemctl enable "$SERVICE_NAME"
echo ">> Starting (or restarting) $SERVICE_NAME" echo ">> Starting (or restarting) $SERVICE_NAME"
sudo systemctl restart "$SERVICE_NAME" sudo systemctl restart "$SERVICE_NAME"
echo ">> Giving NOSE reboot access"
echo "nose ALL=(ALL) NOPASSWD: /sbin/reboot" | sudo tee /etc/sudoers.d/nose-reboot
sudo chmod 440 /etc/sudoers.d/nose-reboot
echo ">> Enabling kiosk mode" echo ">> Enabling kiosk mode"
mkdir -p ~/.config/labwc mkdir -p ~/.config/labwc
cat > ~/.config/labwc/autostart <<'EOF' cat > ~/.config/labwc/autostart <<'EOF'

View File

@ -47,10 +47,16 @@ export async function commandSource(name: string): Promise<string> {
return Bun.file(path).text() return Bun.file(path).text()
} }
export async function loadCommandModule(cmd: string) {
const path = commandPath(cmd)
if (!path) return
return await import(path + "?t+" + Date.now())
}
let sysCmdWatcher let sysCmdWatcher
let usrCmdWatcher let usrCmdWatcher
function startWatchers() { function startWatchers() {
expectDir(NOSE_BIN) if (!expectDir(NOSE_BIN)) return
sysCmdWatcher = watch(NOSE_SYS_BIN, async (event, filename) => sysCmdWatcher = watch(NOSE_SYS_BIN, async (event, filename) =>
sendAll({ type: "commands", data: await commands() }) sendAll({ type: "commands", data: await commands() })

View File

@ -123,3 +123,26 @@ textarea {
color: var(--c64-light-blue); color: var(--c64-light-blue);
max-width: 97%; max-width: 97%;
} }
form {
padding: 10px;
margin: 15px;
background: var(--c64-light-blue);
}
input[type="file"]::file-selector-button {
font-family: 'C64ProMono', monospace;
color: var(--c64-dark-blue);
background: var(--white);
border-radius: 2px;
border: 2px solid #fff;
padding: 4px 8px;
cursor: pointer;
}
input[type="file"],
input[type="submit"],
button {
color: var(--c64-dark-blue);
padding: 5px;
}

View File

@ -5,6 +5,7 @@ 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_WWW } from "./config"
import { expectShellCmd } from "./utils"
export const dnsEntries: Record<string, any> = {} export const dnsEntries: Record<string, any> = {}
@ -17,6 +18,8 @@ let dnsInit = false
export async function initDNS() { export async function initDNS() {
if (process.env.NODE_ENV !== "production") return if (process.env.NODE_ENV !== "production") return
if (!await expectShellCmd("avahi-publish")) return
dnsInit = true dnsInit = true
startWatcher() startWatcher()
@ -44,7 +47,7 @@ export function publishAppDNS(app: string) {
let wwwWatcher let wwwWatcher
function startWatcher() { function startWatcher() {
expectDir(NOSE_WWW) if (!expectDir(NOSE_WWW)) return
wwwWatcher = watch(NOSE_WWW, (event, filename) => { wwwWatcher = watch(NOSE_WWW, (event, filename) => {
const www = apps() const www = apps()

10
src/fatal.ts Normal file
View File

@ -0,0 +1,10 @@
////
// We want to show a blue screen of death if NOSE has a fatal error.
export let fatal: string | undefined
export function setFatal(error: string) {
console.error(error)
if (!fatal)
fatal = error
}

60
src/html/error.tsx Normal file
View File

@ -0,0 +1,60 @@
import type { FC } from "hono/jsx"
import { css, js } from "../helpers"
export const Error: FC = async ({ error }) => (
<>
{css`
:root {
--letterbox: #CC8800;
--text: #FFBF40;
--bg: #201600;
}
* { color: var(--text); text-align: center; }
html { background: var(--letterbox); }
#content { background-color: var(--bg); }
h1 { max-width: 38%; margin: 0 auto; margin-top: 100px; }
h2 { max-width: 80%; margin: 0 auto; margin-top: 10px; }
p { max-width: 90%; margin: 0 auto; margin-top: 150px; }
.restart { max-width: 35%; }
.restart button {
font-size: 30px;
background: var(--letterbox);
color: var(--red);
}
h1 { animation: glow 3s ease-in-out infinite alternate; }
@keyframes glow {
0% {
text-shadow: none;
}
100% {
text-shadow:
0 0 2px #FFAA33,
0 0 4px #FF8800,
0 0 6px #FF6600;
}
}
`}
{js`
window.addEventListener("click", async e => {
if (!e.target || !e.target.matches("button")) return
e.target.textContent = "[RESTARTING...]"
await fetch("/cmd/restart")
setTimeout(() => window.location.reload(), 3000)
})
`}
<h1>Fatal Error</h1>
<h2>NOSE failed to start properly.</h2>
<br />
<p>{error}</p>
<p class="restart">
<button>[RESTART]</button>
</p>
</>
)

View File

@ -12,7 +12,7 @@ export const Layout: FC = async ({ children, title }) => (
<link href="/css/game.css" rel="stylesheet" /> <link href="/css/game.css" rel="stylesheet" />
<script type="importmap" dangerouslySetInnerHTML={{ __html: `{ "imports": { "@/": "/" } }` }} /> <script type="importmap" dangerouslySetInnerHTML={{ __html: `{ "imports": { "@/": "/" } }` }} />
<script src="/js/main.js" type="module" async></script> <script src={process.env.NODE_ENV === "production" ? "/bundle.js" : "/js/main.js"} type="module" async></script>
</head> </head>
<body data-mode="tall"> <body data-mode="tall">
<main> <main>

View File

@ -4,11 +4,12 @@
import { scrollback } from "./dom.js" import { scrollback } from "./dom.js"
import { resize } from "./resize.js" import { resize } from "./resize.js"
import { autoScroll } from "./scrollback.js" import { autoScroll } from "./scrollback.js"
import { sessionID } from "./session.js" import { sessionId } from "./session.js"
export const commands: string[] = [] export const commands: string[] = []
export const browserCommands: Record<string, () => any> = { export const browserCommands: Record<string, () => any> = {
"browser-session": () => sessionId,
clear: () => scrollback.innerHTML = "", clear: () => scrollback.innerHTML = "",
commands: () => commands.join(" "), commands: () => commands.join(" "),
fullscreen: () => document.body.requestFullscreen(), fullscreen: () => document.body.requestFullscreen(),
@ -18,7 +19,6 @@ export const browserCommands: Record<string, () => any> = {
autoScroll() autoScroll()
}, },
reload: () => window.location.reload(), reload: () => window.location.reload(),
session: () => sessionID,
} }
export function cacheCommands(cmds: string[]) { export function cacheCommands(cmds: string[]) {

View File

@ -15,7 +15,6 @@ function handleCompletion(e: KeyboardEvent) {
const input = cmdInput.value const input = cmdInput.value
for (const command of commands) { for (const command of commands) {
console.log(input, command)
if (command.startsWith(input)) { if (command.startsWith(input)) {
cmdInput.value = command cmdInput.value = command
return return

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

@ -0,0 +1,25 @@
export function initDrop() {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.body.addEventListener(eventName, preventDefaults, false);
})
document.body.addEventListener("drop", handleDrop)
}
function preventDefaults(e: Event) {
e.preventDefault()
e.stopPropagation()
}
function handleDrop(e: DragEvent) {
const fileInput = document.querySelector("input[type=file]") as HTMLInputElement
const files = e.dataTransfer?.files ?? []
if (files.length > 0) {
const dt = new DataTransfer()
Array.from(files).forEach(f => dt.items.add(f))
fileInput.files = dt.files
fileInput.dispatchEvent(new Event('change', { bubbles: true }))
}
}

48
src/js/form.ts Normal file
View File

@ -0,0 +1,48 @@
////
// All forms are submitted via ajax.
import type { CommandResult, CommandOutput } from "../shared/types.js"
import { sessionId } from "./session.js"
import { setStatus, replaceOutput } from "./scrollback.js"
import { focusInput } from "./focus.js"
export function initForm() {
document.addEventListener("submit", submitHandler)
}
export const submitHandler = async (e: SubmitEvent) => {
e.preventDefault()
const form = e.target
if (!(form instanceof HTMLFormElement)) return
const li = form.closest(".output")
if (!(li instanceof HTMLLIElement)) return
const id = li.dataset.id
if (!id) return
let output: CommandOutput
let error = false
try {
const fd = new FormData(form)
const data: CommandResult = await fetch("/cmd" + new URL(form.action).pathname, {
method: "POST",
headers: { "X-Session": sessionId }, // don't set Content-Type manually
body: fd
}).then(r => r.json())
output = data.output
error = data.status === "error"
} catch (e: any) {
output = e.message || e.toString()
error = true
}
if (error) setStatus(id, "error")
replaceOutput(id, output)
focusInput()
}

View File

@ -34,7 +34,7 @@ export async function handleGameStart(msg: Message) {
let game let game
try { try {
game = await import(`/command/${name}`) game = await import(`/source/${name}`)
} catch (err: any) { } catch (err: any) {
setStatus(msgId, "error") setStatus(msgId, "error")
addOutput(msgId, `Error: ${err.message ? err.message : err}`) addOutput(msgId, `Error: ${err.message ? err.message : err}`)

View File

@ -40,7 +40,6 @@ function navigateHistory(e: KeyboardEvent) {
} else if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { } else if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) {
e.preventDefault() e.preventDefault()
console.log(idx, savedInput)
if (idx <= 0) { if (idx <= 0) {
cmdInput.value = savedInput cmdInput.value = savedInput
idx = -1 idx = -1

View File

@ -1,7 +1,9 @@
import { initCompletion } from "./completion.js" import { initCompletion } from "./completion.js"
import { initCursor } from "./cursor.js" import { initCursor } from "./cursor.js"
import { initDrop } from "./drop.js"
import { initEditor } from "./editor.js" import { initEditor } from "./editor.js"
import { initFocus } from "./focus.js" import { initFocus } from "./focus.js"
import { initForm } from "./form.js"
import { initGamepad } from "./gamepad.js" import { initGamepad } from "./gamepad.js"
import { initHistory } from "./history.js" import { initHistory } from "./history.js"
import { initHyperlink } from "./hyperlink.js" import { initHyperlink } from "./hyperlink.js"
@ -13,7 +15,9 @@ import { startConnection } from "./websocket.js"
initCompletion() initCompletion()
initCursor() initCursor()
initDrop()
initFocus() initFocus()
initForm()
initEditor() initEditor()
initGamepad() initGamepad()
initHistory() initHistory()

View File

@ -36,22 +36,15 @@ export function setStatus(id: string, status: InputStatus) {
const statusEl = document.querySelector(`[data-id="${id}"].input .status`) const statusEl = document.querySelector(`[data-id="${id}"].input .status`)
if (!statusEl) return if (!statusEl) return
statusEl.className = "" const colors = {
waiting: "yellow",
switch (status) { streaming: "purple",
case "waiting": ok: "green",
statusEl.classList.add("yellow") error: "red"
break
case "streaming":
statusEl.classList.add("purple")
break
case "ok":
statusEl.classList.add("green")
break
case "error":
statusEl.classList.add("red")
break
} }
statusEl.classList.remove(...Object.values(colors))
statusEl.classList.add(colors[status])
} }
export function addOutput(id: string, output: CommandOutput) { export function addOutput(id: string, output: CommandOutput) {
@ -80,7 +73,6 @@ export function addErrorMessage(message: string) {
addOutput("", { html: `<span class="red">${message}</span>` }) addOutput("", { html: `<span class="red">${message}</span>` })
} }
export function appendOutput(id: string, output: CommandOutput) { export function appendOutput(id: string, output: CommandOutput) {
const item = document.querySelector(`[data-id="${id}"].output`) const item = document.querySelector(`[data-id="${id}"].output`)
@ -125,14 +117,14 @@ function processOutput(output: CommandOutput): ["html" | "text", string] {
content = output content = output
} else if (Array.isArray(output)) { } else if (Array.isArray(output)) {
content = output.join(" ") content = output.join(" ")
} else if ("html" in output) { } else if (typeof output === "object" && "html" in output) {
html = true html = true
content = output.html content = output.html
if (output.script) eval(output.script) if (output.script) eval(output.script)
} else if ("text" in output) { } else if (typeof output === "object" && "text" in output) {
content = output.text content = output.text
if (output.script) eval(output.script) if (output.script) eval(output.script)
} else if ("script" in output) { } else if (typeof output === "object" && "script" in output) {
eval(output.script!) eval(output.script!)
} else { } else {
content = JSON.stringify(output) content = JSON.stringify(output)

View File

@ -4,4 +4,4 @@
import { randomId } from "../shared/utils.js" import { randomId } from "../shared/utils.js"
export const sessionID = randomId() export const sessionId = randomId()

View File

@ -2,7 +2,7 @@
// 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.js"
import { sessionID } from "./session.js" import { sessionId } from "./session.js"
import { handleMessage } from "./shell.js" import { handleMessage } from "./shell.js"
import { addErrorMessage } from "./scrollback.js" import { addErrorMessage } from "./scrollback.js"
@ -37,7 +37,7 @@ export function send(msg: Message) {
return return
} }
if (!msg.session) msg.session = sessionID if (!msg.session) msg.session = sessionId
ws?.readyState === 1 && ws.send(JSON.stringify(msg)) ws?.readyState === 1 && ws.send(JSON.stringify(msg))
console.log("-> send", msg) console.log("-> send", msg)
} }
@ -57,11 +57,11 @@ function retryConnection() {
connected = false connected = false
if (retries >= MAX_RETRIES) { if (retries >= MAX_RETRIES) {
addErrorMessage(`!! Failed to reconnect ${retries} times. Server is down.`) addErrorMessage(`Failed to reconnect ${retries} times. Server is down.`)
if (ws) ws.onclose = () => { } if (ws) ws.onclose = () => { }
return return
} }
retries++ retries++
addErrorMessage(`!! Connection lost. Retrying...`) addErrorMessage(`Connection lost. Retrying...`)
setTimeout(startConnection, 2000) setTimeout(startConnection, 2000)
} }

View File

@ -3,12 +3,13 @@
import { $ } from "bun" import { $ } from "bun"
import { NOSE_DIR } from "./config" import { NOSE_DIR } from "./config"
import { isDir } from "./utils" import { expectDir } from "./utils"
// Make NOSE_DIR if it doesn't exist // Make NOSE_DIR if it doesn't exist
export async function initNoseDir() { export async function initNoseDir() {
if (isDir(NOSE_DIR)) return if (expectDir(NOSE_DIR)) return
await $`cp -r ./nose ${NOSE_DIR}` await $`cp -r ./nose ${NOSE_DIR}`
expectDir(NOSE_DIR)
} }

View File

@ -8,16 +8,20 @@ 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_WWW, NOSE_DATA, NOSE_DIR } from "./config"
import { transpile, isFile, tilde } from "./utils" import { transpile, isFile, tilde, isDir } from "./utils"
import { serveApp } from "./webapp" import { serveApp } from "./webapp"
import { initDNS } from "./dns" import { commands, commandPath, loadCommandModule } from "./commands"
import { commands, commandPath } from "./commands" import { runCommandFn } from "./shell"
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket" import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
import { initSneakers, disconnectSneakers } from "./sneaker"
import { dispatchMessage } from "./dispatch"
import { fatal } from "./fatal"
import { Layout } from "./html/layout" import { Layout } from "./html/layout"
import { Terminal } from "./html/terminal" import { Terminal } from "./html/terminal"
import { dispatchMessage } from "./dispatch" import { Error } from "./html/error"
import { initSneakers, disconnectSneakers } from "./sneaker"
import { initDNS } from "./dns"
import { initNoseDir } from "./nosedir" import { initNoseDir } from "./nosedir"
import { initCommands } from "./commands" import { initCommands } from "./commands"
@ -29,6 +33,7 @@ const app = new Hono()
app.use("*", prettyJSON()) app.use("*", prettyJSON())
app.use('/*', serveStatic({ root: './public' }))
app.use('/img/*', serveStatic({ root: './public' })) app.use('/img/*', serveStatic({ root: './public' }))
app.use('/vendor/*', serveStatic({ root: './public' })) app.use('/vendor/*', serveStatic({ root: './public' }))
app.use('/css/*', serveStatic({ root: './src' })) app.use('/css/*', serveStatic({ root: './src' }))
@ -41,6 +46,16 @@ app.use("*", async (c, next) => {
console.log(fn(`${c.res.status} ${c.req.method} ${c.req.url} (${end - start}ms)`)) console.log(fn(`${c.res.status} ${c.req.method} ${c.req.url} (${end - start}ms)`))
}) })
app.use("*", async (c, next) => {
const error = fatal ? fatal : !isDir(NOSE_DIR) ? `NOSE_DIR doesn't exist: ${NOSE_DIR}` : undefined
if (!error || (["/cmd/restart", "/cmd/reboot"].includes(c.req.path))) {
await next()
} else {
return c.html(<Layout><Error error={error} /></Layout>, 500)
}
})
app.on("GET", ["/js/:path{.+}", "/shared/:path{.+}"], async c => { app.on("GET", ["/js/:path{.+}", "/shared/:path{.+}"], async c => {
let path = "./src/" + c.req.path.replace("..", ".") let path = "./src/" + c.req.path.replace("..", ".")
@ -78,7 +93,7 @@ app.use("*", async (c, next) => {
return next() return next()
}) })
app.get("/command/:name", async c => { app.get("/source/:name", async c => {
const name = c.req.param("name") const name = c.req.param("name")
const path = commandPath(name) const path = commandPath(name)
if (!path) return c.text("Command not found", 404) if (!path) return c.text("Command not found", 404)
@ -89,6 +104,22 @@ app.get("/command/:name", async c => {
}) })
}) })
app.on(["GET", "POST"], ["/cmd/:name"], async c => {
const sessionId = c.req.header("X-Session") || "0"
const cmd = c.req.param("name")
const method = c.req.method
try {
const mod = await loadCommandModule(cmd)
if (!mod || !mod[method])
return c.json({ status: "error", output: `No ${method} export in ${cmd}` }, 500)
return c.json(await runCommandFn({ sessionId }, async () => mod[method](c)))
} catch (e: any) {
return c.json({ status: "error", output: e.message || e.toString() }, 500)
}
})
app.get("/", c => c.html(<Layout><Terminal /></Layout>)) app.get("/", c => c.html(<Layout><Terminal /></Layout>))
// //

View File

@ -4,7 +4,7 @@
import type { CommandResult, CommandOutput } from "./shared/types" import type { CommandResult, CommandOutput } from "./shared/types"
import type { Session } from "./session" import type { Session } from "./session"
import { commandExists, commandPath } from "./commands" import { commandExists, loadCommandModule } from "./commands"
import { ALS } from "./session" import { ALS } from "./session"
const sessions: Map<string, Session> = new Map() const sessions: Map<string, Session> = new Map()
@ -15,59 +15,62 @@ export async function runCommand(sessionId: string, taskId: string, input: strin
if (!commandExists(cmd)) if (!commandExists(cmd))
return { status: "error", output: `${cmd} not found` } return { status: "error", output: `${cmd} not found` }
let status: "ok" | "error" = "ok" return runCommandFn({ sessionId, taskId, ws }, async () => exec(cmd, args))
let output: CommandOutput = ""
const state = getState(sessionId, taskId, ws)
try {
[status, output] = await ALS.run(state, async () => await exec(cmd, args))
} catch (err) {
status = "error"
output = errorMessage(err)
}
return { status, output }
} }
async function exec(cmd: string, args: string[]): Promise<["ok" | "error", CommandOutput]> { export async function runCommandFn(
const module = await import(commandPath(cmd) + "?t+" + Date.now()) { sessionId, taskId, ws }: { sessionId: string, taskId?: string, ws?: any },
fn: () => Promise<CommandResult>
): Promise<CommandResult> {
try {
const state = getState(sessionId, taskId, ws)
return processExecOutput(await ALS.run(state, async () => fn()))
} catch (err) {
return { status: "error", output: errorMessage(err) }
}
}
async function exec(cmd: string, args: string[]): Promise<CommandResult> {
const module = await loadCommandModule(cmd)
if (module?.game) if (module?.game)
return ["ok", { game: cmd }] return { status: "ok", output: { game: cmd } }
if (!module || !module.default) if (!module || !module.default)
return ["error", `${cmd} has no default export`] return { status: "error", output: `${cmd} has no default export` }
return processExecOutput(await module.default(...args)) return await module.default(...args)
} }
export function processExecOutput(output: string | any): ["ok" | "error", CommandOutput] { export function processExecOutput(output: string | any): CommandResult {
if (typeof output === "string") { if (typeof output === "string") {
return ["ok", output] return { status: "ok", output }
} else if (typeof output === "object") { } else if (typeof output === "object") {
if (output.error) { if (output.error) {
return ["error", output.error] return { status: "error", output: output.error }
} else if (isJSX(output)) { } else if (isJSX(output)) {
return ["ok", { html: output.toString() }] return { status: "ok", output: { html: output.toString() } }
} else if (output.html && isJSX(output.html)) { } else if (output.html && isJSX(output.html)) {
output.html = output.html.toString() output.html = output.html.toString()
return ["ok", output] return { status: "ok", output }
} else { } else {
return ["ok", output] return { status: "ok", output }
} }
} else if (output === undefined) { } else if (output === undefined) {
return ["ok", ""] return { status: "ok", output: "" }
} else { } else {
return ["ok", String(output)] return { status: "ok", output: String(output) }
} }
} }
function getState(sessionId: string, taskId: string, ws?: any): Session { function getState(sessionId: string, taskId?: string, ws?: any): Session {
let state = sessions.get(sessionId) let state = sessions.get(sessionId)
if (!state) { if (!state) {
state = { sessionId: sessionId, project: "" } state = { sessionId: sessionId, project: "" }
sessions.set(sessionId, state) sessions.set(sessionId, state)
} }
if (taskId)
state.taskId = taskId state.taskId = taskId
if (ws) state.ws = ws if (ws) state.ws = ws
return state return state

View File

@ -26,7 +26,6 @@ export function initSneakers() {
for (const key in state) { for (const key in state) {
if (key.startsWith(PREFIX)) { if (key.startsWith(PREFIX)) {
const app = key.replace(PREFIX, "") const app = key.replace(PREFIX, "")
console.log("sharing", app, state[key])
connectSneaker(app, state[key]) connectSneaker(app, state[key])
} }
} }

View File

@ -1,8 +1,9 @@
//// ////
// Shell utilities and helper functions. // Shell utilities and helper functions.
import { $ } from "bun"
import { statSync } from "fs" import { statSync } from "fs"
import { basename } from "path" import { setFatal } from "./fatal"
import { stat } from "fs/promises" import { stat } from "fs/promises"
// Convert /Users/$USER or /home/$USER to ~ for simplicity // Convert /Users/$USER or /home/$USER to ~ for simplicity
@ -16,11 +17,25 @@ export function untilde(path: string): string {
return path.replace("~", `/${prefix}/${process.env.USER}`) return path.replace("~", `/${prefix}/${process.env.USER}`)
} }
// End the process with an instructive error if a directory doesn't exist. // Cause a fatal error if a directory doesn't exist.
export function expectDir(path: string) { export function expectDir(path: string): boolean {
if (!isDir(path)) { if (!isDir(path)) {
console.error(`No ${basename(path)} directory detected.`) setFatal(`Missing critical directory: ${path}`)
process.exit(1) return false
}
return true
}
// Cause a fatal error if a system binary doesn't exist.
export async function expectShellCmd(cmd: string): Promise<boolean> {
try {
await $`which ${cmd}`
console.log("WHICH", cmd)
return true
} catch {
setFatal(`Missing critical dependency: avahi-publish`)
return false
} }
} }