diff --git a/bin/env.ts b/bin/env.ts index 41745dd..ff45582 100644 --- a/bin/env.ts +++ b/bin/env.ts @@ -2,7 +2,7 @@ // // Show some debugging information. -import { NOSE_STARTED, NOSE_SYS_BIN, NOSE_DIR, GIT_SHA } from "@/config" +import { NOSE_STARTED, NOSE_SYS_BIN, NOSE_BIN, NOSE_DATA, NOSE_DIR, GIT_SHA } from "@/config" import { highlightToHTML } from "../lib/highlight" export default function () { @@ -14,7 +14,9 @@ export default function () { `USER=${valueOrNone(process.env.USER)}`, `PWD=${valueOrNone(process.env.PWD)}`, `NOSE_STARTED=${NOSE_STARTED}`, + `NOSE_BIN="${NOSE_BIN}"`, `NOSE_SYS_BIN="${NOSE_SYS_BIN}"`, + `NOSE_DATA="${NOSE_DATA}"`, `NOSE_DIR="${NOSE_DIR}"`, `GIT_SHA="${GIT_SHA}"`, ].join("\n")) diff --git a/bin/games.tsx b/bin/games.tsx index 4bc18d4..96ea37b 100644 --- a/bin/games.tsx +++ b/bin/games.tsx @@ -2,13 +2,13 @@ import { readdirSync } from "fs" import { join } from "path" -import { NOSE_SYS_BIN } from "@/config" +import { NOSE_BIN } from "@/config" 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 - 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)) return file.name.replace(".tsx", "").replace(".ts", "") diff --git a/bin/reboot.ts b/bin/reboot.ts index b31fd99..796bd01 100644 --- a/bin/reboot.ts +++ b/bin/reboot.ts @@ -1,3 +1,4 @@ +// Reboot the whole computer! Careful! export default async function reboot() { setTimeout(async () => await Bun.$`reboot`, 1000) diff --git a/bin/restart.ts b/bin/restart.ts index 1730180..ce2d056 100644 --- a/bin/restart.ts +++ b/bin/restart.ts @@ -1,3 +1,4 @@ +// Restart the NOSE server. export default function restart() { setTimeout(() => process.exit(), 1000) diff --git a/bin/version.ts b/bin/version.ts index 7524054..df9803d 100644 --- a/bin/version.ts +++ b/bin/version.ts @@ -1,3 +1,4 @@ +// The git sha for the running server. import { GIT_SHA } from "@/config" export default function () { return GIT_SHA diff --git a/nose/ping/ping.ts b/nose/ping/index.ts similarity index 100% rename from nose/ping/ping.ts rename to nose/ping/index.ts diff --git a/src/commands.ts b/src/commands.ts index 82f7f5d..ef267a7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -7,34 +7,49 @@ import { join } from "path" import { isFile } from "./utils" import { sendAll } from "./websocket" import { expectDir } from "./utils" -import { NOSE_SYS_BIN, NOSE_BIN } from "./config" +import { unique } from "./shared/utils" +import { projectBin, projectName } from "./project" +import { NOSE_DIR, NOSE_SYS_BIN, NOSE_BIN } from "./config" export function initCommands() { startWatchers() } -export async function commands(): Promise { - return (await findCommands(NOSE_SYS_BIN)).concat(await findCommands(NOSE_BIN)) +export async function commands(project = "sys"): Promise { + let cmds = (await findCommands(NOSE_BIN)) + .concat(await findCommands(NOSE_SYS_BIN)) + + if (project !== "sys") + cmds = cmds.concat(await findCommands(projectBin())) + + return unique(cmds).sort() } export async function findCommands(path: string): Promise { const glob = new Glob("**/*.{ts,tsx}") 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", "")) - } return list } export function commandPath(cmd: string): string | undefined { - return [ + let paths = [ + join(NOSE_BIN, cmd + ".ts"), + join(NOSE_BIN, cmd + ".tsx"), join(NOSE_SYS_BIN, cmd + ".ts"), join(NOSE_SYS_BIN, cmd + ".tsx"), - join(NOSE_BIN, cmd + ".ts"), - join(NOSE_BIN, cmd + ".tsx") - ].find((path: string) => isFile(path)) + ] + + if (projectName() !== "sys") + paths.concat([ + join(projectBin(), cmd + ".ts"), + join(projectBin(), cmd + ".tsx"), + ]) + + return paths.find((path: string) => isFile(path)) } export function commandExists(cmd: string): boolean { @@ -53,16 +68,17 @@ export async function loadCommandModule(cmd: string) { return await import(path + "?t+" + Date.now()) } -let sysCmdWatcher -let usrCmdWatcher +let noseDirWatcher +let binCmdWatcher function startWatchers() { if (!expectDir(NOSE_BIN)) return + if (!expectDir(NOSE_SYS_BIN)) return - sysCmdWatcher = watch(NOSE_SYS_BIN, async (event, filename) => - sendAll({ type: "commands", data: await commands() }) - ) - - usrCmdWatcher = watch(NOSE_BIN, async (event, filename) => { + binCmdWatcher = watch(NOSE_BIN, async (event, filename) => { sendAll({ type: "commands", data: await commands() }) }) + + noseDirWatcher = watch(NOSE_DIR, async (event, filename) => + sendAll({ type: "commands", data: await commands() }) + ) } \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 4d45951..71cca6a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,13 +4,11 @@ import { untilde } from "./utils" 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_BIN = join(NOSE_DIR, "bin") -export const NOSE_WWW = join(NOSE_DIR, "www") - -export const NOSE_DATA = resolve("./data") +export const NOSE_SYS_BIN = join(NOSE_DIR, "sys", "bin") export const NOSE_STARTED = Date.now() export const GIT_SHA = (await $`git rev-parse --short HEAD`.text()).trim() \ No newline at end of file diff --git a/src/dns.ts b/src/dns.ts index 225ed9c..3347b4b 100644 --- a/src/dns.ts +++ b/src/dns.ts @@ -4,7 +4,7 @@ import { watch } from "fs" import { apps } from "./webapp" import { expectDir } from "./utils" -import { NOSE_WWW } from "./config" +import { NOSE_DIR } from "./config" import { expectShellCmd } from "./utils" export const dnsEntries: Record = {} @@ -47,11 +47,11 @@ export function publishAppDNS(app: string) { return dnsEntries[app] } -let wwwWatcher +let dnsWatcher 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() www.forEach(publishAppDNS) for (const name in dnsEntries) diff --git a/src/project.ts b/src/project.ts index 44e4376..1b7cdd3 100644 --- a/src/project.ts +++ b/src/project.ts @@ -11,14 +11,14 @@ export function projectName(): string { const state = sessionGet() if (!state) throw "no state" - const project = state.project - if (!project) throw "no project loaded" - - return project + return state.project || "sys" } export function projects(): string[] { - return readdirSync(NOSE_DIR, { withFileTypes: true }).filter(file => file.isDirectory()).map(dir => dir.name) + return readdirSync(NOSE_DIR, { withFileTypes: true }) + .filter(file => file.isDirectory()) + .map(dir => dir.name) + .sort() } export function projectDir(name = projectName()): string { @@ -28,6 +28,10 @@ export function projectDir(name = projectName()): string { return root } +export function projectBin(name = projectName()): string { + return join(projectDir(), "bin") +} + export function projectFiles(name = projectName()): Dirent[] { return readdirSync(projectDir(name), { recursive: true, withFileTypes: true }) } \ No newline at end of file diff --git a/src/server.tsx b/src/server.tsx index ec6386f..2ebb454 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -7,7 +7,7 @@ import { prettyJSON } from "hono/pretty-json" import color from "kleur" 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_SYS_BIN } from "./config" import { transpile, isFile, tilde, isDir } from "./utils" import { serveApp } from "./webapp" import { commands, commandPath, loadCommandModule } from "./commands" @@ -127,8 +127,10 @@ app.get("/", c => c.html()) // websocket // -app.get("/ws", upgradeWebSocket(async c => { - return { +app.get("/ws", c => { + const _sessionId = c.req.query("session") + + return upgradeWebSocket(c, { async onOpen(_e, ws) { addWebsocket(ws) send(ws, { type: "commands", data: await commands() }) @@ -152,8 +154,8 @@ app.get("/ws", upgradeWebSocket(async c => { await dispatchMessage(ws, data) }, onClose: (event, ws) => removeWebsocket(ws) - } -})) + }) +}) // // hot reload mode cleanup @@ -191,10 +193,10 @@ if (process.env.NODE_ENV === "production") { // console.log(color.cyan(NOSE_ICON)) -console.log(color.blue("NOSE_DATA:"), color.yellow(tilde(NOSE_DATA))) -console.log(color.blue("NOSE_DIR:"), color.yellow(tilde(NOSE_DIR))) -console.log(color.blue("NOSE_BIN:"), color.yellow(tilde(NOSE_BIN))) -console.log(color.blue("NOSE_WWW:"), color.yellow(tilde(NOSE_WWW))) +console.log(color.blue(" NOSE_BIN:"), color.yellow(tilde(NOSE_BIN))) +console.log(color.blue(" NOSE_DATA:"), color.yellow(tilde(NOSE_DATA))) +console.log(color.blue(" NOSE_DIR:"), color.yellow(tilde(NOSE_DIR))) +console.log(color.blue("NOSE_SYS_BIN:"), color.yellow(tilde(NOSE_SYS_BIN))) await initNoseDir() initCommands() diff --git a/src/session.ts b/src/session.ts index 9a83621..f9fa054 100644 --- a/src/session.ts +++ b/src/session.ts @@ -15,9 +15,16 @@ export type Session = { const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage } export const ALS = g.__thread ??= new AsyncLocalStorage() +const sessions: Map = new Map() + +export async function sessionRun(sessionId: string, fn: () => void | Promise) { + const state = sessionStore(sessionId) + return await ALS.run(state, async () => fn()) +} + export function sessionGet(key?: keyof Session): Session | any | undefined { const store = ALS.getStore() - if (!store) return + if (!store) throw "sessionGet() called outside of ALS.run" 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) { const store = ALS.getStore() - if (!store) return + if (!store) throw "sessionSet() called outside of ALS.run" 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 } \ No newline at end of file diff --git a/src/shell.ts b/src/shell.ts index ef6a221..c6bf772 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -2,21 +2,14 @@ // Runs commands and such on the server. // This is the "shell" - the "terminal" is the browser UI. -import type { CommandResult, CommandOutput } from "./shared/types" -import type { Session } from "./session" +import type { CommandResult } from "./shared/types" +import { sessionStore } from "./session" import { commandExists, loadCommandModule } from "./commands" import { ALS } from "./session" -const sessions: Map = new Map() - export async function runCommand(sessionId: string, taskId: string, input: string, ws?: any): Promise { const [cmd = "", ...args] = input.split(" ") - - if (!commandExists(cmd)) - return { status: "error", output: `${cmd} not found` } - return runCommandFn({ sessionId, taskId, ws }, async () => exec(cmd, args)) - } export async function runCommandFn( @@ -24,7 +17,7 @@ export async function runCommandFn( fn: () => Promise ): Promise { try { - const state = getState(sessionId, taskId, ws) + const state = sessionStore(sessionId, taskId, ws) return processExecOutput(await ALS.run(state, async () => fn())) } catch (err) { return { status: "error", output: errorMessage(err) } @@ -32,6 +25,9 @@ export async function runCommandFn( } async function exec(cmd: string, args: string[]): Promise { + if (!commandExists(cmd)) + return { status: "error", output: `${cmd} not found` } + const module = await loadCommandModule(cmd) 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 { if (!(error instanceof Error)) return String(error) diff --git a/src/webapp.ts b/src/webapp.ts index 6c35174..cf0d1f5 100644 --- a/src/webapp.ts +++ b/src/webapp.ts @@ -4,10 +4,10 @@ import type { Child } from "hono/jsx" import { type Context, Hono } from "hono" import { renderToString } from "hono/jsx/dom/server" -import { join, dirname } from "path" +import { join } from "path" import { readdirSync } from "fs" -import { NOSE_WWW } from "./config" +import { NOSE_DIR } from "./config" import { isFile, isDir } from "./utils" export type Handler = (r: Context) => string | Child | Response | Promise @@ -35,39 +35,32 @@ export async function serveApp(c: Context, subdomain: string): Promise export function apps(): string[] { const apps: string[] = [] - for (const entry of readdirSync(NOSE_WWW)) - apps.push(entry.replace(/\.tsx?/, "")) + for (const entry of readdirSync(NOSE_DIR)) + if (isApp(entry)) + apps.push(entry) 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 { - const path = [ - `${name}.ts`, - `${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 + if (isApp(name)) + return join(NOSE_DIR, name) } async function findApp(name: string): Promise { const paths = [ - `${name}.ts`, - `${name}.tsx`, join(name, "index.ts"), join(name, "index.tsx") ] - let app - for (const path of paths) { - app = await loadApp(join(NOSE_WWW, path)) + const app = await loadApp(join(NOSE_DIR, path)) if (app) return app } }