Compare commits
12 Commits
f828384dba
...
f06425f7ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f06425f7ff | ||
|
|
14645980af | ||
|
|
3ac1ba4f23 | ||
|
|
66084337fa | ||
|
|
4beb091ab2 | ||
|
|
7e3f7d8170 | ||
|
|
1d6a7bc0c1 | ||
| b411ce7013 | |||
| 0ba73b6dac | |||
| 6701c31a3a | |||
| 3ce1bce5f0 | |||
| 329d36a878 |
11
README.md
11
README.md
|
|
@ -56,6 +56,13 @@ https://wakamaifondue.com/
|
|||
- [x] public tunnel lives through reboots
|
||||
- [x] self updating NOSE server
|
||||
- [x] `pub/` static hosting in webapps
|
||||
- [x] upload files to projects
|
||||
- [ ] "project"-based rehaul
|
||||
- [ ] 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
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
// Show some debugging information.
|
||||
|
||||
import { NOSE_STARTED, NOSE_SYS_BIN, NOSE_DIR, GIT_SHA } from "@/config"
|
||||
import { highlightToHTML } from "../lib/highlight"
|
||||
|
||||
export default function () {
|
||||
return [
|
||||
return highlightToHTML([
|
||||
`NODE_ENV=${process.env.NODE_ENV || "(none)"}`,
|
||||
`BUN_HOT=${process.env.BUN_HOT || "(none)"}`,
|
||||
`PORT=${process.env.PORT || "(none)"}`,
|
||||
|
|
@ -15,5 +16,5 @@ export default function () {
|
|||
`NOSE_SYS_BIN=${NOSE_SYS_BIN}`,
|
||||
`NOSE_DIR=${NOSE_DIR}`,
|
||||
`GIT_SHA=${GIT_SHA.slice(0, 8)}`,
|
||||
].join("\n")
|
||||
].join("\n"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
// Load a project so you can work on it.
|
||||
|
||||
import { apps } from "@/webapp"
|
||||
import { sessionGet } from "@/session"
|
||||
import { sessionGet, sessionSet } from "@/session"
|
||||
|
||||
export default function (project: string) {
|
||||
const state = sessionGet()
|
||||
if (!project) throw `usage: load <project name>`
|
||||
|
||||
if (state && apps().includes(project)) {
|
||||
state.project = project
|
||||
state.cwd = ""
|
||||
sessionSet("project", project)
|
||||
sessionSet("cwd", "")
|
||||
} else {
|
||||
return { error: `failed to load ${project}` }
|
||||
}
|
||||
|
|
|
|||
11
bin/reboot.ts
Normal file
11
bin/reboot.ts
Normal 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
11
bin/restart.ts
Normal 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
6
bin/session.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { sessionGet } from "@/session"
|
||||
import { highlightToHTML } from "../lib/highlight"
|
||||
|
||||
export default function () {
|
||||
return highlightToHTML(JSON.stringify(sessionGet(), null, 2))
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { NOSE_DATA } from "@/config"
|
||||
import { join } from "path"
|
||||
import { NOSE_DATA } from "@/config"
|
||||
import { highlightToHTML } from "../lib/highlight"
|
||||
|
||||
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())
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Update NOSE itself and restart.
|
||||
|
||||
import { $ } from "bun"
|
||||
import restart from "./restart"
|
||||
|
||||
export default async function () {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
|
|
@ -13,10 +14,6 @@ export default async function () {
|
|||
if (/up to date/.test(out)) {
|
||||
return "Up to date."
|
||||
} else {
|
||||
setTimeout(() => process.exit(), 1000)
|
||||
return {
|
||||
text: "Reloading in 3 seconds...",
|
||||
script: `setTimeout(() => window.location.reload(), 3000)`
|
||||
}
|
||||
return restart()
|
||||
}
|
||||
}
|
||||
34
bin/upload.tsx
Normal file
34
bin/upload.tsx
Normal 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" }
|
||||
}
|
||||
|
|
@ -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("")
|
||||
}
|
||||
|
||||
export function highlightToHTML(code: string): { html: string } {
|
||||
return { html: `<div style='white-space: pre;'>${highlight(code)}</div>` }
|
||||
}
|
||||
|
||||
export function tokenize(src: string): Program {
|
||||
const tokens: Token[] = []
|
||||
let i = 0
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -4,12 +4,16 @@
|
|||
"type": "module",
|
||||
"private": true,
|
||||
"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",
|
||||
"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",
|
||||
"push": "./scripts/deploy.sh",
|
||||
|
||||
"remote:install": "./scripts/remote-install.sh",
|
||||
"remote:start": "./scripts/remote-start.sh",
|
||||
"remote:stop": "./scripts/remote-stop.sh",
|
||||
|
|
@ -21,4 +25,4 @@
|
|||
"dependencies": {
|
||||
"kleur": "^4.1.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1113
public/bundle.js
Normal file
1113
public/bundle.js
Normal file
File diff suppressed because it is too large
Load Diff
8
scripts/build.sh
Executable file
8
scripts/build.sh
Executable 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
|
||||
|
|
@ -9,6 +9,14 @@ ROOT_DIR="$SCRIPT_DIR/.."
|
|||
source "$ROOT_DIR/scripts/config.sh"
|
||||
|
||||
# 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 gitea main
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ sudo systemctl enable "$SERVICE_NAME"
|
|||
echo ">> Starting (or restarting) $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"
|
||||
mkdir -p ~/.config/labwc
|
||||
cat > ~/.config/labwc/autostart <<'EOF'
|
||||
|
|
|
|||
|
|
@ -47,10 +47,16 @@ export async function commandSource(name: string): Promise<string> {
|
|||
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 usrCmdWatcher
|
||||
function startWatchers() {
|
||||
expectDir(NOSE_BIN)
|
||||
if (!expectDir(NOSE_BIN)) return
|
||||
|
||||
sysCmdWatcher = watch(NOSE_SYS_BIN, async (event, filename) =>
|
||||
sendAll({ type: "commands", data: await commands() })
|
||||
|
|
|
|||
|
|
@ -122,4 +122,27 @@ body[data-mode=tall] #content {
|
|||
textarea {
|
||||
color: var(--c64-light-blue);
|
||||
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;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { watch } from "fs"
|
|||
import { apps } from "./webapp"
|
||||
import { expectDir } from "./utils"
|
||||
import { NOSE_WWW } from "./config"
|
||||
import { expectShellCmd } from "./utils"
|
||||
|
||||
export const dnsEntries: Record<string, any> = {}
|
||||
|
||||
|
|
@ -17,6 +18,8 @@ let dnsInit = false
|
|||
|
||||
export async function initDNS() {
|
||||
if (process.env.NODE_ENV !== "production") return
|
||||
if (!await expectShellCmd("avahi-publish")) return
|
||||
|
||||
dnsInit = true
|
||||
|
||||
startWatcher()
|
||||
|
|
@ -44,7 +47,7 @@ export function publishAppDNS(app: string) {
|
|||
|
||||
let wwwWatcher
|
||||
function startWatcher() {
|
||||
expectDir(NOSE_WWW)
|
||||
if (!expectDir(NOSE_WWW)) return
|
||||
|
||||
wwwWatcher = watch(NOSE_WWW, (event, filename) => {
|
||||
const www = apps()
|
||||
|
|
|
|||
10
src/fatal.ts
Normal file
10
src/fatal.ts
Normal 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
60
src/html/error.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
|
|
@ -12,7 +12,7 @@ export const Layout: FC = async ({ children, title }) => (
|
|||
<link href="/css/game.css" rel="stylesheet" />
|
||||
|
||||
<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>
|
||||
<body data-mode="tall">
|
||||
<main>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@
|
|||
import { scrollback } from "./dom.js"
|
||||
import { resize } from "./resize.js"
|
||||
import { autoScroll } from "./scrollback.js"
|
||||
import { sessionID } from "./session.js"
|
||||
import { sessionId } from "./session.js"
|
||||
|
||||
export const commands: string[] = []
|
||||
|
||||
export const browserCommands: Record<string, () => any> = {
|
||||
"browser-session": () => sessionId,
|
||||
clear: () => scrollback.innerHTML = "",
|
||||
commands: () => commands.join(" "),
|
||||
fullscreen: () => document.body.requestFullscreen(),
|
||||
|
|
@ -18,7 +19,6 @@ export const browserCommands: Record<string, () => any> = {
|
|||
autoScroll()
|
||||
},
|
||||
reload: () => window.location.reload(),
|
||||
session: () => sessionID,
|
||||
}
|
||||
|
||||
export function cacheCommands(cmds: string[]) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ function handleCompletion(e: KeyboardEvent) {
|
|||
const input = cmdInput.value
|
||||
|
||||
for (const command of commands) {
|
||||
console.log(input, command)
|
||||
if (command.startsWith(input)) {
|
||||
cmdInput.value = command
|
||||
return
|
||||
|
|
|
|||
25
src/js/drop.ts
Normal file
25
src/js/drop.ts
Normal 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
48
src/js/form.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ export async function handleGameStart(msg: Message) {
|
|||
|
||||
let game
|
||||
try {
|
||||
game = await import(`/command/${name}`)
|
||||
game = await import(`/source/${name}`)
|
||||
} catch (err: any) {
|
||||
setStatus(msgId, "error")
|
||||
addOutput(msgId, `Error: ${err.message ? err.message : err}`)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ function navigateHistory(e: KeyboardEvent) {
|
|||
} else if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) {
|
||||
e.preventDefault()
|
||||
|
||||
console.log(idx, savedInput)
|
||||
if (idx <= 0) {
|
||||
cmdInput.value = savedInput
|
||||
idx = -1
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { initCompletion } from "./completion.js"
|
||||
import { initCursor } from "./cursor.js"
|
||||
import { initDrop } from "./drop.js"
|
||||
import { initEditor } from "./editor.js"
|
||||
import { initFocus } from "./focus.js"
|
||||
import { initForm } from "./form.js"
|
||||
import { initGamepad } from "./gamepad.js"
|
||||
import { initHistory } from "./history.js"
|
||||
import { initHyperlink } from "./hyperlink.js"
|
||||
|
|
@ -13,7 +15,9 @@ import { startConnection } from "./websocket.js"
|
|||
|
||||
initCompletion()
|
||||
initCursor()
|
||||
initDrop()
|
||||
initFocus()
|
||||
initForm()
|
||||
initEditor()
|
||||
initGamepad()
|
||||
initHistory()
|
||||
|
|
|
|||
|
|
@ -36,22 +36,15 @@ export function setStatus(id: string, status: InputStatus) {
|
|||
const statusEl = document.querySelector(`[data-id="${id}"].input .status`)
|
||||
if (!statusEl) return
|
||||
|
||||
statusEl.className = ""
|
||||
|
||||
switch (status) {
|
||||
case "waiting":
|
||||
statusEl.classList.add("yellow")
|
||||
break
|
||||
case "streaming":
|
||||
statusEl.classList.add("purple")
|
||||
break
|
||||
case "ok":
|
||||
statusEl.classList.add("green")
|
||||
break
|
||||
case "error":
|
||||
statusEl.classList.add("red")
|
||||
break
|
||||
const colors = {
|
||||
waiting: "yellow",
|
||||
streaming: "purple",
|
||||
ok: "green",
|
||||
error: "red"
|
||||
}
|
||||
|
||||
statusEl.classList.remove(...Object.values(colors))
|
||||
statusEl.classList.add(colors[status])
|
||||
}
|
||||
|
||||
export function addOutput(id: string, output: CommandOutput) {
|
||||
|
|
@ -80,7 +73,6 @@ export function addErrorMessage(message: string) {
|
|||
addOutput("", { html: `<span class="red">${message}</span>` })
|
||||
}
|
||||
|
||||
|
||||
export function appendOutput(id: string, output: CommandOutput) {
|
||||
const item = document.querySelector(`[data-id="${id}"].output`)
|
||||
|
||||
|
|
@ -125,14 +117,14 @@ function processOutput(output: CommandOutput): ["html" | "text", string] {
|
|||
content = output
|
||||
} else if (Array.isArray(output)) {
|
||||
content = output.join(" ")
|
||||
} else if ("html" in output) {
|
||||
} else if (typeof output === "object" && "html" in output) {
|
||||
html = true
|
||||
content = output.html
|
||||
if (output.script) eval(output.script)
|
||||
} else if ("text" in output) {
|
||||
} else if (typeof output === "object" && "text" in output) {
|
||||
content = output.text
|
||||
if (output.script) eval(output.script)
|
||||
} else if ("script" in output) {
|
||||
} else if (typeof output === "object" && "script" in output) {
|
||||
eval(output.script!)
|
||||
} else {
|
||||
content = JSON.stringify(output)
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@
|
|||
|
||||
import { randomId } from "../shared/utils.js"
|
||||
|
||||
export const sessionID = randomId()
|
||||
export const sessionId = randomId()
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// The terminal communicates with the shell via websockets.
|
||||
|
||||
import type { Message } from "../shared/types.js"
|
||||
import { sessionID } from "./session.js"
|
||||
import { sessionId } from "./session.js"
|
||||
import { handleMessage } from "./shell.js"
|
||||
import { addErrorMessage } from "./scrollback.js"
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ export function send(msg: Message) {
|
|||
return
|
||||
}
|
||||
|
||||
if (!msg.session) msg.session = sessionID
|
||||
if (!msg.session) msg.session = sessionId
|
||||
ws?.readyState === 1 && ws.send(JSON.stringify(msg))
|
||||
console.log("-> send", msg)
|
||||
}
|
||||
|
|
@ -57,11 +57,11 @@ function retryConnection() {
|
|||
connected = false
|
||||
|
||||
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 = () => { }
|
||||
return
|
||||
}
|
||||
retries++
|
||||
addErrorMessage(`!! Connection lost. Retrying...`)
|
||||
addErrorMessage(`Connection lost. Retrying...`)
|
||||
setTimeout(startConnection, 2000)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
|
||||
import { $ } from "bun"
|
||||
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() {
|
||||
if (isDir(NOSE_DIR)) return
|
||||
if (expectDir(NOSE_DIR)) return
|
||||
|
||||
await $`cp -r ./nose ${NOSE_DIR}`
|
||||
expectDir(NOSE_DIR)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,16 +8,20 @@ import color from "kleur"
|
|||
|
||||
import type { Message } from "./shared/types"
|
||||
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 { initDNS } from "./dns"
|
||||
import { commands, commandPath } from "./commands"
|
||||
import { commands, commandPath, loadCommandModule } from "./commands"
|
||||
import { runCommandFn } from "./shell"
|
||||
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 { Terminal } from "./html/terminal"
|
||||
import { dispatchMessage } from "./dispatch"
|
||||
import { initSneakers, disconnectSneakers } from "./sneaker"
|
||||
import { Error } from "./html/error"
|
||||
|
||||
import { initDNS } from "./dns"
|
||||
import { initNoseDir } from "./nosedir"
|
||||
import { initCommands } from "./commands"
|
||||
|
||||
|
|
@ -29,6 +33,7 @@ const app = new Hono()
|
|||
|
||||
app.use("*", prettyJSON())
|
||||
|
||||
app.use('/*', serveStatic({ root: './public' }))
|
||||
app.use('/img/*', serveStatic({ root: './public' }))
|
||||
app.use('/vendor/*', serveStatic({ root: './public' }))
|
||||
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)`))
|
||||
})
|
||||
|
||||
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 => {
|
||||
let path = "./src/" + c.req.path.replace("..", ".")
|
||||
|
||||
|
|
@ -78,7 +93,7 @@ app.use("*", async (c, next) => {
|
|||
return next()
|
||||
})
|
||||
|
||||
app.get("/command/:name", async c => {
|
||||
app.get("/source/:name", async c => {
|
||||
const name = c.req.param("name")
|
||||
const path = commandPath(name)
|
||||
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>))
|
||||
|
||||
//
|
||||
|
|
@ -144,7 +175,7 @@ if (process.env.BUN_HOT) {
|
|||
}
|
||||
|
||||
//
|
||||
// production mode
|
||||
// production mode
|
||||
//
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
|
|
|
|||
57
src/shell.ts
57
src/shell.ts
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import type { CommandResult, CommandOutput } from "./shared/types"
|
||||
import type { Session } from "./session"
|
||||
import { commandExists, commandPath } from "./commands"
|
||||
import { commandExists, loadCommandModule } from "./commands"
|
||||
import { ALS } from "./session"
|
||||
|
||||
const sessions: Map<string, Session> = new Map()
|
||||
|
|
@ -15,60 +15,63 @@ export async function runCommand(sessionId: string, taskId: string, input: strin
|
|||
if (!commandExists(cmd))
|
||||
return { status: "error", output: `${cmd} not found` }
|
||||
|
||||
let status: "ok" | "error" = "ok"
|
||||
let output: CommandOutput = ""
|
||||
const state = getState(sessionId, taskId, ws)
|
||||
return runCommandFn({ sessionId, taskId, ws }, async () => exec(cmd, args))
|
||||
|
||||
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]> {
|
||||
const module = await import(commandPath(cmd) + "?t+" + Date.now())
|
||||
export async function runCommandFn(
|
||||
{ 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)
|
||||
return ["ok", { game: cmd }]
|
||||
return { status: "ok", output: { game: cmd } }
|
||||
|
||||
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") {
|
||||
return ["ok", output]
|
||||
return { status: "ok", output }
|
||||
} else if (typeof output === "object") {
|
||||
if (output.error) {
|
||||
return ["error", output.error]
|
||||
return { status: "error", output: output.error }
|
||||
} else if (isJSX(output)) {
|
||||
return ["ok", { html: output.toString() }]
|
||||
return { status: "ok", output: { html: output.toString() } }
|
||||
} else if (output.html && isJSX(output.html)) {
|
||||
output.html = output.html.toString()
|
||||
return ["ok", output]
|
||||
return { status: "ok", output }
|
||||
} else {
|
||||
return ["ok", output]
|
||||
return { status: "ok", output }
|
||||
}
|
||||
} else if (output === undefined) {
|
||||
return ["ok", ""]
|
||||
return { status: "ok", output: "" }
|
||||
} 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)
|
||||
if (!state) {
|
||||
state = { sessionId: sessionId, project: "" }
|
||||
sessions.set(sessionId, state)
|
||||
}
|
||||
state.taskId = taskId
|
||||
if (taskId)
|
||||
state.taskId = taskId
|
||||
if (ws) state.ws = ws
|
||||
return state
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export function initSneakers() {
|
|||
for (const key in state) {
|
||||
if (key.startsWith(PREFIX)) {
|
||||
const app = key.replace(PREFIX, "")
|
||||
console.log("sharing", app, state[key])
|
||||
connectSneaker(app, state[key])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
////
|
||||
// Shell utilities and helper functions.
|
||||
|
||||
import { $ } from "bun"
|
||||
import { statSync } from "fs"
|
||||
import { basename } from "path"
|
||||
import { setFatal } from "./fatal"
|
||||
import { stat } from "fs/promises"
|
||||
|
||||
// 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}`)
|
||||
}
|
||||
|
||||
// End the process with an instructive error if a directory doesn't exist.
|
||||
export function expectDir(path: string) {
|
||||
// Cause a fatal error if a directory doesn't exist.
|
||||
export function expectDir(path: string): boolean {
|
||||
if (!isDir(path)) {
|
||||
console.error(`No ${basename(path)} directory detected.`)
|
||||
process.exit(1)
|
||||
setFatal(`Missing critical directory: ${path}`)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user