Compare commits
28 Commits
827baeb620
...
aea0c5558e
| Author | SHA1 | Date | |
|---|---|---|---|
| aea0c5558e | |||
| f5fb571823 | |||
| 09267bcd12 | |||
| 413149172b | |||
| 1643dca7a0 | |||
| de990f312d | |||
| 0ec9edd595 | |||
| cea3fa32ed | |||
| 7a2af832f4 | |||
| 7385900445 | |||
| 3e02b3d348 | |||
| 26855ce388 | |||
| 2fce2d914a | |||
| 9dded30417 | |||
| 960f92b829 | |||
| 1197af67ef | |||
| bdb6fcdf05 | |||
| d3504d09f3 | |||
| 8544327f42 | |||
| 30dde3349f | |||
| 9fe788a35f | |||
| 682e0c29f3 | |||
| 0202ebb217 | |||
| 88640d7acf | |||
| b44c4d10fb | |||
| 64e76502e8 | |||
| ebb1afebdb | |||
|
|
e0572a9353 |
|
|
@ -55,3 +55,4 @@ https://wakamaifondue.com/
|
||||||
- [ ] pico8-style games
|
- [ ] pico8-style games
|
||||||
- [ ] public tunnel for your NOSE webapps
|
- [ ] public tunnel for your NOSE webapps
|
||||||
- [ ] self updating
|
- [ ] self updating
|
||||||
|
- [ ] `pub/` static hosting in webapps
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Show the webapps hosted on this NOSEputer.
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { apps } from "app/src/webapp"
|
import { apps } from "app/src/webapp"
|
||||||
|
|
||||||
|
|
@ -12,6 +14,6 @@ export default async function () {
|
||||||
port = port === "80" ? "" : `:${port}`
|
port = port === "80" ? "" : `:${port}`
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
{apps().map(app => <><a href={`http://${app}.${domain}${port}`}>{app}</a>{" "}</>)}
|
{apps().map(app => <a href={`http://${app}.${domain}${port}`}>{app}</a>)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
148
app/nose/bin/breakout.ts
Normal file
148
app/nose/bin/breakout.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
/// <reference lib="dom" />
|
||||||
|
export const game = true
|
||||||
|
|
||||||
|
import type { GameContext, InputState } from "@/shared/game"
|
||||||
|
|
||||||
|
const CELL = 20
|
||||||
|
const COLS = 30 // 600 px wide
|
||||||
|
const ROWS = 12 // 240 px tall of bricks
|
||||||
|
|
||||||
|
const PADDLE_W = 6
|
||||||
|
const PADDLE_H = 1
|
||||||
|
const BALL_SPEED = 4
|
||||||
|
|
||||||
|
const ROW_COLORS = ["red", "orange", "yellow", "green", "blue", "magenta"]
|
||||||
|
|
||||||
|
let paddleX = 0
|
||||||
|
let ball = { x: 0, y: 0, dx: BALL_SPEED, dy: -BALL_SPEED }
|
||||||
|
let bricks: { x: number, y: number, alive: boolean }[] = []
|
||||||
|
let score = 0
|
||||||
|
let dead = false
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
paddleX = (COLS * CELL - PADDLE_W * CELL) / 2
|
||||||
|
ball = {
|
||||||
|
x: COLS * CELL / 2,
|
||||||
|
y: ROWS * CELL + 60,
|
||||||
|
dx: BALL_SPEED,
|
||||||
|
dy: -BALL_SPEED
|
||||||
|
}
|
||||||
|
|
||||||
|
bricks = []
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
for (let c = 0; c < COLS; c += 2) {
|
||||||
|
bricks.push({
|
||||||
|
x: c * CELL,
|
||||||
|
y: r * CELL,
|
||||||
|
alive: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dead = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function update(_delta: number, input: InputState) {
|
||||||
|
if (dead) return
|
||||||
|
|
||||||
|
if (input.pressed.has("ArrowLeft") || input.pressed.has("a")) {
|
||||||
|
paddleX -= 6
|
||||||
|
}
|
||||||
|
if (input.pressed.has("ArrowRight") || input.pressed.has("d")) {
|
||||||
|
paddleX += 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep paddle on screen
|
||||||
|
paddleX = Math.max(0, Math.min(paddleX, COLS * CELL - PADDLE_W * CELL))
|
||||||
|
|
||||||
|
// move ball
|
||||||
|
ball.x += ball.dx
|
||||||
|
ball.y += ball.dy
|
||||||
|
|
||||||
|
// wall bounce
|
||||||
|
if (ball.x < 0 || ball.x > COLS * CELL) ball.dx *= -1
|
||||||
|
if (ball.y < 0) ball.dy *= -1
|
||||||
|
|
||||||
|
// paddle bounce
|
||||||
|
const paddleY = ROWS * CELL + 60
|
||||||
|
const paddleH = CELL
|
||||||
|
|
||||||
|
if (
|
||||||
|
ball.x > paddleX &&
|
||||||
|
ball.x < paddleX + PADDLE_W * CELL &&
|
||||||
|
ball.y + 6 >= paddleY &&
|
||||||
|
ball.y - 6 <= paddleY + paddleH
|
||||||
|
) {
|
||||||
|
ball.dy *= -1
|
||||||
|
ball.y = paddleY - 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// brick collision
|
||||||
|
for (const b of bricks) {
|
||||||
|
if (!b.alive) continue
|
||||||
|
if (
|
||||||
|
ball.x > b.x &&
|
||||||
|
ball.x < b.x + (CELL * 2) &&
|
||||||
|
ball.y > b.y &&
|
||||||
|
ball.y < b.y + CELL
|
||||||
|
) {
|
||||||
|
score += 100
|
||||||
|
b.alive = false
|
||||||
|
ball.dy *= -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// death
|
||||||
|
if (ball.y > ROWS * CELL + 100) dead = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draw(ctx: GameContext) {
|
||||||
|
ctx.clear("#6C6FF6")
|
||||||
|
|
||||||
|
const boardW = COLS * CELL
|
||||||
|
const boardH = ROWS * CELL + 100
|
||||||
|
const offsetX = (ctx.width - boardW) / 2
|
||||||
|
const offsetY = (ctx.height - boardH) / 2
|
||||||
|
|
||||||
|
const c = ctx.ctx
|
||||||
|
c.save()
|
||||||
|
c.translate(offsetX, offsetY)
|
||||||
|
|
||||||
|
// background
|
||||||
|
ctx.rectfill(0, 0, boardW, boardH, "black")
|
||||||
|
|
||||||
|
// paddle
|
||||||
|
ctx.rectfill(
|
||||||
|
paddleX,
|
||||||
|
ROWS * CELL + 60,
|
||||||
|
paddleX + PADDLE_W * CELL,
|
||||||
|
ROWS * CELL + 60 + CELL,
|
||||||
|
"white"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ball
|
||||||
|
ctx.circfill(ball.x, ball.y, 6, "red")
|
||||||
|
|
||||||
|
// bricks
|
||||||
|
for (const b of bricks) {
|
||||||
|
if (b.alive) {
|
||||||
|
const color = pickColor(b.x, b.y)
|
||||||
|
ctx.rectfill(b.x, b.y, (b.x + (CELL * 2)) - .5, (b.y + CELL) - .5, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// score!
|
||||||
|
ctx.text(`Score: ${score}`, 5, boardH - 18, "cyan", 12)
|
||||||
|
|
||||||
|
c.restore()
|
||||||
|
|
||||||
|
// ya dead
|
||||||
|
if (dead) {
|
||||||
|
ctx.centerTextX("GAME OVER", boardH + 30, "red", 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickColor(x: number, y: number): string {
|
||||||
|
const row = Math.floor(y / CELL)
|
||||||
|
const colorIndex = Math.floor(row / 2) % ROW_COLORS.length
|
||||||
|
return ROW_COLORS[colorIndex] || "white"
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,19 @@
|
||||||
|
// Show the contents of a file, if possible.
|
||||||
|
|
||||||
import { escapeHTML } from "bun"
|
import { escapeHTML } from "bun"
|
||||||
import { readdirSync } from "fs"
|
import { readdirSync } from "fs"
|
||||||
import { join, extname } from "path"
|
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 { 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"
|
||||||
|
import { projectName, projectDir } from "@/project"
|
||||||
|
import { getState } from "@/session"
|
||||||
|
|
||||||
export default async function (path: string) {
|
export default async function (path: string) {
|
||||||
const state = getState()
|
const project = projectName()
|
||||||
if (!state) return { error: "no state" }
|
const root = getState("cwd") || projectDir()
|
||||||
|
|
||||||
const project = state.project
|
|
||||||
if (!project) return { error: "no project loaded" }
|
|
||||||
|
|
||||||
const root = appPath(project)
|
|
||||||
if (!root) return { error: "error loading project" }
|
|
||||||
|
|
||||||
let files: string[] = []
|
let files: string[] = []
|
||||||
|
|
||||||
|
|
@ -42,7 +38,7 @@ async function readFile(path: string): Promise<CommandOutput> {
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case "jpeg": case "jpg": case "png": case "gif": case "webp":
|
case "jpeg": case "jpg": case "png": case "gif": case "webp":
|
||||||
const img = await file.arrayBuffer()
|
const img = await file.arrayBuffer()
|
||||||
return { html: `<img src="data:image/${ext};base64,${Buffer.from(img).toString('base64')}" />` }
|
return { html: `<img src="data:image/${ext};base64,${Buffer.from(img).toString('base64')}" style="max-height:270px;max-width:480px;" />` }
|
||||||
case "mp3": case "wav":
|
case "mp3": case "wav":
|
||||||
case "mp4": case "mov": case "avi": case "mkv": case "webm":
|
case "mp4": case "mov": case "avi": case "mkv": case "webm":
|
||||||
return "Not implemented"
|
return "Not implemented"
|
||||||
|
|
|
||||||
45
app/nose/bin/cd.ts
Normal file
45
app/nose/bin/cd.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Change directory.
|
||||||
|
|
||||||
|
import { dirname, resolve, isAbsolute } from "path"
|
||||||
|
import { statSync } from "fs"
|
||||||
|
import { projectDir } from "@/project"
|
||||||
|
import { getState, setState } from "@/session"
|
||||||
|
|
||||||
|
export default async function (path?: string) {
|
||||||
|
const root = projectDir()
|
||||||
|
const cwd = getState("cwd") || root
|
||||||
|
|
||||||
|
if (!path || path.trim() === "") {
|
||||||
|
setState("cwd", root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.endsWith("/")) path = path.slice(0, -1)
|
||||||
|
|
||||||
|
if (path === ".") return
|
||||||
|
if (path === "..") {
|
||||||
|
if (cwd !== root) {
|
||||||
|
const parent = dirname(cwd)
|
||||||
|
if (parent.startsWith(root)) {
|
||||||
|
setState("cwd", parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = isAbsolute(path) ? resolve(path) : resolve(cwd, path)
|
||||||
|
|
||||||
|
if (!target.startsWith(root))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = statSync(target)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
setState("cwd", target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: `${path} doesn't exist` }
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// stream() demo. Counts.
|
||||||
|
|
||||||
import { stream } from "@/stream"
|
import { stream } from "@/stream"
|
||||||
|
|
||||||
export default async function () {
|
export default async function () {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Monkey see, monkey do.
|
||||||
|
|
||||||
export default function (...args: string[]): string {
|
export default function (...args: string[]): string {
|
||||||
return args.join(" ")
|
return args.join(" ")
|
||||||
}
|
}
|
||||||
|
|
@ -1,23 +1,18 @@
|
||||||
import { escapeHTML } from "bun"
|
// Open the Advanced Text Editor and start editing a file.
|
||||||
|
|
||||||
import { readdirSync } from "fs"
|
import { readdirSync } from "fs"
|
||||||
import { join, extname } from "path"
|
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 { 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"
|
||||||
|
import { projectName, projectDir } from "@/project"
|
||||||
|
import { getState } from "@/session"
|
||||||
|
|
||||||
export default async function (path: string) {
|
export default async function (path: string) {
|
||||||
const state = getState()
|
const project = projectName()
|
||||||
if (!state) return { error: "no state" }
|
const root = getState("cwd") || projectDir()
|
||||||
|
|
||||||
const project = state.project
|
|
||||||
if (!project) return { error: "no project loaded" }
|
|
||||||
|
|
||||||
const root = appPath(project)
|
|
||||||
if (!root) return { error: "error loading project" }
|
|
||||||
|
|
||||||
let files: string[] = []
|
let files: string[] = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
// NOSE developer feature.
|
||||||
|
//
|
||||||
|
// Show some debugging information.
|
||||||
|
|
||||||
import { NOSE_STARTED, NOSE_SYS, NOSE_DIR, GIT_SHA } from "@/config"
|
import { NOSE_STARTED, NOSE_SYS, NOSE_DIR, GIT_SHA } from "@/config"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
/// <reference lib="dom" />
|
/// <reference lib="dom" />
|
||||||
|
// Small game demo.
|
||||||
|
|
||||||
export const game = true
|
export const game = true
|
||||||
|
|
||||||
import type { InputState } from "@/shared/types"
|
import type { GameContext, InputState } from "@/shared/game"
|
||||||
import type { GameContext } from "@/shared/game"
|
|
||||||
import { rng } from "@/shared/utils.ts"
|
import { rng } from "@/shared/utils.ts"
|
||||||
|
|
||||||
const WIDTH = 960
|
const WIDTH = 960
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function (name: string): string {
|
|
||||||
return `Hi, ${name || "stranger"}!!!!`
|
|
||||||
}
|
|
||||||
26
app/nose/bin/help.ts
Normal file
26
app/nose/bin/help.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Show helpful information about a command.
|
||||||
|
//
|
||||||
|
// (Hopefully.)
|
||||||
|
|
||||||
|
import { commandPath } from "@/commands"
|
||||||
|
|
||||||
|
export default async function (cmd: string) {
|
||||||
|
const path = commandPath(cmd)
|
||||||
|
if (!path) throw `${cmd} not found`
|
||||||
|
|
||||||
|
const code = (await Bun.file(path).text()).split("\n")
|
||||||
|
let docs = []
|
||||||
|
|
||||||
|
for (const line of code) {
|
||||||
|
if (line.startsWith("///")) {
|
||||||
|
docs.push("Runs in the browser.\n")
|
||||||
|
continue
|
||||||
|
} else if (line.startsWith("//")) {
|
||||||
|
docs.push(line.slice(2).trim())
|
||||||
|
} else if (line.trim()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs.join("\n")
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
|
// Load a project so you can work on it.
|
||||||
|
|
||||||
import { apps } from "app/src/webapp"
|
import { apps } from "app/src/webapp"
|
||||||
import { getState } from "@/session"
|
import { getState } from "@/session"
|
||||||
|
|
||||||
export default function (project: string) {
|
export default function (project: string) {
|
||||||
const state = getState()
|
const state = getState()
|
||||||
|
if (!project) throw `usage: load <project name>`
|
||||||
|
|
||||||
if (state && apps().includes(project)) {
|
if (state && apps().includes(project)) {
|
||||||
state.project = project
|
state.project = project
|
||||||
|
state.cwd = ""
|
||||||
|
} else {
|
||||||
|
return { error: `failed to load ${project}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
return state?.project ? `loaded ${project}` : { error: `failed to load ${project}` }
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { readdirSync } from "fs"
|
|
||||||
import { NOSE_WWW } from "app/src/config"
|
|
||||||
import { getState } from "@/session"
|
|
||||||
import { appPath } from "app/src/webapp"
|
|
||||||
|
|
||||||
export default function () {
|
|
||||||
const state = getState()
|
|
||||||
if (!state) return { error: "no state" }
|
|
||||||
|
|
||||||
const project = state.project
|
|
||||||
if (!project) return { error: "no project loaded" }
|
|
||||||
|
|
||||||
const root = appPath(project)
|
|
||||||
if (!root) return { error: "error loading project" }
|
|
||||||
|
|
||||||
let files: string[] = []
|
|
||||||
|
|
||||||
for (const file of readdirSync(root, { withFileTypes: true })) {
|
|
||||||
files.push(file.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root === NOSE_WWW) {
|
|
||||||
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
|
|
||||||
}
|
|
||||||
|
|
||||||
return files.join(" ")
|
|
||||||
}
|
|
||||||
28
app/nose/bin/ls.tsx
Normal file
28
app/nose/bin/ls.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Look around.
|
||||||
|
|
||||||
|
import { readdirSync } from "fs"
|
||||||
|
import { NOSE_WWW } from "app/src/config"
|
||||||
|
import { projectName, projectDir } from "@/project"
|
||||||
|
import { getState } from "@/session"
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const project = projectName()
|
||||||
|
const root = getState("cwd") || projectDir()
|
||||||
|
|
||||||
|
let files: string[] = []
|
||||||
|
|
||||||
|
for (const file of readdirSync(root, { withFileTypes: true })) {
|
||||||
|
files.push(file.isDirectory() ? `${file.name}/` : file.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root === NOSE_WWW) {
|
||||||
|
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{root !== projectDir() && <a href="#cd ..;ls">..</a>}
|
||||||
|
{files.map(file =>
|
||||||
|
<a href={file.endsWith('/') ? `#cd ${file};ls` : `#cat ${file}`}>{file}</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
20
app/nose/bin/mkdir.ts
Normal file
20
app/nose/bin/mkdir.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Makes a directory inside the current directory.
|
||||||
|
//
|
||||||
|
// Essentially `mkdir -p`.
|
||||||
|
|
||||||
|
import { mkdirSync } from "fs"
|
||||||
|
import { join } from "path"
|
||||||
|
import { projectDir } from "@/project"
|
||||||
|
import { readdirSync } from "fs"
|
||||||
|
import { getState } from "@/session"
|
||||||
|
|
||||||
|
export default async function (path: string) {
|
||||||
|
if (path.endsWith("/")) path = path.slice(0, path.length - 1)
|
||||||
|
|
||||||
|
const root = getState("cwd") || projectDir()
|
||||||
|
for (const file of readdirSync(root, { withFileTypes: true }))
|
||||||
|
if (file.name === path) throw `${path} exists`
|
||||||
|
|
||||||
|
mkdirSync(join(root, path), { recursive: true })
|
||||||
|
return `${path} created`
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
// Create a new project.
|
||||||
|
//
|
||||||
|
// We should probably rename this...
|
||||||
|
|
||||||
import { mkdirSync, writeFileSync } from "fs"
|
import { mkdirSync, writeFileSync } from "fs"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
|
|
||||||
|
|
@ -8,7 +12,7 @@ import { isDir } from "app/src/utils"
|
||||||
import load from "./load"
|
import load from "./load"
|
||||||
|
|
||||||
export default function (project: string) {
|
export default function (project: string) {
|
||||||
if (!project) throw "Please provide a name for your new project\n> new <project>"
|
if (!project) throw "usage: new <project name>"
|
||||||
|
|
||||||
if (apps().includes(project)) throw `${project} already exists`
|
if (apps().includes(project)) throw `${project} already exists`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Print the currently loaded project.
|
||||||
|
|
||||||
import { getState } from "@/session"
|
import { getState } from "@/session"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Show the projects on this NOSEputer.
|
||||||
|
|
||||||
import { apps } from "app/src/webapp"
|
import { apps } from "app/src/webapp"
|
||||||
import { getState } from "@/session"
|
import { getState } from "@/session"
|
||||||
|
|
||||||
|
|
@ -5,5 +7,7 @@ export default function () {
|
||||||
const state = getState()
|
const state = getState()
|
||||||
if (!state) return { error: "no state" }
|
if (!state) return { error: "no state" }
|
||||||
|
|
||||||
return { html: apps().map(app => app === state.project ? `<b class="cyan">${app}</b>` : app).join(" ") }
|
return <>
|
||||||
|
{apps().map(app => <a href={`#load ${app}`} class={app === state.project ? "magenta" : ""}>{app}</a>)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
10
app/nose/bin/pwd.ts
Normal file
10
app/nose/bin/pwd.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Show the current working directory.
|
||||||
|
|
||||||
|
import { dirname } from "path"
|
||||||
|
import { projectDir } from "@/project"
|
||||||
|
import { getState } from "@/session"
|
||||||
|
|
||||||
|
export default async function () {
|
||||||
|
const root = projectDir()
|
||||||
|
return (getState("cwd") || root).replace(dirname(root), "")
|
||||||
|
}
|
||||||
27
app/nose/bin/rm.ts
Normal file
27
app/nose/bin/rm.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Remove a file.
|
||||||
|
|
||||||
|
import { unlinkSync, readdirSync } from "fs"
|
||||||
|
import { join } from "path"
|
||||||
|
import { projectDir } from "@/project"
|
||||||
|
import { getState } from "@/session"
|
||||||
|
|
||||||
|
export default function (path: string) {
|
||||||
|
let target = ""
|
||||||
|
if (path.endsWith("/")) path = path.slice(0, path.length - 1)
|
||||||
|
|
||||||
|
const root = getState("cwd") || projectDir()
|
||||||
|
for (const file of readdirSync(root, { withFileTypes: true }))
|
||||||
|
if (file.name === path) {
|
||||||
|
if (file.isDirectory())
|
||||||
|
return { error: "Use `rmdir` to remove directory" }
|
||||||
|
|
||||||
|
target = file.name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target)
|
||||||
|
return { error: `${path} not found` }
|
||||||
|
|
||||||
|
unlinkSync(join(root, path))
|
||||||
|
return `${path} removed`
|
||||||
|
}
|
||||||
27
app/nose/bin/rmdir.ts
Normal file
27
app/nose/bin/rmdir.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Remove a directory, and all its children.
|
||||||
|
|
||||||
|
import { rmdirSync, readdirSync } from "fs"
|
||||||
|
import { join } from "path"
|
||||||
|
import { projectDir } from "@/project"
|
||||||
|
import { getState } from "@/session"
|
||||||
|
|
||||||
|
export default function (path: string) {
|
||||||
|
let target = ""
|
||||||
|
if (path.endsWith("/")) path = path.slice(0, path.length - 1)
|
||||||
|
|
||||||
|
const root = getState("cwd") || projectDir()
|
||||||
|
for (const file of readdirSync(root, { withFileTypes: true }))
|
||||||
|
if (file.name === path) {
|
||||||
|
if (file.isFile())
|
||||||
|
return { error: "Use `rm` to remove files" }
|
||||||
|
|
||||||
|
target = file.name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target)
|
||||||
|
return { error: `${path} not found` }
|
||||||
|
|
||||||
|
rmdirSync(join(root, path), { recursive: true })
|
||||||
|
return `${path} removed`
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Share a webapp with the public internet.
|
||||||
|
|
||||||
import { apps } from "app/src/webapp"
|
import { apps } from "app/src/webapp"
|
||||||
import { connectSneaker, sneakers, sneakerUrl } from "app/src/sneaker"
|
import { connectSneaker, sneakers, sneakerUrl } from "app/src/sneaker"
|
||||||
|
|
||||||
|
|
|
||||||
102
app/nose/bin/snake.ts
Normal file
102
app/nose/bin/snake.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/// <reference lib="dom" />
|
||||||
|
export const game = true
|
||||||
|
|
||||||
|
import type { GameContext, InputState } from "@/shared/game"
|
||||||
|
import { rng } from "@/shared/utils.ts"
|
||||||
|
|
||||||
|
const CELL = 20
|
||||||
|
const WIDTH = 30
|
||||||
|
const HEIGHT = 20
|
||||||
|
const TICK = 5
|
||||||
|
|
||||||
|
let snake: { x: number; y: number }[] = [{ x: 5, y: 5 }]
|
||||||
|
let dir = { x: 1, y: 0 }
|
||||||
|
let food = { x: rng(WIDTH) - 1, y: rng(HEIGHT) - 1 }
|
||||||
|
let dead = false
|
||||||
|
let tick = 0
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
snake = [{ x: 5, y: 5 }]
|
||||||
|
dir = { x: 1, y: 0 }
|
||||||
|
food = { x: rng(WIDTH) - 1, y: rng(HEIGHT) - 1 }
|
||||||
|
dead = false
|
||||||
|
tick = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function update(_delta: number, input: InputState) {
|
||||||
|
if (dead) return
|
||||||
|
|
||||||
|
const keys = input.pressed
|
||||||
|
|
||||||
|
if (keys.has("ArrowUp") || keys.has("w")) dir = { x: 0, y: -1 }
|
||||||
|
if (keys.has("ArrowDown") || keys.has("s")) dir = { x: 0, y: 1 }
|
||||||
|
if (keys.has("ArrowLeft") || keys.has("a")) dir = { x: -1, y: 0 }
|
||||||
|
if (keys.has("ArrowRight") || keys.has("d")) dir = { x: 1, y: 0 }
|
||||||
|
|
||||||
|
// move every $TICK ticks
|
||||||
|
if (++tick % TICK !== 0) return
|
||||||
|
|
||||||
|
const head = { x: snake[0]!.x + dir.x, y: snake[0]!.y + dir.y }
|
||||||
|
|
||||||
|
// death checks
|
||||||
|
dead = head.x < 0 || head.x >= WIDTH || head.y < 0 || head.y >= HEIGHT ||
|
||||||
|
snake.some(s => s.x === head.x && s.y === head.y)
|
||||||
|
|
||||||
|
if (dead) return
|
||||||
|
|
||||||
|
snake.unshift(head)
|
||||||
|
|
||||||
|
// eat
|
||||||
|
if (head.x === food.x && head.y === food.y) {
|
||||||
|
food = { x: rng(WIDTH) - 1, y: rng(HEIGHT) - 1 }
|
||||||
|
} else {
|
||||||
|
snake.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draw(ctx: GameContext) {
|
||||||
|
ctx.clear()
|
||||||
|
ctx.rectfill(0, 0, ctx.width, ctx.height, "black")
|
||||||
|
|
||||||
|
const boardW = WIDTH * CELL
|
||||||
|
const boardH = HEIGHT * CELL
|
||||||
|
|
||||||
|
// move board center → canvas center
|
||||||
|
const offsetX = (ctx.width - boardW) / 2
|
||||||
|
const offsetY = (ctx.height - boardH) / 2
|
||||||
|
|
||||||
|
console.log("X", offsetX)
|
||||||
|
console.log("Y", offsetY)
|
||||||
|
|
||||||
|
const c = ctx.ctx
|
||||||
|
c.save()
|
||||||
|
c.translate(offsetX, offsetY)
|
||||||
|
|
||||||
|
// board background (now local 0,0 is board top-left)
|
||||||
|
ctx.rectfill(0, 0, boardW, boardH, "green")
|
||||||
|
|
||||||
|
// food
|
||||||
|
ctx.rectfill(
|
||||||
|
food.x * CELL, food.y * CELL,
|
||||||
|
(food.x + 1) * CELL, (food.y + 1) * CELL,
|
||||||
|
"lime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// snake
|
||||||
|
for (const s of snake) {
|
||||||
|
ctx.rectfill(
|
||||||
|
s.x * CELL, s.y * CELL,
|
||||||
|
((s.x + 1) * CELL) + .5, ((s.y + 1) * CELL) + .5,
|
||||||
|
"magenta"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// score!
|
||||||
|
ctx.text(`Score: ${snake.length - 1}`, 5, boardH - 18, "cyan", 12)
|
||||||
|
|
||||||
|
c.restore()
|
||||||
|
|
||||||
|
if (dead) {
|
||||||
|
ctx.centerText("GAME OVER", "red", 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/nose/bin/touch.ts
Normal file
17
app/nose/bin/touch.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Create an empty text file.
|
||||||
|
|
||||||
|
import { join } from "path"
|
||||||
|
import { readdirSync } from "fs"
|
||||||
|
import { projectDir } from "@/project"
|
||||||
|
import { getState } from "@/session"
|
||||||
|
|
||||||
|
export default async function (path: string) {
|
||||||
|
if (path.endsWith("/")) path = path.slice(0, path.length - 1)
|
||||||
|
|
||||||
|
const root = getState("cwd") || projectDir()
|
||||||
|
for (const file of readdirSync(root, { withFileTypes: true }))
|
||||||
|
if (file.name === path) throw `${path} exists`
|
||||||
|
|
||||||
|
await Bun.write(join(root, path), "")
|
||||||
|
return `${path} created`
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Stop sharing a webapp with the public internet.
|
||||||
|
|
||||||
import { apps } from "app/src/webapp"
|
import { apps } from "app/src/webapp"
|
||||||
import { disconnectSneaker, sneakers } from "app/src/sneaker"
|
import { disconnectSneaker, sneakers } from "app/src/sneaker"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Update NOSE itself and restart.
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
|
||||||
export default async function () {
|
export default async function () {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
// How long has the NOSE app been running?
|
||||||
|
//
|
||||||
|
// Totally different from the host computer's uptime. Usually.
|
||||||
|
|
||||||
import { NOSE_STARTED } from "@/config"
|
import { NOSE_STARTED } from "@/config"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
|
|
|
||||||
19
app/src/css/game.css
Normal file
19
app/src/css/game.css
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game {
|
||||||
|
background-color: white;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game.active {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
@ -71,10 +71,6 @@
|
||||||
color: var(--c64-dark-blue);
|
color: var(--c64-dark-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--cyan);
|
color: var(--cyan);
|
||||||
}
|
}
|
||||||
|
|
@ -88,9 +84,10 @@ body {
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
/* black bars */
|
|
||||||
background: var(--c64-light-blue);
|
background: var(--c64-light-blue);
|
||||||
color: var(--c64-light-blue);
|
color: var(--c64-light-blue);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,10 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#scrollback .output a {
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
#scrollback .input .content {
|
#scrollback .input .content {
|
||||||
margin-left: var(--cli-status-width);
|
margin-left: var(--cli-status-width);
|
||||||
}
|
}
|
||||||
|
|
@ -148,15 +152,3 @@
|
||||||
#scrollback .output {
|
#scrollback .output {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* games */
|
|
||||||
|
|
||||||
.game {
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game.active {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
@ -16,6 +16,7 @@ const host = hostRaw.toString().trim()
|
||||||
let dnsInit = false
|
let dnsInit = false
|
||||||
|
|
||||||
export async function initDNS() {
|
export async function initDNS() {
|
||||||
|
if (process.env.NODE_ENV !== "production") return
|
||||||
dnsInit = true
|
dnsInit = true
|
||||||
|
|
||||||
apps().forEach(publishAppDNS)
|
apps().forEach(publishAppDNS)
|
||||||
|
|
@ -31,8 +32,8 @@ export async function initDNS() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publishAppDNS(app: string) {
|
export function publishAppDNS(app: string) {
|
||||||
if (!dnsInit) throw "publishAppDNS() must be called after initDNS()"
|
|
||||||
if (process.env.NODE_ENV !== "production") return
|
if (process.env.NODE_ENV !== "production") return
|
||||||
|
if (!dnsInit) throw "publishAppDNS() must be called after initDNS()"
|
||||||
|
|
||||||
if (!dnsEntries[app])
|
if (!dnsEntries[app])
|
||||||
dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip])
|
dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip])
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,10 @@ export const Layout: FC = async ({ children, title }) => (
|
||||||
|
|
||||||
<link href="/css/reset.css" rel="stylesheet" />
|
<link href="/css/reset.css" rel="stylesheet" />
|
||||||
<link href="/css/main.css" rel="stylesheet" />
|
<link href="/css/main.css" rel="stylesheet" />
|
||||||
|
<link href="/css/game.css" rel="stylesheet" />
|
||||||
|
|
||||||
<script type="importmap" dangerouslySetInnerHTML={{
|
<script type="importmap" dangerouslySetInnerHTML={{ __html: `{ "imports": { "@/": "/" } }` }} />
|
||||||
__html: `{ "imports": { "@/": "/" } }`
|
|
||||||
}} />
|
|
||||||
<script src="/js/main.js" type="module" async></script>
|
<script src="/js/main.js" type="module" async></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body data-mode="tall">
|
<body data-mode="tall">
|
||||||
<main>
|
<main>
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,12 @@ export function focusHandler(e: MouseEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// let them click on links
|
if (["INPUT", "TEXTAREA", "CANVAS", "A"].includes(target.tagName))
|
||||||
const a = target.closest("a")
|
return false
|
||||||
|
|
||||||
if (!a) {
|
const selection = window.getSelection() || ""
|
||||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "CANVAS")
|
if (selection.toString() === "")
|
||||||
return false
|
focusInput()
|
||||||
|
|
||||||
const selection = window.getSelection() || ""
|
e.preventDefault()
|
||||||
if (selection.toString() === "")
|
|
||||||
focusInput()
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
import type { Message, InputState } from "../shared/types.js"
|
import type { Message, InputState } from "../shared/types.js"
|
||||||
import { GameContext } from "../shared/game.js"
|
import { GameContext } from "../shared/game.js"
|
||||||
import { focusInput } from "./focus.js"
|
import { focusInput } from "./focus.js"
|
||||||
import { $ } from "./dom.js"
|
import { $$, scrollback } from "./dom.js"
|
||||||
import { randomId } from "../shared/utils.js"
|
import { randomId } from "../shared/utils.js"
|
||||||
import { setStatus, addOutput } from "./scrollback.js"
|
import { setStatus, addOutput } from "./scrollback.js"
|
||||||
import { browserCommands } from "./commands.js"
|
import { browserCommands } from "./commands.js"
|
||||||
|
|
||||||
const FPS = 30
|
const FPS = 30
|
||||||
|
const HEIGHT = 540
|
||||||
|
const WIDTH = 980
|
||||||
|
|
||||||
|
type Game = { init?: () => void, update?: (delta: number, input: InputState) => void, draw?: (ctx: GameContext) => void }
|
||||||
|
|
||||||
let oldMode = "cinema"
|
let oldMode = "cinema"
|
||||||
let running = false
|
let running = false
|
||||||
let canvas: HTMLCanvasElement
|
let canvas: HTMLCanvasElement
|
||||||
type Game = { init?: () => void, update?: (delta: number, input: InputState) => void, draw?: (ctx: GameContext) => void }
|
|
||||||
|
|
||||||
const pressedStack = new Set<string>()
|
const pressedStack = new Set<string>()
|
||||||
|
|
||||||
let pressed: InputState = {
|
let pressed: InputState = {
|
||||||
|
|
@ -35,34 +38,41 @@ export async function handleGameStart(msg: Message) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvasId = randomId()
|
|
||||||
addOutput(msgId, { html: `<canvas id="${canvasId}" class="game active" height="540" width="960" tabindex="0"></canvas>` })
|
|
||||||
|
|
||||||
if (document.body.dataset.mode === "tall") {
|
if (document.body.dataset.mode === "tall") {
|
||||||
browserCommands.mode?.()
|
browserCommands.mode?.()
|
||||||
oldMode = "tall"
|
oldMode = "tall"
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas = $(canvasId) as HTMLCanvasElement
|
canvas = createCanvas()
|
||||||
canvas.focus()
|
canvas.focus()
|
||||||
setStatus(msgId, "ok")
|
setStatus(msgId, "ok")
|
||||||
canvas.addEventListener("keydown", handleKeydown)
|
canvas.addEventListener("keydown", handleKeydown)
|
||||||
canvas.addEventListener("keyup", handleKeyup)
|
canvas.addEventListener("keyup", handleKeyup)
|
||||||
|
window.addEventListener("resize", resizeCanvas)
|
||||||
|
resizeCanvas()
|
||||||
gameLoop(new GameContext(canvas.getContext("2d")!), game)
|
gameLoop(new GameContext(canvas.getContext("2d")!), game)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCanvas(): HTMLCanvasElement {
|
||||||
|
const canvas = $$("canvas.game.active") as HTMLCanvasElement
|
||||||
|
canvas.id = randomId()
|
||||||
|
canvas.height = HEIGHT
|
||||||
|
canvas.width = WIDTH
|
||||||
|
canvas.tabIndex = 0
|
||||||
|
|
||||||
|
const main = document.querySelector("main")
|
||||||
|
main?.parentNode?.insertBefore(canvas, main)
|
||||||
|
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
pressedStack.add(e.key)
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (e.key === "Escape" || (e.ctrlKey && e.key === "c")) {
|
if (e.key === "Escape" || (e.ctrlKey && e.key === "c")) {
|
||||||
running = false
|
endGame()
|
||||||
if (oldMode === "tall") browserCommands.mode?.()
|
|
||||||
canvas.classList.remove("active")
|
|
||||||
canvas.style.height = canvas.height / 2 + "px"
|
|
||||||
canvas.style.width = canvas.width / 2 + "px"
|
|
||||||
focusInput()
|
|
||||||
} else {
|
} else {
|
||||||
|
pressedStack.add(e.key)
|
||||||
pressed.key = e.key
|
pressed.key = e.key
|
||||||
pressed.ctrl = e.ctrlKey
|
pressed.ctrl = e.ctrlKey
|
||||||
pressed.shift = e.shiftKey
|
pressed.shift = e.shiftKey
|
||||||
|
|
@ -80,6 +90,19 @@ function handleKeyup(e: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
const scale = Math.min(
|
||||||
|
window.innerWidth / 960,
|
||||||
|
window.innerHeight / 540
|
||||||
|
)
|
||||||
|
|
||||||
|
canvas.width = 960 * scale
|
||||||
|
canvas.height = 540 * scale
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")!
|
||||||
|
ctx.setTransform(scale, 0, 0, scale, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
function gameLoop(ctx: GameContext, game: Game) {
|
function gameLoop(ctx: GameContext, game: Game) {
|
||||||
running = true
|
running = true
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
@ -100,3 +123,19 @@ function gameLoop(ctx: GameContext, game: Game) {
|
||||||
|
|
||||||
requestAnimationFrame(loop)
|
requestAnimationFrame(loop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function endGame() {
|
||||||
|
running = false
|
||||||
|
|
||||||
|
if (oldMode === "tall") browserCommands.mode?.()
|
||||||
|
|
||||||
|
canvas.classList.remove("active")
|
||||||
|
canvas.style.height = HEIGHT / 2 + "px"
|
||||||
|
canvas.style.width = WIDTH / 2 + "px"
|
||||||
|
|
||||||
|
const output = $$("li.output")
|
||||||
|
output.append(canvas)
|
||||||
|
scrollback.append(output)
|
||||||
|
|
||||||
|
focusInput()
|
||||||
|
}
|
||||||
24
app/src/js/hyperlink.ts
Normal file
24
app/src/js/hyperlink.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { runCommand } from "./shell.js"
|
||||||
|
import { focusInput } from "./focus.js"
|
||||||
|
|
||||||
|
export function initHyperlink() {
|
||||||
|
window.addEventListener("click", handleClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
const target = e.target
|
||||||
|
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
const a = target.closest("a")
|
||||||
|
if (!a) return
|
||||||
|
|
||||||
|
const href = a.getAttribute("href")
|
||||||
|
if (!href) return
|
||||||
|
|
||||||
|
if (href.startsWith("#")) {
|
||||||
|
e.preventDefault()
|
||||||
|
runCommand(href.slice(1))
|
||||||
|
focusInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { initCursor } from "./cursor.js"
|
||||||
import { initEditor } from "./editor.js"
|
import { initEditor } from "./editor.js"
|
||||||
import { initFocus } from "./focus.js"
|
import { initFocus } from "./focus.js"
|
||||||
import { initHistory } from "./history.js"
|
import { initHistory } from "./history.js"
|
||||||
|
import { initHyperlink } from "./hyperlink.js"
|
||||||
import { initInput } from "./input.js"
|
import { initInput } from "./input.js"
|
||||||
import { initResize } from "./resize.js"
|
import { initResize } from "./resize.js"
|
||||||
import { startVramCounter } from "./vram.js"
|
import { startVramCounter } from "./vram.js"
|
||||||
|
|
@ -13,6 +14,7 @@ initCursor()
|
||||||
initFocus()
|
initFocus()
|
||||||
initEditor()
|
initEditor()
|
||||||
initHistory()
|
initHistory()
|
||||||
|
initHyperlink()
|
||||||
initInput()
|
initInput()
|
||||||
initResize()
|
initResize()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,12 @@ export function addOutput(id: string, output: CommandOutput) {
|
||||||
else
|
else
|
||||||
item.textContent = content
|
item.textContent = content
|
||||||
|
|
||||||
scrollback.append(item)
|
const input = document.querySelector(`[data-id="${id}"].input`)
|
||||||
|
if (input instanceof HTMLLIElement)
|
||||||
|
input.parentNode!.insertBefore(item, input.nextSibling)
|
||||||
|
else
|
||||||
|
scrollback.append(item)
|
||||||
|
|
||||||
autoScroll()
|
autoScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,7 +116,7 @@ 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 (output.html !== undefined) {
|
} else if ("html" in output) {
|
||||||
html = true
|
html = true
|
||||||
content = output.html
|
content = output.html
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ import { handleGameStart } from "./game.js"
|
||||||
export function runCommand(input: string) {
|
export function runCommand(input: string) {
|
||||||
if (!input.trim()) return
|
if (!input.trim()) return
|
||||||
|
|
||||||
|
if (input.includes(";")) {
|
||||||
|
input.split(";").forEach(cmd => runCommand(cmd.trim()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const id = randomId()
|
const id = randomId()
|
||||||
|
|
||||||
addToHistory(input)
|
addToHistory(input)
|
||||||
|
|
|
||||||
27
app/src/project.ts
Normal file
27
app/src/project.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
////
|
||||||
|
// Helpers for working with projects in the CLI.
|
||||||
|
|
||||||
|
import { readdirSync, type Dirent } from "fs"
|
||||||
|
import { getState } from "./session"
|
||||||
|
import { appDir } from "./webapp"
|
||||||
|
|
||||||
|
export function projectName(): string {
|
||||||
|
const state = getState()
|
||||||
|
if (!state) throw "no state"
|
||||||
|
|
||||||
|
const project = state.project
|
||||||
|
if (!project) throw "no project loaded"
|
||||||
|
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectDir(): string {
|
||||||
|
const root = appDir(projectName())
|
||||||
|
if (!root) throw "error loading project"
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectFiles(): Dirent[] {
|
||||||
|
return readdirSync(projectDir(), { recursive: true, withFileTypes: true })
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ export type Session = {
|
||||||
taskId?: string
|
taskId?: string
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
project?: string
|
project?: string
|
||||||
|
cwd?: string
|
||||||
ws?: any
|
ws?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -14,6 +15,17 @@ export type Session = {
|
||||||
const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<Session> }
|
const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<Session> }
|
||||||
export const ALS = g.__thread ??= new AsyncLocalStorage<Session>()
|
export const ALS = g.__thread ??= new AsyncLocalStorage<Session>()
|
||||||
|
|
||||||
export function getState(): Session | undefined {
|
export function getState(key?: keyof Session): Session | any | undefined {
|
||||||
return ALS.getStore()
|
const store = ALS.getStore()
|
||||||
|
if (!store) return
|
||||||
|
|
||||||
|
if (key) return store[key]
|
||||||
|
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setState(key: keyof Session, value: any) {
|
||||||
|
const store = ALS.getStore()
|
||||||
|
if (!store) return
|
||||||
|
store[key] = value
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,59 @@
|
||||||
|
export type InputState = { key: string, shift: boolean, ctrl: boolean, meta: boolean, pressed: Set<string> }
|
||||||
|
|
||||||
export class GameContext {
|
export class GameContext {
|
||||||
constructor(public ctx: CanvasRenderingContext2D) { }
|
constructor(public ctx: CanvasRenderingContext2D) { }
|
||||||
|
|
||||||
get width() { return this.ctx.canvas.width }
|
width = 960
|
||||||
get height() { return this.ctx.canvas.height }
|
height = 540
|
||||||
|
|
||||||
clear() {
|
clear(color?: string) {
|
||||||
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
|
if (color)
|
||||||
|
this.rectfill(0, 0, this.ctx.canvas.width, this.ctx.canvas.height, color)
|
||||||
|
else
|
||||||
|
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
text(msg: string, x: number, y: number, color = "black", size = 16, font = "C64ProMono") {
|
||||||
|
const c = this.ctx
|
||||||
|
c.save()
|
||||||
|
c.fillStyle = color
|
||||||
|
c.font = `${size}px ${font}`
|
||||||
|
c.textBaseline = "top"
|
||||||
|
c.fillText(msg, x, y)
|
||||||
|
c.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
centerText(msg: string, color = "black", size = 16, font = "C64ProMono") {
|
||||||
|
const c = this.ctx
|
||||||
|
c.save()
|
||||||
|
c.fillStyle = color
|
||||||
|
c.font = `${size}px ${font}`
|
||||||
|
c.textBaseline = "middle"
|
||||||
|
c.textAlign = "center"
|
||||||
|
c.fillText(msg, this.width / 2, this.height / 2)
|
||||||
|
c.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
centerTextX(msg: string, y: number, color = "black", size = 16, font = "C64ProMono") {
|
||||||
|
const c = this.ctx
|
||||||
|
c.save()
|
||||||
|
c.fillStyle = color
|
||||||
|
c.font = `${size}px ${font}`
|
||||||
|
c.textBaseline = "middle"
|
||||||
|
c.textAlign = "center"
|
||||||
|
c.fillText(msg, this.width / 2, y)
|
||||||
|
c.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
centerTextY(msg: string, x: number, color = "black", size = 16, font = "C64ProMono") {
|
||||||
|
const c = this.ctx
|
||||||
|
c.save()
|
||||||
|
c.fillStyle = color
|
||||||
|
c.font = `${size}px ${font}`
|
||||||
|
c.textBaseline = "middle"
|
||||||
|
c.textAlign = "center"
|
||||||
|
c.fillText(msg, x, this.height / 2)
|
||||||
|
c.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
circ(x: number, y: number, r: number, color = "black") {
|
circ(x: number, y: number, r: number, color = "black") {
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,3 @@ export type CommandResult = {
|
||||||
status: "ok" | "error"
|
status: "ok" | "error"
|
||||||
output: CommandOutput
|
output: CommandOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InputState = { key: string, shift: boolean, ctrl: boolean, meta: boolean, pressed: Set<string> }
|
|
||||||
|
|
|
||||||
105
app/src/sniff.ts
Normal file
105
app/src/sniff.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// sniffs the types and params of a file's exports, including const values
|
||||||
|
|
||||||
|
import ts from "typescript"
|
||||||
|
|
||||||
|
export type Signature = {
|
||||||
|
type: string,
|
||||||
|
returnType: string | null,
|
||||||
|
params: Param[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Param = {
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
optional: boolean,
|
||||||
|
rest: boolean,
|
||||||
|
default: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SniffError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = "TypeError"
|
||||||
|
Object.setPrototypeOf(this, SniffError.prototype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportInfo =
|
||||||
|
| { kind: "function", name: string, signatures: Signature[] }
|
||||||
|
| { kind: "value", name: string, type: string, value: string | null }
|
||||||
|
|
||||||
|
let prevProgram: ts.Program | undefined
|
||||||
|
|
||||||
|
|
||||||
|
export async function allExports(file: string): Promise<ExportInfo[]> {
|
||||||
|
const program = ts.createProgram([file], {
|
||||||
|
target: ts.ScriptTarget.ESNext,
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
||||||
|
noLib: true,
|
||||||
|
types: [],
|
||||||
|
skipDefaultLibCheck: true,
|
||||||
|
skipLibCheck: true
|
||||||
|
}, undefined, prevProgram)
|
||||||
|
prevProgram = program
|
||||||
|
|
||||||
|
const checker = program.getTypeChecker()
|
||||||
|
const sf = program.getSourceFile(file)
|
||||||
|
if (!sf) throw new SniffError(`File not found: ${file}`)
|
||||||
|
|
||||||
|
const moduleSym = (sf as any).symbol as ts.Symbol | undefined
|
||||||
|
if (!moduleSym) return []
|
||||||
|
|
||||||
|
const exportSymbols = checker.getExportsOfModule(moduleSym)
|
||||||
|
const result: ExportInfo[] = []
|
||||||
|
|
||||||
|
for (const sym of exportSymbols) {
|
||||||
|
const decl = sym.valueDeclaration ?? sym.declarations?.[0] ?? sf
|
||||||
|
const type = checker.getTypeOfSymbolAtLocation(sym, decl)
|
||||||
|
const sigs = checker.getSignaturesOfType(type, ts.SignatureKind.Call)
|
||||||
|
|
||||||
|
if (sigs.length > 0) {
|
||||||
|
result.push({
|
||||||
|
kind: "function",
|
||||||
|
name: sym.getName(),
|
||||||
|
signatures: sigs.map(sig => ({
|
||||||
|
type: checker.typeToString(type, decl),
|
||||||
|
returnType: checker.typeToString(checker.getReturnTypeOfSignature(sig), decl),
|
||||||
|
params: sig.getParameters().map(p => {
|
||||||
|
const pd: any = p.getDeclarations()?.[0] ?? decl
|
||||||
|
const pt = checker.getTypeOfSymbolAtLocation(p, pd)
|
||||||
|
return {
|
||||||
|
name: p.getName(),
|
||||||
|
type: checker.typeToString(pt, pd),
|
||||||
|
optional: !!pd.questionToken || !!pd.initializer,
|
||||||
|
rest: !!pd.dotDotDotToken,
|
||||||
|
default: pd.initializer ? pd.initializer.getText() : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let value: string | null = null
|
||||||
|
if (ts.isVariableDeclaration(decl) && decl.initializer) {
|
||||||
|
value = decl.initializer.getText()
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
kind: "value",
|
||||||
|
name: sym.getName(),
|
||||||
|
type: checker.typeToString(type, decl),
|
||||||
|
value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const path = Bun.argv[2]
|
||||||
|
if (!path) {
|
||||||
|
console.error("usage: sniff <path>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log(await allExports(path))
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,11 @@ export function isDir(path: string): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function mtime(path: string): Promise<Date> {
|
||||||
|
const { mtime } = await stat(path)
|
||||||
|
return mtime
|
||||||
|
}
|
||||||
|
|
||||||
// is the given file binary?
|
// is the given file binary?
|
||||||
export async function isBinaryFile(path: string): Promise<boolean> {
|
export async function isBinaryFile(path: string): Promise<boolean> {
|
||||||
// Create a stream to read just the beginning
|
// Create a stream to read just the beginning
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,23 @@ import { join, dirname } from "path"
|
||||||
import { readdirSync } from "fs"
|
import { readdirSync } from "fs"
|
||||||
|
|
||||||
import { NOSE_WWW } from "./config"
|
import { NOSE_WWW } from "./config"
|
||||||
import { isFile } from "./utils"
|
import { isFile, isDir } 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
|
||||||
|
|
||||||
export async function serveApp(c: Context, subdomain: string): Promise<Response> {
|
export async function serveApp(c: Context, subdomain: string): Promise<Response> {
|
||||||
const app = await findApp(subdomain)
|
const app = await findApp(subdomain)
|
||||||
|
const path = appDir(subdomain)
|
||||||
|
|
||||||
if (!app) return c.text(`App Not Found: ${subdomain}`, 404)
|
if (!path) return c.text(`App not found: ${subdomain}`, 404)
|
||||||
|
|
||||||
|
const staticPath = join(path, "pub", c.req.path === "/" ? "/index.html" : c.req.path)
|
||||||
|
|
||||||
|
if (isFile(staticPath))
|
||||||
|
return serveStatic(staticPath)
|
||||||
|
|
||||||
|
if (!app) return c.text(`App not found: ${subdomain}`, 404)
|
||||||
|
|
||||||
if (app instanceof Hono)
|
if (app instanceof Hono)
|
||||||
return app.fetch(c.req.raw)
|
return app.fetch(c.req.raw)
|
||||||
|
|
@ -33,20 +41,19 @@ export function apps(): string[] {
|
||||||
return apps.sort()
|
return apps.sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appPath(name: string): string | undefined {
|
export function appDir(name: string): string | undefined {
|
||||||
const path = [
|
const path = [
|
||||||
`${name}.ts`,
|
`${name}.ts`,
|
||||||
`${name}.tsx`,
|
`${name}.tsx`,
|
||||||
join(name, "index.ts"),
|
name
|
||||||
join(name, "index.tsx")
|
|
||||||
]
|
]
|
||||||
.map(path => join(NOSE_WWW, path))
|
.map(path => join(NOSE_WWW, path))
|
||||||
.flat()
|
.flat()
|
||||||
.filter(path => isFile(path))[0]
|
.filter(path => /\.tsx?$/.test(path) ? isFile(path) : isDir(path))[0]
|
||||||
|
|
||||||
if (!path) return
|
if (!path) return
|
||||||
|
|
||||||
return dirname(path)
|
return /\.tsx?$/.test(path) ? dirname(path) : path
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findApp(name: string): Promise<App | undefined> {
|
async function findApp(name: string): Promise<App | undefined> {
|
||||||
|
|
@ -63,8 +70,6 @@ async function findApp(name: string): Promise<App | undefined> {
|
||||||
app = await loadApp(join(NOSE_WWW, path))
|
app = await loadApp(join(NOSE_WWW, path))
|
||||||
if (app) return app
|
if (app) return app
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("can't find app:", name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadApp(path: string): Promise<App | undefined> {
|
async function loadApp(path: string): Promise<App | undefined> {
|
||||||
|
|
@ -87,3 +92,12 @@ export function toResponse(source: string | Child | Response): Response {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serveStatic(path: string): Response {
|
||||||
|
const file = Bun.file(path)
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user