Compare commits
No commits in common. "713754fece23ef6226a96c3f8f7cbe16dc479413" and "38d3481f8d0da384cac9917b437c13eaa3168e43" have entirely different histories.
713754fece
...
38d3481f8d
|
|
@ -1,20 +1,15 @@
|
||||||
# NOSE:pluto
|
# NOSE:pluto
|
||||||
|
|
||||||
## Installation
|
- [x] Hosts valtown-style Bun apps (for your home network)
|
||||||
|
- [x] Provides a NOSE terminal/shell GUI
|
||||||
You just need to make a "nose" user on your RPi and make sure you can ssh in.
|
- [x] Runs one-shot TypeScript commands (via NOSE terminal)
|
||||||
|
- [x] Has a 960x540 (16:9) virtual screen size that scales to the actual size of the display
|
||||||
Then run `bun remote:install`.
|
- [x] Runs on a Raspberry Pi 5
|
||||||
|
|
||||||
When it's done (it'll reboot) visit:
|
|
||||||
|
|
||||||
http://nose-pluto.local
|
|
||||||
|
|
||||||
## Local Dev
|
## Local Dev
|
||||||
|
|
||||||
bun install
|
bun install
|
||||||
bun dev
|
bun dev
|
||||||
open localhost:3000
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|
@ -30,16 +25,4 @@ They can also `throw` to display an error.
|
||||||
|
|
||||||
Use this to examine what's inside the C64 .woff2 font file in public/vendor:
|
Use this to examine what's inside the C64 .woff2 font file in public/vendor:
|
||||||
|
|
||||||
https://wakamaifondue.com/
|
https://wakamaifondue.com/
|
||||||
|
|
||||||
## Pluto Goals: Phase 1
|
|
||||||
|
|
||||||
- [x] Hosts valtown-style Bun apps (for your home network)
|
|
||||||
- [x] Provides a NOSE terminal/shell GUI
|
|
||||||
- [x] Runs one-shot TypeScript commands (via NOSE terminal)
|
|
||||||
- [x] Has a 960x540 (16:9) virtual screen size that scales to the actual size of the display
|
|
||||||
- [x] Runs on a Raspberry Pi 5
|
|
||||||
|
|
||||||
## Pluto Goals: Phase 2
|
|
||||||
|
|
||||||
- [ ] coming soon
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { join, extname } from "path"
|
||||||
|
|
||||||
import type { CommandOutput } from "app/src/shared/types"
|
import type { CommandOutput } from "app/src/shared/types"
|
||||||
import { NOSE_WWW } from "app/src/config"
|
import { NOSE_WWW } from "app/src/config"
|
||||||
import { getState } from "@/session"
|
import { getState } from "app/src/state"
|
||||||
import { appPath } from "app/src/webapp"
|
import { appPath } from "app/src/webapp"
|
||||||
import { isBinaryFile } from "app/src/utils"
|
import { isBinaryFile } from "app/src/utils"
|
||||||
import { highlight } from "../lib/highlight"
|
import { highlight } from "../lib/highlight"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { join, extname } from "path"
|
||||||
|
|
||||||
import type { CommandOutput } from "app/src/shared/types"
|
import type { CommandOutput } from "app/src/shared/types"
|
||||||
import { NOSE_WWW } from "app/src/config"
|
import { NOSE_WWW } from "app/src/config"
|
||||||
import { getState } from "@/session"
|
import { getState } from "app/src/state"
|
||||||
import { appPath } from "app/src/webapp"
|
import { appPath } from "app/src/webapp"
|
||||||
import { isBinaryFile } from "app/src/utils"
|
import { isBinaryFile } from "app/src/utils"
|
||||||
import { countChar } from "app/src/shared/utils"
|
import { countChar } from "app/src/shared/utils"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { apps } from "app/src/webapp"
|
import { apps } from "app/src/webapp"
|
||||||
import { getState } from "@/session"
|
import { getState } from "app/src/state"
|
||||||
|
|
||||||
export default function (project: string) {
|
export default function (project: string) {
|
||||||
const state = getState()
|
const state = getState()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { readdirSync } from "fs"
|
import { readdirSync } from "fs"
|
||||||
import { NOSE_WWW } from "app/src/config"
|
import { NOSE_WWW } from "app/src/config"
|
||||||
import { getState } from "@/session"
|
import { getState } from "app/src/state"
|
||||||
import { appPath } from "app/src/webapp"
|
import { appPath } from "app/src/webapp"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getState } from "@/session"
|
import { getState } from "app/src/state"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const state = getState()
|
const state = getState()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { apps } from "app/src/webapp"
|
import { apps } from "app/src/webapp"
|
||||||
import { getState } from "@/session"
|
import { getState } from "app/src/state"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const state = getState()
|
const state = getState()
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { $ } from "bun"
|
|
||||||
|
|
||||||
export default async function () {
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
return { error: "Can only update in production." }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stdout } = await $`cd .. && git pull`.quiet()
|
|
||||||
const out = stdout.toString()
|
|
||||||
|
|
||||||
if (/up to date/.test(out)) {
|
|
||||||
return "Up to date."
|
|
||||||
} else {
|
|
||||||
setTimeout(() => process.exit(), 1000)
|
|
||||||
return "Restarting in 1 second..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# It isn't enough to modify this yet.
|
# It isn't enough to modify this yet.
|
||||||
# You also need to manually update the nose-pluto.service file.
|
# You also need to manually update the nose-pluto.service file.
|
||||||
HOST="${HOST:-nose@nose-pluto.local}"
|
HOST="${HOST:-chris@nose-pluto.local}"
|
||||||
DEST="${DEST:-~/nose}"
|
DEST="${DEST:-~/pluto}"
|
||||||
REPO="${REPO:-https://git.nose.space/defunkt/nose-pluto}"
|
|
||||||
|
|
@ -1,12 +1,39 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
|
||||||
|
|
||||||
# Get absolute path of this script’s directory
|
##
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
# deploys from your dev machine to your NOSEputer
|
||||||
ROOT_DIR="$SCRIPT_DIR/../.."
|
|
||||||
|
|
||||||
# Run deploy + config with absolute paths
|
set -euo pipefail
|
||||||
source "$ROOT_DIR/app/scripts/config.sh"
|
|
||||||
|
|
||||||
# Run remote install on the target
|
source ./scripts/config.sh
|
||||||
ssh $HOST "cd $DEST && git pull && bun install && sudo systemctl restart nose-pluto.service"
|
SOCK="$HOME/.ssh/cm-%r@%h:%p"
|
||||||
|
|
||||||
|
# 1) Open a master connection (prompts once)
|
||||||
|
ssh -MNf -o ControlMaster=yes -o ControlPersist=120 \
|
||||||
|
-o ControlPath="$SOCK" "$HOST"
|
||||||
|
|
||||||
|
# 2) rsync (reuses the connection)
|
||||||
|
|
||||||
|
# ensure our directory exists
|
||||||
|
ssh -o ControlPath="$SOCK" "$HOST" "mkdir -p $DEST/app"
|
||||||
|
|
||||||
|
# destructive sync for /app
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "ssh -o ControlPath=$SOCK" \
|
||||||
|
--exclude 'node_modules/' \
|
||||||
|
--exclude '.git/' \
|
||||||
|
../app/ "$HOST:$DEST/app/"
|
||||||
|
|
||||||
|
# additive sync for root, /bin, /www
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -o ControlPath=$SOCK" \
|
||||||
|
--exclude 'node_modules/' \
|
||||||
|
--exclude '.git/' \
|
||||||
|
--exclude 'app/' \
|
||||||
|
../ "$HOST:$DEST/"
|
||||||
|
|
||||||
|
# 3) remote deploy (reuses the connection)
|
||||||
|
ssh -o ControlPath="$SOCK" "$HOST" "cd $DEST && bun install --frozen-lockfile && sudo systemctl restart nose-pluto.service"
|
||||||
|
|
||||||
|
# 4) close the master connection
|
||||||
|
ssh -O exit -o ControlPath="$SOCK" "$HOST"
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,8 @@ if [ ! -x "$BUN_SYMLINK" ]; then
|
||||||
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
||||||
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
||||||
else
|
else
|
||||||
echo ">> Installing bun at $BUN_REAL"
|
echo "Error: bun not found at $BUN_REAL"
|
||||||
sudo apt install unzip
|
exit 1
|
||||||
curl -fsSL https://bun.com/install | bash
|
|
||||||
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
|
||||||
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "bun already available at $BUN_SYMLINK"
|
echo "bun already available at $BUN_SYMLINK"
|
||||||
|
|
@ -45,12 +42,5 @@ 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 ">> Enabling kiosk mode"
|
echo ">> Done!"
|
||||||
mkdir -p ~/.config/labwc
|
|
||||||
cat > ~/.config/labwc/autostart <<'EOF'
|
|
||||||
chromium-browser --noerrdialogs --disable-infobars --kiosk http://localhost
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo ">> Done! Rebooting!"
|
|
||||||
systemctl status "$SERVICE_NAME" --no-pager -l
|
systemctl status "$SERVICE_NAME" --no-pager -l
|
||||||
sudo reboot
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=nose
|
User=chris
|
||||||
WorkingDirectory=/home/nose/nose/app
|
WorkingDirectory=/home/chris/pluto/app
|
||||||
Environment=PORT=80
|
Environment=PORT=80
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
ExecStart=/home/nose/.bun/bin/bun start
|
ExecStart=/home/chris/.bun/bin/bun start
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
|
||||||
|
|
||||||
# Get absolute path of this script’s directory
|
##
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
# setup your NOSEputer from your dev machine
|
||||||
ROOT_DIR="$SCRIPT_DIR/../.."
|
|
||||||
|
|
||||||
# Run deploy + config with absolute paths
|
source ./app/scripts/config.sh
|
||||||
source "$ROOT_DIR/app/scripts/config.sh"
|
|
||||||
|
|
||||||
# Run remote install on the target
|
ssh $HOST "cd $DEST && ./app/scripts/install.sh && sudo systemctl start nose-pluto.service"
|
||||||
ssh "$HOST" "git clone $REPO $DEST && cd $DEST && ./app/scripts/install.sh && sudo systemctl start nose-pluto.service"
|
|
||||||
|
|
@ -1,9 +1,2 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
ssh chris@nose-pluto.local "sudo systemctl restart nose-pluto.service"
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
ROOT_DIR="$SCRIPT_DIR/../.."
|
|
||||||
|
|
||||||
source "$ROOT_DIR/app/scripts/config.sh"
|
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl restart nose-pluto.service"
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,2 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
ssh chris@nose-pluto.local "sudo systemctl start nose-pluto.service"
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
ROOT_DIR="$SCRIPT_DIR/../.."
|
|
||||||
|
|
||||||
source "$ROOT_DIR/app/scripts/config.sh"
|
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl start nose-pluto.service"
|
|
||||||
|
|
@ -1,9 +1,2 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
ssh chris@nose-pluto.local "sudo systemctl stop nose-pluto.service"
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
ROOT_DIR="$SCRIPT_DIR/../.."
|
|
||||||
|
|
||||||
source "$ROOT_DIR/app/scripts/config.sh"
|
|
||||||
|
|
||||||
ssh "$HOST" "sudo systemctl stop nose-pluto.service"
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
////
|
|
||||||
// Manages the commands on disk, in NOSE_SYS_BIN and NOSE_BIN
|
|
||||||
|
|
||||||
import { Glob } from "bun"
|
import { Glob } from "bun"
|
||||||
import { watch } from "fs"
|
import { watch } from "fs"
|
||||||
import { sendAll } from "./websocket"
|
import { sendAll } from "./websocket"
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,24 @@
|
||||||
////
|
////
|
||||||
// Dispatch Messages received via WebSocket
|
// Dispatch Messages
|
||||||
|
|
||||||
import { basename } from "path"
|
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 { isFile } from "./utils"
|
||||||
|
|
||||||
export async function dispatchMessage(ws: any, msg: Message) {
|
export async function dispatchMessage(ws: any, msg: Message) {
|
||||||
switch (msg.type) {
|
if (msg.type === "input") {
|
||||||
case "input":
|
const result = await runCommand(msg.session || "", msg.id || "", msg.data as string)
|
||||||
await inputMessage(ws, msg); break
|
send(ws, { id: msg.id, type: "output", data: result })
|
||||||
|
|
||||||
case "save-file":
|
} else if (msg.type === "save-file") {
|
||||||
await saveFileMessage(ws, msg); break
|
if (msg.id && typeof msg.data === "string") {
|
||||||
|
await Bun.write(msg.id.replace("..", ""), msg.data, { createPath: true })
|
||||||
|
send(ws, { type: "output", data: { status: "ok", output: `saved ${basename(msg.id)}` } })
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
} else {
|
||||||
send(ws, { type: "error", data: `unknown message: ${msg.type}` })
|
send(ws, { type: "error", data: `unknown message: ${msg.type}` })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inputMessage(ws: any, msg: Message) {
|
|
||||||
const result = await runCommand(msg.session || "", msg.id || "", msg.data as string)
|
|
||||||
send(ws, { id: msg.id, type: "output", data: result })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveFileMessage(ws: any, msg: Message) {
|
|
||||||
if (msg.id && typeof msg.data === "string") {
|
|
||||||
await Bun.write(msg.id.replace("..", ""), msg.data, { createPath: true })
|
|
||||||
send(ws, { type: "output", data: { status: "ok", output: `saved ${basename(msg.id)}` } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
////
|
|
||||||
// Publishes webapps as subdomains on your local network
|
|
||||||
|
|
||||||
import { watch } from "fs"
|
|
||||||
import { apps } from "./webapp"
|
|
||||||
import { expectDir } from "./utils"
|
|
||||||
import { NOSE_WWW } from "./config"
|
|
||||||
|
|
||||||
export const dnsEntries: Record<string, any> = {}
|
|
||||||
|
|
||||||
const { stdout: ipRaw } = await Bun.$`hostname -I | awk '{print $1}'`.quiet()
|
|
||||||
const { stdout: hostRaw } = await Bun.$`hostname`.quiet()
|
|
||||||
|
|
||||||
const ip = ipRaw.toString().trim()
|
|
||||||
const host = hostRaw.toString().trim()
|
|
||||||
let dnsInit = false
|
|
||||||
|
|
||||||
export async function initDNS() {
|
|
||||||
apps().forEach(publishAppDNS)
|
|
||||||
|
|
||||||
const signals = ["SIGINT", "SIGTERM"]
|
|
||||||
signals.forEach(sig =>
|
|
||||||
process.on(sig, () => {
|
|
||||||
for (const name in dnsEntries)
|
|
||||||
dnsEntries[name].kill("SIGTERM")
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
dnsInit = true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function publishAppDNS(app: string) {
|
|
||||||
if (!dnsInit) throw "publishAppDNS() must be called after initDNS()"
|
|
||||||
if (process.env.NODE_ENV !== "production") return
|
|
||||||
|
|
||||||
|
|
||||||
if (!dnsEntries[app])
|
|
||||||
dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip])
|
|
||||||
|
|
||||||
return dnsEntries[app]
|
|
||||||
}
|
|
||||||
|
|
||||||
// exit process with error if no WWW dir
|
|
||||||
expectDir(NOSE_WWW)
|
|
||||||
|
|
||||||
const wwwWatcher = watch(NOSE_WWW, (event, filename) => {
|
|
||||||
const www = apps()
|
|
||||||
www.forEach(publishAppDNS)
|
|
||||||
for (const name in dnsEntries)
|
|
||||||
if (!www.includes(name)) {
|
|
||||||
dnsEntries[name].kill("SIGTERM")
|
|
||||||
delete dnsEntries[name]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
////
|
|
||||||
// Helpers for writing NOSE webapps & cli commands
|
|
||||||
//
|
|
||||||
// Be *very careful* modifying this file, as people's scripts and www's will depend
|
|
||||||
// on the API. We need to eventually version it and provide backwards compat.
|
|
||||||
//
|
|
||||||
// Access them in your command or webapp:
|
|
||||||
// import { css } from "@nose"
|
|
||||||
|
|
||||||
import { Hono } from "hono"
|
|
||||||
import { type Handler, toResponse } from "./webapp"
|
|
||||||
|
|
||||||
//
|
|
||||||
// command helpers
|
|
||||||
//
|
|
||||||
|
|
||||||
// (none for now)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//
|
|
||||||
// webapp helpers
|
|
||||||
//
|
|
||||||
|
|
||||||
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
|
|
||||||
|
|
||||||
export function css(strings: TemplateStringsArray, ...values: any[]) {
|
|
||||||
return <style dangerouslySetInnerHTML={
|
|
||||||
{
|
|
||||||
__html: strings.reduce((result, str, i) => {
|
|
||||||
return result + str + (values[i] || '')
|
|
||||||
}, '')
|
|
||||||
}
|
|
||||||
} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function js(strings: TemplateStringsArray, ...values: any[]) {
|
|
||||||
return <script dangerouslySetInnerHTML={
|
|
||||||
{
|
|
||||||
__html: strings.reduce((result, str, i) => {
|
|
||||||
return transpiler.transformSync(result + str + (values[i] || ''))
|
|
||||||
}, '')
|
|
||||||
}
|
|
||||||
} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// for defining routes in your NOSE webapp
|
|
||||||
// example:
|
|
||||||
// export default routes({
|
|
||||||
// "GET /": index,
|
|
||||||
// "GET /pets": pets
|
|
||||||
// })
|
|
||||||
export function routes(def: Record<string, Handler>): Hono {
|
|
||||||
const app = new Hono
|
|
||||||
|
|
||||||
for (const key in def) {
|
|
||||||
const parts = key.split(" ") // GET /path
|
|
||||||
const method = parts[0] || "GET"
|
|
||||||
const path = parts[1] || "/"
|
|
||||||
|
|
||||||
console.log(method, path, def[key])
|
|
||||||
//@ts-ignore
|
|
||||||
app.on(method, path, async c => toResponse(await def[key](c)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
@ -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.js"
|
||||||
|
|
||||||
export const sessionID = randomId()
|
export const sessionID = randomID()
|
||||||
|
|
@ -4,14 +4,14 @@
|
||||||
import type { Message, CommandResult } from "../shared/types.js"
|
import type { Message, CommandResult } from "../shared/types.js"
|
||||||
import { addInput, setStatus, addOutput } from "./scrollback.js"
|
import { addInput, setStatus, addOutput } from "./scrollback.js"
|
||||||
import { send } from "./websocket.js"
|
import { send } from "./websocket.js"
|
||||||
import { randomId } from "../shared/utils.js"
|
import { randomID } from "../shared/utils.js"
|
||||||
import { addToHistory } from "./history.js"
|
import { addToHistory } from "./history.js"
|
||||||
import { browserCommands, cacheCommands } from "./commands.js"
|
import { browserCommands, cacheCommands } from "./commands.js"
|
||||||
|
|
||||||
export function runCommand(input: string) {
|
export function runCommand(input: string) {
|
||||||
if (!input.trim()) return
|
if (!input.trim()) return
|
||||||
|
|
||||||
const id = randomId()
|
const id = randomID()
|
||||||
|
|
||||||
addToHistory(input)
|
addToHistory(input)
|
||||||
addInput(id, input)
|
addInput(id, input)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
////
|
|
||||||
// Web server that serves shell commands, websocket connections, etc
|
|
||||||
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { serveStatic, upgradeWebSocket, websocket } from "hono/bun"
|
import { serveStatic, upgradeWebSocket, websocket } from "hono/bun"
|
||||||
import { prettyJSON } from "hono/pretty-json"
|
import { prettyJSON } from "hono/pretty-json"
|
||||||
|
|
@ -9,13 +6,12 @@ import color from "kleur"
|
||||||
import type { Message } from "./shared/types"
|
import type { Message } from "./shared/types"
|
||||||
import { NOSE_ICON, NOSE_BIN, NOSE_WWW } from "./config"
|
import { NOSE_ICON, NOSE_BIN, NOSE_WWW } from "./config"
|
||||||
import { transpile, isFile, tilde } from "./utils"
|
import { transpile, isFile, tilde } from "./utils"
|
||||||
import { apps, serveApp } from "./webapp"
|
import { apps, serveApp, publishDNS } from "./webapp"
|
||||||
import { initDNS } from "./dns"
|
|
||||||
import { commands } from "./commands"
|
import { commands } from "./commands"
|
||||||
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
|
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
|
||||||
|
|
||||||
import { Layout } from "./html/layout"
|
import { Layout } from "./components/layout"
|
||||||
import { Terminal } from "./html/terminal"
|
import { Terminal } from "./components/terminal"
|
||||||
import { dispatchMessage } from "./dispatch"
|
import { dispatchMessage } from "./dispatch"
|
||||||
import "./sneaker"
|
import "./sneaker"
|
||||||
|
|
||||||
|
|
@ -140,7 +136,7 @@ if (process.env.BUN_HOT) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
initDNS()
|
publishDNS()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
////
|
|
||||||
// Session storage. 1 browser tab = 1 session
|
|
||||||
|
|
||||||
import { AsyncLocalStorage } from "async_hooks"
|
|
||||||
|
|
||||||
export type Session = {
|
|
||||||
taskId?: string
|
|
||||||
sessionId?: string
|
|
||||||
project?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure "ALS" lives between bun's hot reloads
|
|
||||||
const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<Session> }
|
|
||||||
export const ALS = g.__thread ??= new AsyncLocalStorage<Session>()
|
|
||||||
|
|
||||||
export function getState(): Session | undefined {
|
|
||||||
return ALS.getStore()
|
|
||||||
}
|
|
||||||
|
|
@ -4,6 +4,6 @@ export function countChar(str: string, char: string): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a 6 character random ID
|
// Generate a 6 character random ID
|
||||||
export function randomId(): string {
|
export function randomID(): string {
|
||||||
return Math.random().toString(36).slice(7)
|
return Math.random().toString(36).slice(7)
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
////
|
////
|
||||||
// Runs commands and such on the server.
|
// runs commands and such.
|
||||||
// This is the "shell" - the "terminal" is the browser UI.
|
|
||||||
|
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import type { CommandResult, CommandOutput } from "./shared/types"
|
import type { CommandResult, CommandOutput } from "./shared/types"
|
||||||
import type { Session } from "./session"
|
import type { State } from "./state"
|
||||||
import { NOSE_SYS_BIN, NOSE_BIN } from "./config"
|
import { NOSE_SYS_BIN, NOSE_BIN } from "./config"
|
||||||
import { isFile } from "./utils"
|
import { isFile } from "./utils"
|
||||||
import { ALS } from "./session"
|
import { ALS } from "./state"
|
||||||
|
|
||||||
const sessions: Map<string, Session> = new Map()
|
const sessions: Map<string, State> = new Map()
|
||||||
|
|
||||||
export async function runCommand(session: string, id: string, input: string): Promise<CommandResult> {
|
export async function runCommand(session: string, id: string, input: string): Promise<CommandResult> {
|
||||||
const [cmd = "", ...args] = input.split(" ")
|
const [cmd = "", ...args] = input.split(" ")
|
||||||
|
|
@ -57,13 +56,13 @@ function processExecOutput(output: string | any): ["ok" | "error", CommandOutput
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState(sessionId: string, taskId: string): Session {
|
function getState(session: string, id: string): State {
|
||||||
let state = sessions.get(sessionId)
|
let state = sessions.get(session)
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = { sessionId: sessionId, project: "" }
|
state = { session, project: "" }
|
||||||
sessions.set(sessionId, state)
|
sessions.set(session, state)
|
||||||
}
|
}
|
||||||
state.taskId = taskId
|
state.id = id
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
////
|
|
||||||
// Sneaker is our tunneling service that allows you to share your local NOSE webapps
|
|
||||||
// with the public internet. It requires a sneaker server, usually hosted by us.
|
|
||||||
|
|
||||||
import nose from "./server"
|
import nose from "./server"
|
||||||
|
|
||||||
const SNEAKER_URL = "nose.space"
|
const SNEAKER_URL = "nose.space"
|
||||||
|
|
|
||||||
15
app/src/state.ts
Normal file
15
app/src/state.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { AsyncLocalStorage } from "async_hooks"
|
||||||
|
|
||||||
|
export type State = {
|
||||||
|
id?: string
|
||||||
|
session?: string
|
||||||
|
project?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure "ALS" lives between bun's hot reloads
|
||||||
|
const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<State> }
|
||||||
|
export const ALS = g.__thread ??= new AsyncLocalStorage<State>()
|
||||||
|
|
||||||
|
export function getState(): State | undefined {
|
||||||
|
return ALS.getStore()
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
////
|
import { Hono } from "hono"
|
||||||
// Shell utilities and helper functions.
|
|
||||||
|
|
||||||
import { statSync } from "fs"
|
import { statSync } from "fs"
|
||||||
import { basename } from "path"
|
import { basename } from "path"
|
||||||
import { stat } from "node:fs/promises"
|
import { stat } from "node:fs/promises"
|
||||||
|
import { type Handler, toResponse } from "./webapp"
|
||||||
import { NOSE_ICON } from "./config"
|
import { NOSE_ICON } from "./config"
|
||||||
|
|
||||||
// End the process with an instructive error if a directory doesn't exist.
|
// End the process with an instructive error if a directory doesn't exist.
|
||||||
|
|
@ -65,11 +63,17 @@ export async function isBinaryFile(path: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Convert /Users/$USER or /home/$USER to ~ for simplicity
|
// Convert /Users/$USER or /home/$USER to ~ for simplicity
|
||||||
export function tilde(path: string): string {
|
export function tilde(path: string): string {
|
||||||
return path.replace(new RegExp(`/(Users|home)/${process.env.USER}`), "~")
|
return path.replace(new RegExp(`/(Users|home)/${process.env.USER}`), "~")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate a random 8 character string
|
||||||
|
export function randomID(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
|
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
|
||||||
const transpileCache: Record<string, string> = {}
|
const transpileCache: Record<string, string> = {}
|
||||||
|
|
||||||
|
|
@ -88,3 +92,45 @@ export async function transpile(path: string): Promise<string> {
|
||||||
|
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// webapp utils (for writing webapps)
|
||||||
|
//
|
||||||
|
|
||||||
|
export function css(strings: TemplateStringsArray, ...values: any[]) {
|
||||||
|
return <style dangerouslySetInnerHTML={{
|
||||||
|
__html: strings.reduce((result, str, i) => {
|
||||||
|
return result + str + (values[i] || '')
|
||||||
|
}, '')
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function js(strings: TemplateStringsArray, ...values: any[]) {
|
||||||
|
return <script dangerouslySetInnerHTML={{
|
||||||
|
__html: strings.reduce((result, str, i) => {
|
||||||
|
return transpiler.transformSync(result + str + (values[i] || ''))
|
||||||
|
}, '')
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// for defining routes in your NOSE webapp
|
||||||
|
// example:
|
||||||
|
// export default routes({
|
||||||
|
// "GET /": index,
|
||||||
|
// "GET /pets": pets
|
||||||
|
// })
|
||||||
|
export function routes(def: Record<string, Handler>): Hono {
|
||||||
|
const app = new Hono
|
||||||
|
|
||||||
|
for (const key in def) {
|
||||||
|
const parts = key.split(" ") // GET /path
|
||||||
|
const method = parts[0] || "GET"
|
||||||
|
const path = parts[1] || "/"
|
||||||
|
|
||||||
|
console.log(method, path, def[key])
|
||||||
|
//@ts-ignore
|
||||||
|
app.on(method, path, async c => toResponse(await def[key](c)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
////
|
|
||||||
// Hosting for your NOSE webapps!
|
|
||||||
|
|
||||||
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, dirname } from "path"
|
||||||
import { readdirSync } from "fs"
|
import { readdirSync, watch } from "fs"
|
||||||
|
|
||||||
import { NOSE_WWW } from "./config"
|
import { NOSE_WWW } from "./config"
|
||||||
import { isFile } from "./utils"
|
import { expectDir, isFile } from "./utils"
|
||||||
|
|
||||||
export type Handler = (r: Context) => string | Child | Response | Promise<Response>
|
export type Handler = (r: Context) => string | Child | Response | Promise<Response>
|
||||||
export type App = Hono | Handler
|
export type App = Hono | Handler
|
||||||
|
|
@ -87,3 +84,50 @@ export function toResponse(source: string | Child | Response): Response {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// dns nonsense
|
||||||
|
//
|
||||||
|
|
||||||
|
const dnsEntries: Record<string, any> = {}
|
||||||
|
|
||||||
|
const { stdout: ipRaw } = await Bun.$`hostname -I | awk '{print $1}'`.quiet()
|
||||||
|
const { stdout: hostRaw } = await Bun.$`hostname`.quiet()
|
||||||
|
|
||||||
|
const ip = ipRaw.toString().trim()
|
||||||
|
const host = hostRaw.toString().trim()
|
||||||
|
|
||||||
|
export async function publishDNS() {
|
||||||
|
apps().forEach(publishAppDNS)
|
||||||
|
|
||||||
|
const signals = ["SIGINT", "SIGTERM"]
|
||||||
|
signals.forEach(sig =>
|
||||||
|
process.on(sig, () => {
|
||||||
|
for (const name in dnsEntries)
|
||||||
|
dnsEntries[name].kill("SIGTERM")
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishAppDNS(app: string) {
|
||||||
|
if (process.env.NODE_ENV !== "production") return
|
||||||
|
|
||||||
|
if (!dnsEntries[app])
|
||||||
|
dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip])
|
||||||
|
|
||||||
|
return dnsEntries[app]
|
||||||
|
}
|
||||||
|
|
||||||
|
// exit process with error if no WWW dir
|
||||||
|
expectDir(NOSE_WWW)
|
||||||
|
|
||||||
|
const wwwWatcher = watch(NOSE_WWW, (event, filename) => {
|
||||||
|
const www = apps()
|
||||||
|
www.forEach(publishAppDNS)
|
||||||
|
for (const name in dnsEntries)
|
||||||
|
if (!www.includes(name)) {
|
||||||
|
dnsEntries[name].kill("SIGTERM")
|
||||||
|
delete dnsEntries[name]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
"alias": {
|
"alias": {
|
||||||
"@nose": "./app/src/helpers.tsx",
|
"@utils": "./app/src/utils.tsx",
|
||||||
"@config": "./app/src/config.ts",
|
"@config": "./app/src/config.ts",
|
||||||
"@/*": "./app/src/*"
|
"@/*": "./app/src/*"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user