upload command

This commit is contained in:
Chris Wanstrath 2025-09-30 22:35:11 -07:00
parent 329d36a878
commit 3ce1bce5f0
19 changed files with 181 additions and 39 deletions

View File

@ -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"))
}

View File

@ -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}` }
}

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 { 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())
}

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("")
}
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

View File

@ -47,6 +47,12 @@ 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() {

View File

@ -123,3 +123,28 @@ 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"] {
color: var(--c64-dark-blue);
}
input[type="submit"] {
color: var(--c64-dark-blue);
padding: 5px;
}

View File

@ -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[]) {

View File

@ -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

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

@ -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

View File

@ -2,6 +2,7 @@ import { initCompletion } from "./completion.js"
import { initCursor } from "./cursor.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"
@ -14,6 +15,7 @@ import { startConnection } from "./websocket.js"
initCompletion()
initCursor()
initFocus()
initForm()
initEditor()
initGamepad()
initHistory()

View File

@ -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)

View File

@ -4,4 +4,4 @@
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.
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)
}

View File

@ -11,7 +11,8 @@ import { NOSE_ICON, NOSE_BIN, NOSE_WWW, NOSE_DATA, NOSE_DIR } from "./config"
import { transpile, isFile, tilde } from "./utils"
import { serveApp } from "./webapp"
import { initDNS } from "./dns"
import { commands, commandPath } from "./commands"
import { commands, commandPath, loadCommandModule } from "./commands"
import { runInSession, processExecOutput } from "./shell"
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
import { Layout } from "./html/layout"
@ -89,6 +90,25 @@ app.get("/source/: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({ error: `No ${method} export in ${cmd}` }, 500)
return c.json(await runInSession(sessionId, async () => {
const [status, output] = processExecOutput(await mod[method](c))
return { status, output }
}))
} catch (e: any) {
return c.json({ status: "error", output: e.message || e.toString() }, 500)
}
})
app.get("/", c => c.html(<Layout><Terminal /></Layout>))
//

View File

@ -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()
@ -29,8 +29,13 @@ export async function runCommand(sessionId: string, taskId: string, input: strin
return { status, output }
}
export async function runInSession(sessionId: string, fn: () => Promise<any>) {
const state = getState(sessionId)
return await ALS.run(state, async () => await fn())
}
async function exec(cmd: string, args: string[]): Promise<["ok" | "error", CommandOutput]> {
const module = await import(commandPath(cmd) + "?t+" + Date.now())
const module = await loadCommandModule(cmd)
if (module?.game)
return ["ok", { game: cmd }]
@ -62,12 +67,13 @@ export function processExecOutput(output: string | any): ["ok" | "error", Comman
}
}
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)
}
if (taskId)
state.taskId = taskId
if (ws) state.ws = ws
return state

View File

@ -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])
}
}