site generator: commands/*.md with front matter -> k8s/site/
site / generated-is-current (push) Failing after 36s
site / generated-is-current (push) Failing after 36s
- build/build.mjs: one-file-per-command content (filename = command,
underscores = spaces; help/suggestion/params/prefix/text/hidden in
front matter; ${args} ${date} ${time} ${ls} ${help} resolved at runtime)
- generates index.html (banner + chips), commands.json (the single data
source), hello.txt for curl, style.css via tailwind v4
- src/site.js is now dispatch-only over commands.json; typewriter kept
- src/telnet.py: stdlib telnet shell sharing commands.json
- k8s: python telnet sidecar in the pod + LoadBalancer service 23->2323
- .gitea/workflows/site.yml fails CI when k8s/site/ is not the built output
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
name: site
|
||||||
|
|
||||||
|
# The cluster serves what is committed in k8s/site/ — so CI just enforces that
|
||||||
|
# k8s/site/ is exactly what the generator produces from commands/ + src/.
|
||||||
|
# If this job fails, run `npm run build` and commit the result.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
generated-is-current:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22-alpine
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- run: npm ci --no-audit --no-fund
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
- name: generated output is committed
|
||||||
|
run: |
|
||||||
|
git add -A k8s/site
|
||||||
|
if ! git diff --staged --quiet; then
|
||||||
|
echo "k8s/site/ is not in sync with commands/ + src/ — run 'npm run build' and commit:"
|
||||||
|
git diff --staged --stat
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -1,72 +1,76 @@
|
|||||||
# momme.world
|
# momme.world
|
||||||
|
|
||||||
Personal site at [momme.world](https://momme.world) — a terminal-style page
|
Personal site at [momme.world](https://momme.world) — a terminal-style page
|
||||||
that behaves like a small shell (`whoami`, `projects`, `social`, …), plus a
|
that behaves like a small shell, a plain-text rendering for
|
||||||
plain-text rendering for `curl momme.world`.
|
`curl momme.world`, and a real telnet shell for the tailnet.
|
||||||
|
|
||||||
## Layout
|
## How it is put together
|
||||||
|
|
||||||
```
|
**`commands/*.md` is the content.** One file per command, front matter for
|
||||||
k8s/
|
metadata, body for output. The filename is the command — underscores are
|
||||||
kustomization.yaml the whole deployment: generated ConfigMaps + refs
|
spaces:
|
||||||
deployment.yaml one unprivileged nginx, content served from ConfigMaps
|
|
||||||
service.yaml ClusterIP, :80 -> :8080
|
|
||||||
site/ the actual website, served as-is
|
|
||||||
index.html markup + noscript fallback
|
|
||||||
style.css
|
|
||||||
site.js the terminal; ALL site content lives in COMMANDS here
|
|
||||||
nginx.conf mounted as /etc/nginx/conf.d/default.conf
|
|
||||||
hello.txt what curl gets
|
|
||||||
```
|
|
||||||
|
|
||||||
There is deliberately **no build step**: no package.json, no bundler, no CI.
|
| file | command | notes |
|
||||||
Edit a file under `k8s/site/`, merge to `main`, ArgoCD syncs it. The content
|
|---|---|---|
|
||||||
of the site is the repo — the kustomize `configMapGenerator` hashes the files
|
| `help.md` | `help` | |
|
||||||
into their ConfigMap names, which rolls the Deployment automatically.
|
| `project_fpv.md` | `project fpv` | multi-word commands just work |
|
||||||
|
| `echo.md` | `echo hello world` | `params: <text>` + `${args}` in the body |
|
||||||
|
|
||||||
|
Front matter keys (all optional except the body):
|
||||||
|
|
||||||
|
| key | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `name:` | override the filename-derived command name |
|
||||||
|
| `help:` | one-line description; shown in `help` (no help = listed by `ls`, not in `help`) |
|
||||||
|
| `params:` | argument placeholder shown in help, e.g. `<text>` |
|
||||||
|
| `prefix: true` | command swallows anything typed after its name |
|
||||||
|
| `suggestion: true` | rendered as a chip button under the terminal |
|
||||||
|
| `hidden: true` | works, but stays out of `help` |
|
||||||
|
| `text: true` | included in the plain-text page curl clients see |
|
||||||
|
|
||||||
|
Dynamic placeholders inside a body, resolved when the command runs:
|
||||||
|
`${args}` (the arguments), `${help}` (the full listing), `${ls}` (subcommand
|
||||||
|
names), `${date}`, `${time}`.
|
||||||
|
|
||||||
## Editing the site
|
## Editing the site
|
||||||
|
|
||||||
- New terminal command: add one object to `COMMANDS` in `k8s/site/site.js`.
|
```bash
|
||||||
It appears in `help` and in tab-completion automatically. `label -> url`
|
npm install
|
||||||
lines become links.
|
# edit commands/, src/index.html or src/input.css
|
||||||
- The noscript block in `index.html` mirrors the main facts by hand; keep it
|
npm run build # regenerates k8s/site/
|
||||||
in sync when changing `whoami`/`contact` content.
|
git add -A && git commit && git push
|
||||||
- `curl`-UA detection is the `$root_doc` map in `k8s/site/nginx.conf`.
|
|
||||||
|
|
||||||
## How it deploys
|
|
||||||
|
|
||||||
The `momme/infra` module `app-momme-world` owns the namespace, the TLS
|
|
||||||
certificate and the HTTPProxy, and creates an ArgoCD `Application` that points
|
|
||||||
at `k8s/` in this repo. Nothing in this repo talks to the cluster.
|
|
||||||
|
|
||||||
## Local preview
|
|
||||||
|
|
||||||
Any static server works, e.g.:
|
|
||||||
|
|
||||||
```
|
|
||||||
cd k8s/site && python -m http.server 8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm run build` (build/build.mjs) generates everything in `k8s/site/`:
|
||||||
|
the HTML (banner + chips injected), `commands.json` (the single data source
|
||||||
|
both the browser runtime and the telnet shell read), `hello.txt` for curl, and
|
||||||
|
`style.css` via Tailwind v4. **The generated files are committed on purpose** —
|
||||||
|
that is what ArgoCD serves — and CI fails if `k8s/site/` is out of sync with
|
||||||
|
the sources, so a forgotten build can never desync the site.
|
||||||
|
|
||||||
|
Styling is [Tailwind v4](https://tailwindcss.com): theme tokens live in
|
||||||
|
`src/input.css`, class names in the template and chips. `src/site.js` is the
|
||||||
|
browser runtime (terminal loop + typewriter), `src/telnet.py` the tailnet
|
||||||
|
shell — both are dispatch over `commands.json`, neither carries content.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
`k8s/` is kustomize: one pod with nginx (site) and python (telnet shell), all
|
||||||
|
content as ConfigMaps, so a content change rolls the pod without any image
|
||||||
|
build. Deployed by ArgoCD from the `app-momme-world` module of the infra repo
|
||||||
|
(git.momme.cloud/momme/infra).
|
||||||
|
|
||||||
## Open decisions
|
## Open decisions
|
||||||
|
|
||||||
Things left deliberately open for you:
|
* **DNS** — `momme.world` still needs an A record (Cloudflare) pointing at the
|
||||||
|
cluster's public entry `31.18.25.47`; until then no certificate and no
|
||||||
1. **DNS** — `momme.world` currently has *no A record*. The zone lives in
|
public HTTPS. Also check that the `cf` ClusterIssuer's API token covers the
|
||||||
Cloudflare (nameservers `fred/jessica.ns.cloudflare.com`), the same place
|
`momme.world` zone (it was created for `momme.cloud`).
|
||||||
`momme.cloud` lives. An `A momme.world -> 31.18.25.47` (grey cloud, the
|
* **telnet exposure** — currently tailnet-only: `momme-world-telnet` is a
|
||||||
same public entry all `*.momme.cloud` services use) is all that's needed.
|
LoadBalancer service that needs a one-line IP-pool entry in
|
||||||
Also check that the Cloudflare API token behind the `cf` ClusterIssuer
|
`infra/sys-cilium/lb.tf` (pattern: `gitea-ssh`). Public port 23 would need a
|
||||||
(Infisical `/global`, `cloudflare_api_token`) is allowed to do DNS-01 in
|
TCPRoute on the njalla/Envoy funnel — doable, left open on purpose.
|
||||||
the momme.world zone — if it's scoped to momme.cloud only, widen it.
|
* The footer advertises `telnet momme.world 23`; if port 23 stays tailnet-only,
|
||||||
2. **www redirect** — not implemented. If you want `www.momme.world` to work,
|
change it to the LB IP (or drop the hint).
|
||||||
add a second DNS name + HTTPProxy route (infra PR can carry it).
|
* GitHub Pages still serves the old site at supermomme.github.io/momme.world —
|
||||||
3. **Analytics / visits** — none included. Self-hosted (Plausible) or none is
|
archive or delete that repo once this one is live.
|
||||||
an easy later call; nothing in this repo blocks it.
|
|
||||||
4. **Blog later** — you mentioned a Markdown-driven blog could be nice
|
|
||||||
someday. The current no-build design can take it as far as "one static
|
|
||||||
page per post written in HTML"; as soon as you want real Markdown +
|
|
||||||
templates, the natural step is a generator (Lume like adb.sh, or
|
|
||||||
Hugo) plus a built image. Decision deferred, structure unaffected.
|
|
||||||
5. **Content facts** — taken from the old site's commands (work at
|
|
||||||
codeanker, projects list, socials). Say the word for anything outdated;
|
|
||||||
they all live in `COMMANDS` in `site.js`.
|
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// momme.world site generator.
|
||||||
|
//
|
||||||
|
// commands/*.md is the single source of content. Each file describes one
|
||||||
|
// command: front matter sets metadata, the body is what it prints.
|
||||||
|
//
|
||||||
|
// filename -> command name (underscores become spaces)
|
||||||
|
// name: -> overrides the filename-derived name
|
||||||
|
// help: -> one-liner for the help listing
|
||||||
|
// params: -> argument placeholder shown in help (e.g. "<text>")
|
||||||
|
// prefix: true -> command accepts anything after its name
|
||||||
|
// suggestion: true -> chip button under the terminal + tailnet MOTD hint
|
||||||
|
// hidden: true -> works, but stays out of help
|
||||||
|
// text: true -> included in the plain-text page (curl)
|
||||||
|
// ${args} ${date} ${time} ${ls} ${help} -> resolved when the command runs
|
||||||
|
//
|
||||||
|
// Output goes to k8s/site/ and is COMMITTED: ArgoCD serves those files
|
||||||
|
// straight from the repo, so `npm run build` is part of editing the site.
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
|
const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
|
||||||
|
const P = (p) => join(root, p);
|
||||||
|
|
||||||
|
// ---------- read commands ----------
|
||||||
|
const read = (file) => {
|
||||||
|
const raw = readFileSync(P("commands/" + file), "utf8").replace(/\r\n/g, "\n");
|
||||||
|
const m = raw.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
|
||||||
|
if (!m) throw new Error(`${file}: missing or malformed front matter`);
|
||||||
|
const meta = {};
|
||||||
|
for (const line of m[1].split("\n")) {
|
||||||
|
const [key, ...rest] = line.split(":");
|
||||||
|
if (key) meta[key.trim()] = rest.join(":").trim().replace(/^["']|["']$/g, "");
|
||||||
|
}
|
||||||
|
const name = meta.name || file.replace(/\.md$/, "").replace(/_/g, " ");
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
help: meta.help ?? "",
|
||||||
|
params: meta.params ?? "",
|
||||||
|
prefix: meta.prefix === "true",
|
||||||
|
suggestion: meta.suggestion === "true",
|
||||||
|
hidden: meta.hidden === "true",
|
||||||
|
text: meta.text === "true",
|
||||||
|
body: m[2].trimEnd(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const files = readdirSync(P("commands")).filter((f) => f.endsWith(".md")).sort();
|
||||||
|
const commands = files.map(read);
|
||||||
|
|
||||||
|
for (const c of commands) {
|
||||||
|
for (const other of commands) {
|
||||||
|
if (other !== c && other.name === c.name) throw new Error(`duplicate command name: ${c.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (new Set(commands.map((c) => c.name.split(" ")[0])).size !== new Set(commands.filter((c) => !c.name.includes(" ")).map((c) => c.name)).size) {
|
||||||
|
// multi-word commands ("project x") must not collide with first words
|
||||||
|
const firsts = new Set(commands.filter((c) => !c.name.includes(" ")).map((c) => c.name));
|
||||||
|
for (const c of commands.filter((c) => c.name.includes(" "))) {
|
||||||
|
if (firsts.has(c.name.split(" ")[0])) throw new Error(`name collision: ${c.name} vs ${c.name.split(" ")[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- index.html ----------
|
||||||
|
const chips = commands
|
||||||
|
.filter((c) => c.suggestion && !c.hidden)
|
||||||
|
.map((c) => ` <button data-run="${c.name}" class="rounded border border-line bg-panel px-3 py-1 text-xs text-dim hover:text-accent hover:border-accent transition-colors">${c.name}</button>`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const banner = readFileSync(P("templates/banner.txt"), "utf8").replace(/\s+$/, "");
|
||||||
|
const template = readFileSync(P("src/index.html"), "utf8");
|
||||||
|
for (const [ph, val] of [["<!--@chips-->", chips], ["<!--@banner-->", banner]]) {
|
||||||
|
if (!template.includes(ph)) throw new Error(`src/index.html: ${ph} placeholder missing`);
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
P("k8s/site/index.html"),
|
||||||
|
template.replace("<!--@chips-->", chips).replace("<!--@banner-->", banner)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- commands.json (web runtime + telnet both read this) ----------
|
||||||
|
const json = JSON.stringify({ banner, commands }, null, 1) + "\n";
|
||||||
|
writeFileSync(P("k8s/site/commands.json"), json);
|
||||||
|
|
||||||
|
// ---------- hello.txt (what curl sees) ----------
|
||||||
|
const sections = commands
|
||||||
|
.filter((c) => c.text)
|
||||||
|
.map((c) => {
|
||||||
|
const body = c.body.replace(/\$\{help\}|\$\{ls\}/g, "");
|
||||||
|
return `${c.name}\n${"=".repeat(c.name.length)}\n${body}`.trimEnd();
|
||||||
|
});
|
||||||
|
writeFileSync(
|
||||||
|
P("k8s/site/hello.txt"),
|
||||||
|
banner + "\n\n" + sections.join("\n\n") + "\n\nThe interactive version of this page is at https://momme.world/\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- copy runtimes ----------
|
||||||
|
writeFileSync(P("k8s/site/site.js"), readFileSync(P("src/site.js"), "utf8"));
|
||||||
|
writeFileSync(P("k8s/site/telnet.py"), readFileSync(P("src/telnet.py"), "utf8"));
|
||||||
|
|
||||||
|
// ---------- tailwind ----------
|
||||||
|
try {
|
||||||
|
execSync("npx --yes @tailwindcss/cli@4 -i src/input.css -o k8s/site/style.css --minify", { cwd: root, stdio: "inherit" });
|
||||||
|
} catch {
|
||||||
|
console.error("tailwind build failed — is npm install done? (style.css stays as it was)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = (n) => String(n).padStart(2);
|
||||||
|
console.log(`built ${words(files.length)} commands -> k8s/site/ (${chips ? commands.filter((c) => c.suggestion).length + " chips" : "no chips"})`);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
help: cat README.md
|
||||||
|
prefix: true
|
||||||
|
---
|
||||||
|
|
||||||
|
cat README.md the welcome message, piped
|
||||||
|
.ssh/id_rsa nice try.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
help: wipe the screen
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
help: brew coffee
|
||||||
|
---
|
||||||
|
|
||||||
|
() ~ I'm out of coffee.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
help: reach me directly
|
||||||
|
suggestion: true
|
||||||
|
text: true
|
||||||
|
---
|
||||||
|
|
||||||
|
mail -> mailto:momme@juergensen.me
|
||||||
|
matrix -> https://matrix.to/#/@momme:chaos.jetzt
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
help: curl momme.world — you already did that
|
||||||
|
prefix: true
|
||||||
|
---
|
||||||
|
|
||||||
|
you are already reading the output :)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
help: today's date
|
||||||
|
---
|
||||||
|
|
||||||
|
${date}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
help: echo <text> — repeat yourself
|
||||||
|
prefix: true
|
||||||
|
params: <text>
|
||||||
|
---
|
||||||
|
|
||||||
|
${args}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
help: an OS with good editing
|
||||||
|
prefix: true
|
||||||
|
---
|
||||||
|
|
||||||
|
a great OS, shame about the editor.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
help: close the session
|
||||||
|
---
|
||||||
|
|
||||||
|
close the tab, or just stop reading :)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
help: the welcome message
|
||||||
|
suggestion: true
|
||||||
|
text: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Hey :)
|
||||||
|
I'm Momme. I build and break infrastructure, and I keep the pieces in
|
||||||
|
Kubernetes so they can be put back together again.
|
||||||
|
Type help to see what this box knows.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
help: this list
|
||||||
|
params: <command>
|
||||||
|
---
|
||||||
|
|
||||||
|
${help}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
help: what's in here
|
||||||
|
---
|
||||||
|
|
||||||
|
${ls}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
help: project <name> — what I'm building
|
||||||
|
suggestion: true
|
||||||
|
text: true
|
||||||
|
---
|
||||||
|
|
||||||
|
nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on
|
||||||
|
brahmsee.digital event management platform for DLRG Schleswig-Holstein
|
||||||
|
silvatica-web website of the Silvatica festival, Schwäbisch Hall
|
||||||
|
momme.world this site — no build step, GitOps all the way down
|
||||||
|
more of them at: https://github.com/supermomme
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: project brahmsee.digital
|
||||||
|
---
|
||||||
|
|
||||||
|
Event management platform for DLRG Schleswig-Holstein.
|
||||||
|
Registrations and scheduling for a state water-rescue organisation, where the
|
||||||
|
traffic arrives in waves of a few hundred people at once. -> https://brahmsee.digital
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: project fpv
|
||||||
|
help: the flying camera budget
|
||||||
|
---
|
||||||
|
|
||||||
|
FPV drones — built, flown, crashed, rebuilt.
|
||||||
|
The honest way to learn soldering, PID tuning and how fast a frame becomes parts.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: project momme.world
|
||||||
|
---
|
||||||
|
|
||||||
|
This site. Markdown in, website out: build/build.mjs generates the commands,
|
||||||
|
the tailnet shell and this page. One nginx pod, GitOps all the way down.
|
||||||
|
-> https://git.momme.cloud/momme/momme-world
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: project nix-ha-kubernetes-cluster
|
||||||
|
---
|
||||||
|
|
||||||
|
An HA Kubernetes cluster on NixOS, built to learn rather than to impress.
|
||||||
|
Control plane in HA, Ceph for storage, GitOps for everything above it.
|
||||||
|
This site runs on it. -> https://github.com/supermomme/nix-ha-kubernetes-cluster
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
name: project silvatica-web
|
||||||
|
---
|
||||||
|
|
||||||
|
Website of the Silvatica festival in Schwäbisch Hall.
|
||||||
|
-> https://github.com/supermomme/silvatica-web
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
help: working directory
|
||||||
|
---
|
||||||
|
|
||||||
|
/world
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
help: where I hang out online
|
||||||
|
suggestion: true
|
||||||
|
text: true
|
||||||
|
---
|
||||||
|
|
||||||
|
github -> https://github.com/supermomme
|
||||||
|
mastodon -> https://cybre.town/@momme
|
||||||
|
instagram -> https://instagram.com/supermommej
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
help: the tools I reach for
|
||||||
|
suggestion: true
|
||||||
|
text: true
|
||||||
|
---
|
||||||
|
|
||||||
|
kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest
|
||||||
|
nix reproducible configs for servers and laptops
|
||||||
|
observability Prometheus, Loki, Grafana — dashboards I actually read
|
||||||
|
fpv drones built, flown, crashed, rebuilt
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
help: elevate
|
||||||
|
prefix: true
|
||||||
|
---
|
||||||
|
|
||||||
|
momme is not in the sudoers file. This incident has been reported.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
help: current time
|
||||||
|
---
|
||||||
|
|
||||||
|
${time}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
help: system information
|
||||||
|
prefix: true
|
||||||
|
---
|
||||||
|
|
||||||
|
momme.world 1.0.0 static x86_64 GNU/freedom
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
help: an editor
|
||||||
|
prefix: true
|
||||||
|
---
|
||||||
|
|
||||||
|
emacs, obviously.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
help: who is typing
|
||||||
|
suggestion: true
|
||||||
|
text: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Momme Jürgensen — infrastructure and developer platforms.
|
||||||
|
I like work that ends up as a short command instead of a long document.
|
||||||
|
Currently at codeanker.
|
||||||
@@ -63,6 +63,41 @@ spec:
|
|||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
capabilities:
|
capabilities:
|
||||||
drop: ["ALL"]
|
drop: ["ALL"]
|
||||||
|
# the telnet shell — same commands.json as the website, stdlib python
|
||||||
|
- name: telnet
|
||||||
|
image: docker.io/library/python:3.14-alpine@sha256:7f2ec713f0436c59df2362d973b6a05af9e87ba78f0e94f5245c302984a7a1fd
|
||||||
|
command: ["python", "-u", "/app/telnet.py"]
|
||||||
|
env:
|
||||||
|
- name: TELNET_PORT
|
||||||
|
value: "2323"
|
||||||
|
ports:
|
||||||
|
- name: telnet
|
||||||
|
containerPort: 2323
|
||||||
|
volumeMounts:
|
||||||
|
- name: telnet
|
||||||
|
mountPath: /app/telnet.py
|
||||||
|
subPath: telnet.py
|
||||||
|
readOnly: true
|
||||||
|
- name: site
|
||||||
|
mountPath: /app/commands.json
|
||||||
|
subPath: commands.json
|
||||||
|
readOnly: true
|
||||||
|
startupProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: telnet
|
||||||
|
failureThreshold: 30
|
||||||
|
periodSeconds: 2
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 48Mi
|
||||||
|
limits:
|
||||||
|
memory: 96Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
volumes:
|
volumes:
|
||||||
- name: site
|
- name: site
|
||||||
configMap:
|
configMap:
|
||||||
@@ -70,5 +105,8 @@ spec:
|
|||||||
- name: config
|
- name: config
|
||||||
configMap:
|
configMap:
|
||||||
name: config
|
name: config
|
||||||
|
- name: telnet
|
||||||
|
configMap:
|
||||||
|
name: telnet
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ configMapGenerator:
|
|||||||
- site/index.html
|
- site/index.html
|
||||||
- site/style.css
|
- site/style.css
|
||||||
- site/site.js
|
- site/site.js
|
||||||
|
- site/commands.json
|
||||||
- site/hello.txt
|
- site/hello.txt
|
||||||
- site/favicon.svg
|
- site/favicon.svg
|
||||||
- site/robots.txt
|
- site/robots.txt
|
||||||
- name: config
|
- name: config
|
||||||
files:
|
files:
|
||||||
- site/nginx.conf
|
- site/nginx.conf
|
||||||
|
- name: telnet
|
||||||
|
files:
|
||||||
|
- site/telnet.py
|
||||||
|
|||||||
@@ -11,3 +11,22 @@ spec:
|
|||||||
- name: http
|
- name: http
|
||||||
port: 80
|
port: 80
|
||||||
targetPort: http
|
targetPort: http
|
||||||
|
---
|
||||||
|
# The telnet shell, for the tailnet. Cilium assigns the IP from the LB pool
|
||||||
|
# entry in sys-cilium/lb.tf (same pattern as gitea-ssh). Reach it with
|
||||||
|
# `telnet <ip> 23` from inside the tailnet.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: momme-world-telnet
|
||||||
|
labels:
|
||||||
|
app: momme-world
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
externalTrafficPolicy: Local
|
||||||
|
selector:
|
||||||
|
app: momme-world
|
||||||
|
ports:
|
||||||
|
- name: telnet
|
||||||
|
port: 23
|
||||||
|
targetPort: telnet
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
{
|
||||||
|
"banner": " _ _ \n | | | |\n _ __ ___ ___ _ __ ___ _ __ ___ ___ __ _____ _ __| | __| |\n | '_ ` _ \\ / _ \\| '_ ` _ \\| '_ ` _ \\ / _ \\ \\ \\ /\\ / / _ \\| '__| |/ _` |\n | | | | | | (_) | | | | | | | | | | | __/ _ \\ V V / (_) | | | | (_| |\n |_| |_| |_|\\___/|_| |_| |_|_| |_| |_|\\___| (_) \\_/\\_/ \\___/|_| |_|\\__,_|\n\n0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100",
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "cat",
|
||||||
|
"help": "cat README.md",
|
||||||
|
"params": "",
|
||||||
|
"prefix": true,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "cat README.md the welcome message, piped\n.ssh/id_rsa nice try."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "clear",
|
||||||
|
"help": "wipe the screen",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "coffee",
|
||||||
|
"help": "brew coffee",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "() ~ I'm out of coffee."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "contact",
|
||||||
|
"help": "reach me directly",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": true,
|
||||||
|
"hidden": false,
|
||||||
|
"text": true,
|
||||||
|
"body": "mail -> mailto:momme@juergensen.me\nmatrix -> https://matrix.to/#/@momme:chaos.jetzt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "curl",
|
||||||
|
"help": "curl momme.world — you already did that",
|
||||||
|
"params": "",
|
||||||
|
"prefix": true,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "you are already reading the output :)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "date",
|
||||||
|
"help": "today's date",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "${date}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "echo",
|
||||||
|
"help": "echo <text> — repeat yourself",
|
||||||
|
"params": "<text>",
|
||||||
|
"prefix": true,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "${args}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "emacs",
|
||||||
|
"help": "an OS with good editing",
|
||||||
|
"params": "",
|
||||||
|
"prefix": true,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "a great OS, shame about the editor."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "exit",
|
||||||
|
"help": "close the session",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "close the tab, or just stop reading :)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hello",
|
||||||
|
"help": "the welcome message",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": true,
|
||||||
|
"hidden": false,
|
||||||
|
"text": true,
|
||||||
|
"body": "Hey :)\nI'm Momme. I build and break infrastructure, and I keep the pieces in\nKubernetes so they can be put back together again.\nType help to see what this box knows."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "help",
|
||||||
|
"help": "this list",
|
||||||
|
"params": "<command>",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "${help}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ls",
|
||||||
|
"help": "what's in here",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "${ls}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"help": "project <name> — what I'm building",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": true,
|
||||||
|
"hidden": false,
|
||||||
|
"text": true,
|
||||||
|
"body": "nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on\nbrahmsee.digital event management platform for DLRG Schleswig-Holstein\nsilvatica-web website of the Silvatica festival, Schwäbisch Hall\nmomme.world this site — no build step, GitOps all the way down\n more of them at: https://github.com/supermomme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "project brahmsee.digital",
|
||||||
|
"help": "",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "Event management platform for DLRG Schleswig-Holstein.\nRegistrations and scheduling for a state water-rescue organisation, where the\ntraffic arrives in waves of a few hundred people at once. -> https://brahmsee.digital"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "project fpv",
|
||||||
|
"help": "the flying camera budget",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "FPV drones — built, flown, crashed, rebuilt.\nThe honest way to learn soldering, PID tuning and how fast a frame becomes parts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "project momme.world",
|
||||||
|
"help": "",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "This site. Markdown in, website out: build/build.mjs generates the commands,\nthe tailnet shell and this page. One nginx pod, GitOps all the way down.\n-> https://git.momme.cloud/momme/momme-world"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "project nix-ha-kubernetes-cluster",
|
||||||
|
"help": "",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "An HA Kubernetes cluster on NixOS, built to learn rather than to impress.\nControl plane in HA, Ceph for storage, GitOps for everything above it.\nThis site runs on it. -> https://github.com/supermomme/nix-ha-kubernetes-cluster"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "project silvatica-web",
|
||||||
|
"help": "",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "Website of the Silvatica festival in Schwäbisch Hall.\n-> https://github.com/supermomme/silvatica-web"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pwd",
|
||||||
|
"help": "working directory",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "/world"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "social",
|
||||||
|
"help": "where I hang out online",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": true,
|
||||||
|
"hidden": false,
|
||||||
|
"text": true,
|
||||||
|
"body": "github -> https://github.com/supermomme\nmastodon -> https://cybre.town/@momme\ninstagram -> https://instagram.com/supermommej"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stack",
|
||||||
|
"help": "the tools I reach for",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": true,
|
||||||
|
"hidden": false,
|
||||||
|
"text": true,
|
||||||
|
"body": "kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest\nnix reproducible configs for servers and laptops\nobservability Prometheus, Loki, Grafana — dashboards I actually read\nfpv drones built, flown, crashed, rebuilt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sudo",
|
||||||
|
"help": "elevate",
|
||||||
|
"params": "",
|
||||||
|
"prefix": true,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "momme is not in the sudoers file. This incident has been reported."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "time",
|
||||||
|
"help": "current time",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "${time}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uname",
|
||||||
|
"help": "system information",
|
||||||
|
"params": "",
|
||||||
|
"prefix": true,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "momme.world 1.0.0 static x86_64 GNU/freedom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vim",
|
||||||
|
"help": "an editor",
|
||||||
|
"params": "",
|
||||||
|
"prefix": true,
|
||||||
|
"suggestion": false,
|
||||||
|
"hidden": false,
|
||||||
|
"text": false,
|
||||||
|
"body": "emacs, obviously."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "whoami",
|
||||||
|
"help": "who is typing",
|
||||||
|
"params": "",
|
||||||
|
"prefix": false,
|
||||||
|
"suggestion": true,
|
||||||
|
"hidden": false,
|
||||||
|
"text": true,
|
||||||
|
"body": "Momme Jürgensen — infrastructure and developer platforms.\nI like work that ends up as a short command instead of a long document.\nCurrently at codeanker."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+28
-20
@@ -5,37 +5,45 @@
|
|||||||
| | | | | | (_) | | | | | | | | | | | __/ _ \ V V / (_) | | | | (_| |
|
| | | | | | (_) | | | | | | | | | | | __/ _ \ V V / (_) | | | | (_| |
|
||||||
|_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_) \_/\_/ \___/|_| |_|\__,_|
|
|_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_) \_/\_/ \___/|_| |_|\__,_|
|
||||||
|
|
||||||
0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100
|
0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100
|
||||||
|
|
||||||
|
contact
|
||||||
|
=======
|
||||||
|
mail -> mailto:momme@juergensen.me
|
||||||
|
matrix -> https://matrix.to/#/@momme:chaos.jetzt
|
||||||
|
|
||||||
|
hello
|
||||||
|
=====
|
||||||
Hey :)
|
Hey :)
|
||||||
I'm Momme. I build and break infrastructure, and I keep the pieces in
|
I'm Momme. I build and break infrastructure, and I keep the pieces in
|
||||||
Kubernetes so they can be put back together again.
|
Kubernetes so they can be put back together again.
|
||||||
|
Type help to see what this box knows.
|
||||||
|
|
||||||
whoami
|
project
|
||||||
------
|
=======
|
||||||
Momme Jürgensen — infrastructure and developer platforms.
|
nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on
|
||||||
I like work that ends up as a short command instead of a long document.
|
brahmsee.digital event management platform for DLRG Schleswig-Holstein
|
||||||
Currently at codeanker.
|
silvatica-web website of the Silvatica festival, Schwäbisch Hall
|
||||||
|
momme.world this site — no build step, GitOps all the way down
|
||||||
|
more of them at: https://github.com/supermomme
|
||||||
|
|
||||||
|
social
|
||||||
|
======
|
||||||
|
github -> https://github.com/supermomme
|
||||||
|
mastodon -> https://cybre.town/@momme
|
||||||
|
instagram -> https://instagram.com/supermommej
|
||||||
|
|
||||||
stack
|
stack
|
||||||
-----
|
=====
|
||||||
kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest
|
kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest
|
||||||
nix reproducible configs for servers and laptops
|
nix reproducible configs for servers and laptops
|
||||||
observability Prometheus, Loki, Grafana — dashboards I actually read
|
observability Prometheus, Loki, Grafana — dashboards I actually read
|
||||||
fpv drones built, flown, crashed, rebuilt
|
fpv drones built, flown, crashed, rebuilt
|
||||||
|
|
||||||
projects
|
whoami
|
||||||
--------
|
======
|
||||||
nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on
|
Momme Jürgensen — infrastructure and developer platforms.
|
||||||
brahmsee.digital event management platform for DLRG Schleswig-Holstein
|
I like work that ends up as a short command instead of a long document.
|
||||||
silvatica-web website of the Silvatica festival, Schwäbisch Hall
|
Currently at codeanker.
|
||||||
momme.world this site — no build step, GitOps all the way down
|
|
||||||
|
|
||||||
contact
|
|
||||||
-------
|
|
||||||
mail momme@juergensen.me
|
|
||||||
matrix @momme:juergensen.me
|
|
||||||
github https://github.com/supermomme
|
|
||||||
mastodon https://cybre.town/@momme
|
|
||||||
|
|
||||||
The interactive version of this page is at https://momme.world/
|
The interactive version of this page is at https://momme.world/
|
||||||
|
|||||||
+28
-53
@@ -13,81 +13,56 @@
|
|||||||
<link rel="canonical" href="https://momme.world/">
|
<link rel="canonical" href="https://momme.world/">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<script>document.documentElement.classList.add("js")</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="min-h-dvh bg-bg text-fg font-mono antialiased flex flex-col items-center justify-center p-4 sm:p-8 selection:bg-accent/30">
|
||||||
<main>
|
|
||||||
|
|
||||||
<div class="term" id="term">
|
<main class="w-full max-w-3xl">
|
||||||
<div class="titlebar">
|
|
||||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
<div class="rounded-lg border border-line bg-panel shadow-[0_0_40px_rgba(0,0,0,.5)] overflow-hidden">
|
||||||
<span class="title">momme@world: ~</span>
|
<div class="flex items-center gap-2 border-b border-line bg-bar px-3 py-2">
|
||||||
|
<span class="size-3 rounded-full bg-[#ff5f56]"></span>
|
||||||
|
<span class="size-3 rounded-full bg-[#ffbd2e]"></span>
|
||||||
|
<span class="size-3 rounded-full bg-[#27c93f]"></span>
|
||||||
|
<span class="ml-2 text-xs text-dim select-none">momme@world: ~</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="screen" id="screen" tabindex="-1">
|
<div id="screen" tabindex="-1" class="h-[62dvh] overflow-y-auto px-4 py-4 text-[13.5px] sm:text-[15px] leading-[1.35] cursor-text">
|
||||||
<pre class="hello">
|
<pre class="text-dim overflow-x-auto not-prose"> _ _
|
||||||
_ _
|
|
||||||
| | | |
|
| | | |
|
||||||
_ __ ___ ___ _ __ ___ _ __ ___ ___ __ _____ _ __| | __| |
|
_ __ ___ ___ _ __ ___ _ __ ___ ___ __ _____ _ __| | __| |
|
||||||
| '_ ` _ \ / _ \| '_ ` _ \| '_ ` _ \ / _ \ \ \ /\ / / _ \| '__| |/ _` |
|
| '_ ` _ \ / _ \| '_ ` _ \| '_ ` _ \ / _ \ \ \ /\ / / _ \| '__| |/ _` |
|
||||||
| | | | | | (_) | | | | | | | | | | | __/ _ \ V V / (_) | | | | (_| |
|
| | | | | | (_) | | | | | | | | | | | __/ _ \ V V / (_) | | | | (_| |
|
||||||
|_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_) \_/\_/ \___/|_| |_|\__,_|
|
|_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_) \_/\_/ \___/|_| |_|\__,_|
|
||||||
|
|
||||||
0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100
|
0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100</pre>
|
||||||
</pre>
|
|
||||||
|
|
||||||
<div id="out"></div>
|
<div id="out" class="whitespace-pre-wrap break-words"></div>
|
||||||
|
|
||||||
<form id="prompt" autocomplete="off">
|
<form id="prompt" autocomplete="off" class="flex items-baseline gap-2 mt-1">
|
||||||
<span class="ps1" aria-hidden="true">momme@world:~$</span>
|
<span aria-hidden="true" class="text-accent shrink-0">momme@world:~$</span>
|
||||||
<input id="cmd" aria-label="Run a command" placeholder="type a command — try `help`"
|
<input id="cmd" aria-label="Run a command" placeholder="type a command — try `help`"
|
||||||
|
class="flex-1 min-w-0 bg-transparent outline-none border-0 p-0 text-fg placeholder:text-dim/60 caret-transparent"
|
||||||
autocapitalize="off" autocorrect="off" spellcheck="false">
|
autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||||
<span class="caret" aria-hidden="true"></span>
|
<span aria-hidden="true" class="cursor -ml-[0.6em]"></span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="chips" aria-label="Commands">
|
<nav class="mt-4 flex flex-wrap justify-center gap-2" aria-label="Commands">
|
||||||
<button data-run="whoami">whoami</button>
|
<button data-run="contact" class="rounded border border-line bg-panel px-3 py-1 text-xs text-dim hover:text-accent hover:border-accent transition-colors">contact</button>
|
||||||
<button data-run="projects">projects</button>
|
<button data-run="hello" class="rounded border border-line bg-panel px-3 py-1 text-xs text-dim hover:text-accent hover:border-accent transition-colors">hello</button>
|
||||||
<button data-run="stack">stack</button>
|
<button data-run="project" class="rounded border border-line bg-panel px-3 py-1 text-xs text-dim hover:text-accent hover:border-accent transition-colors">project</button>
|
||||||
<button data-run="social">social</button>
|
<button data-run="social" class="rounded border border-line bg-panel px-3 py-1 text-xs text-dim hover:text-accent hover:border-accent transition-colors">social</button>
|
||||||
<button data-run="contact">contact</button>
|
<button data-run="stack" class="rounded border border-line bg-panel px-3 py-1 text-xs text-dim hover:text-accent hover:border-accent transition-colors">stack</button>
|
||||||
|
<button data-run="whoami" class="rounded border border-line bg-panel px-3 py-1 text-xs text-dim hover:text-accent hover:border-accent transition-colors">whoami</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<p class="curl">
|
<p class="mt-4 text-center text-xs text-dim">
|
||||||
Terminal people: <code>curl momme.world</code>
|
Terminal people: <code class="text-fg">curl momme.world</code>
|
||||||
· source: <a href="https://git.momme.cloud/momme-world/momme-world">git.momme.cloud/momme-world/momme-world</a>
|
· tailnet: <code class="text-fg">telnet momme.world 23</code>
|
||||||
|
· source: <a class="text-accent hover:underline" href="https://git.momme.cloud/momme/momme-world">git.momme.cloud/momme/momme-world</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Readable and copy-pasteable with JavaScript turned off. The terminal
|
|
||||||
above is the same information, delivered as a conversation. -->
|
|
||||||
<noscript>
|
|
||||||
<section class="facts">
|
|
||||||
<h2>whoami</h2>
|
|
||||||
<p>I'm <strong>Momme Jürgensen</strong>. I work on infrastructure and developer
|
|
||||||
platforms — the kind of work where the best interface is a short command.
|
|
||||||
Currently at <a href="https://codeanker.de">codeanker</a>.</p>
|
|
||||||
|
|
||||||
<h2>stack</h2>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Kubernetes</strong> — self-hosted cluster, GitOps, the whole garden.</li>
|
|
||||||
<li><strong>NixOS</strong> — reproducible configs for servers and laptops.</li>
|
|
||||||
<li><strong>Observability</strong> — Prometheus, Loki, dashboards I actually read.</li>
|
|
||||||
<li><strong>FPV drones</strong> — built, flown, crashed, rebuilt.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>contact</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Mail: <a href="mailto:momme@juergensen.me">momme@juergensen.me</a></li>
|
|
||||||
<li>Matrix: <a href="https://matrix.to/#/@momme:chaos.jetzt">@momme:chaos.jetzt</a></li>
|
|
||||||
<li>Mastodon: <a href="https://cybre.town/@momme">@momme@cybre.town</a></li>
|
|
||||||
<li>GitHub: <a href="https://github.com/supermomme">supermomme</a></li>
|
|
||||||
<li>Instagram: <a href="https://instagram.com/supermommej">@supermommej</a></li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
<script src="/site.js"></script>
|
<script src="/site.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ server {
|
|||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# the command data the terminal fetches at boot: same freshness rule
|
||||||
|
location = /commands.json {
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
location ~* \.(css|js|svg)$ {
|
location ~* \.(css|js|svg)$ {
|
||||||
add_header Cache-Control "public, max-age=604800";
|
add_header Cache-Control "public, max-age=604800";
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-156
@@ -1,127 +1,52 @@
|
|||||||
// Content lives in COMMANDS only: every entry renders in the terminal, and the
|
// momme.world browser runtime. Content comes from commands.json — generated
|
||||||
// names double as the `help` listing. A `label -> url` line becomes a real link.
|
// by build/build.mjs from commands/*.md; edit those files, not this one.
|
||||||
// Adding a section to this site means adding one object here.
|
// This file only implements the loop, the typewriter and command dispatch.
|
||||||
const COMMANDS = [
|
|
||||||
{
|
let COMMANDS = [];
|
||||||
name: "hello",
|
let INDEX = new Map();
|
||||||
help: "the welcome message",
|
|
||||||
body: `Hey :)
|
const load = async () => {
|
||||||
I'm Momme. I build and break infrastructure, and I keep the pieces in
|
const res = await fetch("/commands.json", { cache: "no-store" });
|
||||||
Kubernetes so they can be put back together again.
|
const data = await res.json();
|
||||||
Type help to see what this box knows.`,
|
COMMANDS = data.commands;
|
||||||
},
|
INDEX = new Map(COMMANDS.map((c) => [c.name, c]));
|
||||||
{
|
};
|
||||||
name: "whoami",
|
|
||||||
help: "who is typing",
|
// ---------- content helpers ----------
|
||||||
body: `Momme Jürgensen — infrastructure and developer platforms.
|
const visible = () => COMMANDS.filter((c) => !c.hidden);
|
||||||
I like work that ends up as a short command instead of a long document.
|
const unique = () => COMMANDS.filter((c, i) => COMMANDS.findIndex((x) => x.name === c.name) === i);
|
||||||
Currently at codeanker.`,
|
const commandNames = () => unique().map((c) => c.name);
|
||||||
},
|
const usages = () =>
|
||||||
{
|
visible().filter((c) => c.help).map((c) => ` ${c.name.padEnd(22)} ${c.help}`);
|
||||||
name: "projects",
|
const fail = (message, ...more) => ({ error: message, more: more.filter(Boolean) });
|
||||||
help: "projects <name> — what I'm building",
|
const split = (value) => String(value).split("\n");
|
||||||
body: `nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on
|
|
||||||
brahmsee.digital event management platform for DLRG Schleswig-Holstein
|
|
||||||
silvatica-web website of the Silvatica festival, Schwäbisch Hall
|
|
||||||
momme.world this site — no build step, GitOps all the way down
|
|
||||||
more of them at: https://github.com/supermomme`,
|
|
||||||
detail: {
|
|
||||||
"nix-ha-kubernetes-cluster": `An HA Kubernetes cluster on NixOS, built to learn rather than to impress.
|
|
||||||
Control plane in HA, Ceph for storage, GitOps for everything above it.
|
|
||||||
This site runs on it. -> https://github.com/supermomme/nix-ha-kubernetes-cluster`,
|
|
||||||
"brahmsee.digital": `Event management platform for DLRG Schleswig-Holstein.
|
|
||||||
Registrations and scheduling for a state water-rescue organisation, where the
|
|
||||||
traffic arrives in waves of a few hundred people at once. -> https://brahmsee.digital`,
|
|
||||||
"silvatica-web": `Website of the Silvatica festival in Schwäbisch Hall.
|
|
||||||
-> https://github.com/supermomme/silvatica-web`,
|
|
||||||
"momme.world": `This site. Plain files, a stock nginx image, three manifests, no build
|
|
||||||
step. ArgoCD points at the repo, so a merged commit is a deployed site.`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stack",
|
|
||||||
help: "the tools I reach for",
|
|
||||||
body: `kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest
|
|
||||||
nix reproducible configs for servers and laptops
|
|
||||||
observability Prometheus, Loki, Grafana — dashboards I actually read
|
|
||||||
fpv drones built, flown, crashed, rebuilt`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "social",
|
|
||||||
help: "where I hang out online",
|
|
||||||
body: `github -> https://github.com/supermomme
|
|
||||||
mastodon -> https://cybre.town/@momme
|
|
||||||
instagram -> https://instagram.com/supermommej`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "contact",
|
|
||||||
help: "reach me directly",
|
|
||||||
body: `mail -> mailto:momme@juergensen.me
|
|
||||||
matrix -> https://matrix.to/#/@momme:chaos.jetzt`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ls",
|
|
||||||
help: "what's in here",
|
|
||||||
body: () => names("projects").join(" "),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cat",
|
|
||||||
prefix: true,
|
|
||||||
help: "cat README.md",
|
|
||||||
run: (arg) => {
|
|
||||||
const f = arg.toLowerCase();
|
|
||||||
if (f === "readme.md" || f === "readme") return body("hello");
|
|
||||||
if (f === ".ssh/id_rsa") return fail("nice try.");
|
|
||||||
return fail(`cat: ${arg}: No such file or directory`, "try: cat README.md");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "help",
|
|
||||||
help: "this list",
|
|
||||||
body: () => ["usage: <command>", "", ...usages()],
|
|
||||||
run: (arg) => {
|
|
||||||
if (!arg) return body("help");
|
|
||||||
const row = usages().find((line) => line.trim().startsWith(arg + " "));
|
|
||||||
return row ? row : fail(`no command called "${arg}"`, "see: help");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ name: "clear", help: "wipe the screen", body: "" },
|
|
||||||
{ name: "date", help: "today's date", body: () => new Date().toDateString() },
|
|
||||||
{ name: "time", help: "current time", body: () => new Date().toLocaleTimeString("de-DE") },
|
|
||||||
{ name: "echo", prefix: true, help: "echo <text> — repeat yourself", body: (arg) => arg },
|
|
||||||
{ name: "pwd", help: "working directory", body: "/world" },
|
|
||||||
{ name: "uname", prefix: true, help: "system information", body: "momme.world 1.0.0 static x86_64 GNU/freedom" },
|
|
||||||
{ name: "curl", prefix: true, help: "curl momme.world — you already did that", body: "you are already reading the output :)" },
|
|
||||||
{ name: "sudo", prefix: true, help: "elevate", body: "momme is not in the sudoers file. This incident has been reported." },
|
|
||||||
{ name: "vim", prefix: true, help: "an editor", body: "emacs, obviously." },
|
|
||||||
{ name: "emacs", prefix: true, help: "an OS with good editing", body: "a great OS, shame about the editor." },
|
|
||||||
{ name: "coffee", help: "brew coffee", body: "() ~ I'm out of coffee." },
|
|
||||||
{ name: "exit", help: "close the session", body: "close the tab, or just stop reading :)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ALIASES = {
|
const ALIASES = {
|
||||||
whois: "whoami", about: "whoami", openme: "whoami",
|
whois: "whoami", about: "whoami", openme: "whoami",
|
||||||
tech: "stack", tools: "stack",
|
tech: "stack", tools: "stack",
|
||||||
links: "social", socials: "social",
|
links: "social", socials: "social",
|
||||||
mail: "contact", email: "contact",
|
mail: "contact", email: "contact",
|
||||||
repos: "projects", rm: "sudo",
|
repos: "project", projects: "project", rm: "sudo",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- content helpers ----------
|
// ${...} placeholders a command body may contain; resolved at run time.
|
||||||
const byName = (name) => COMMANDS.filter((c) => c.name === name && !c.prefix)[0];
|
const substitutions = (arg) => ({
|
||||||
const body = (name) => render(byName(name).body, "");
|
args: arg,
|
||||||
const render = (value, arg) => {
|
date: () => new Date().toDateString(),
|
||||||
const v = typeof value === "function" ? value(arg) : value;
|
time: () => new Date().toLocaleTimeString("de-DE"),
|
||||||
return Array.isArray(v) ? v.join("\n") : String(v);
|
help: () => ["usage: <command>", "", ...usages()].join("\n"),
|
||||||
};
|
ls: () =>
|
||||||
const names = (name) =>
|
commandNames()
|
||||||
split(body(name)).map((line) => line.trim().split(/\s{2,}/)[0]).filter(Boolean);
|
.filter((n) => n.includes(" "))
|
||||||
const commandNames = () => unique().map((c) => c.name);
|
.map((n) => n.split(" ").slice(1).join(" "))
|
||||||
const unique = () =>
|
.join(" "),
|
||||||
COMMANDS.filter((c, i) => COMMANDS.findIndex((x) => x.name === c.name) === i);
|
});
|
||||||
const usages = () => unique().map((c) => ` ${c.name.padEnd(9)} ${c.help}`);
|
|
||||||
const fail = (message, ...more) => ({ error: message, more: more.filter(Boolean) });
|
const resolve = (body, arg) =>
|
||||||
const split = (value) => value.split("\n");
|
String(body).replace(/\$\{(\w+)\}/g, (_, key) => {
|
||||||
|
const sub = substitutions(arg)[key];
|
||||||
|
return sub === undefined ? "" : typeof sub === "function" ? sub() : sub;
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- dom ----------
|
// ---------- dom ----------
|
||||||
const out = document.getElementById("out");
|
const out = document.getElementById("out");
|
||||||
@@ -149,7 +74,7 @@ let fast = false;
|
|||||||
let gen = 0;
|
let gen = 0;
|
||||||
let dumpNow = null;
|
let dumpNow = null;
|
||||||
|
|
||||||
// finish every pending line synchronously, then let the queue drain instantly
|
// finish the line being typed synchronously; the queue then drains instantly
|
||||||
const flatten = (node) =>
|
const flatten = (node) =>
|
||||||
node.segs.forEach((seg) => node.append(seg.href ? anchor(seg) : seg.text));
|
node.segs.forEach((seg) => node.append(seg.href ? anchor(seg) : seg.text));
|
||||||
|
|
||||||
@@ -170,13 +95,12 @@ const kill = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function print(value, arg) {
|
function print(value, arg) {
|
||||||
const resolved = typeof value === "function" ? value(arg) : value;
|
|
||||||
const lines = [];
|
const lines = [];
|
||||||
if (resolved && resolved.error) {
|
if (value && value.error) {
|
||||||
lines.push(build(resolved.error, "line err"));
|
lines.push(build(value.error, "text-[#ff7b72]"));
|
||||||
resolved.more.forEach((line) => lines.push(build(line, "line dim")));
|
value.more.forEach((line) => lines.push(build(line, "text-[#5c6f8a]")));
|
||||||
} else {
|
} else {
|
||||||
split(render(resolved, arg)).forEach((line) => lines.push(build(line, "line out")));
|
split(resolve(value, arg)).forEach((line) => lines.push(build(line, "")));
|
||||||
}
|
}
|
||||||
queue.push(lines);
|
queue.push(lines);
|
||||||
pump();
|
pump();
|
||||||
@@ -186,7 +110,7 @@ function print(value, arg) {
|
|||||||
const LINK = /(https?:\/\/\S+|mailto:\S+)/;
|
const LINK = /(https?:\/\/\S+|mailto:\S+)/;
|
||||||
|
|
||||||
function build(line, cls) {
|
function build(line, cls) {
|
||||||
const node = make("span", cls);
|
const node = make("span", "block min-h-[1.35em] " + (cls || ""));
|
||||||
const segs = [];
|
const segs = [];
|
||||||
if (!LINK.test(line)) {
|
if (!LINK.test(line)) {
|
||||||
segs.push({ text: line || "\u200b" });
|
segs.push({ text: line || "\u200b" });
|
||||||
@@ -201,10 +125,10 @@ function build(line, cls) {
|
|||||||
}
|
}
|
||||||
node.segs = segs;
|
node.segs = segs;
|
||||||
return node;
|
return node;
|
||||||
};
|
}
|
||||||
|
|
||||||
const anchor = (seg) => {
|
const anchor = (seg) => {
|
||||||
const a = make("a", null, seg.text);
|
const a = make("a", "text-accent hover:underline", seg.text);
|
||||||
a.href = seg.href;
|
a.href = seg.href;
|
||||||
if (!seg.href.startsWith("mailto:")) a.rel = "noopener me";
|
if (!seg.href.startsWith("mailto:")) a.rel = "noopener me";
|
||||||
return a;
|
return a;
|
||||||
@@ -301,35 +225,43 @@ function recall(step) {
|
|||||||
input.value = history.items[history.at];
|
input.value = history.items[history.at];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve input words to [command, argument]. Longest multi-word name wins so
|
||||||
|
// "project fpv" beats any one-word command; a command with params swallows
|
||||||
|
// the rest.
|
||||||
|
function lookup(words) {
|
||||||
|
for (let len = words.length; len > 0; len--) {
|
||||||
|
const joined = words.slice(0, len).join(" ");
|
||||||
|
const cmd = INDEX.get(ALIASES[joined] || joined);
|
||||||
|
if (cmd) return [cmd, words.slice(len).join(" ")];
|
||||||
|
}
|
||||||
|
return [null, ""];
|
||||||
|
}
|
||||||
|
|
||||||
function execute(raw) {
|
function execute(raw) {
|
||||||
const [word = "", ...rest] = raw.trim().split(/\s+/);
|
|
||||||
const arg = rest.join(" ");
|
|
||||||
const name = word.toLowerCase();
|
|
||||||
|
|
||||||
flush();
|
flush();
|
||||||
out.append(make("div", "line cmd", raw));
|
out.append(make("div", "block text-fg mt-4", raw));
|
||||||
if (!name) return scrollDown();
|
const words = raw.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
if (name === "clear") return kill(), (out.textContent = "");
|
if (!words.length) return scrollDown();
|
||||||
|
if (words[0] === "clear") return kill(), (out.textContent = "");
|
||||||
|
|
||||||
const wanted = ALIASES[name] || name;
|
const [cmd, arg] = lookup(words);
|
||||||
const found =
|
if (!cmd) {
|
||||||
COMMANDS.find((c) => c.name === wanted && !c.prefix) ||
|
const near = commandNames().filter((n) => n !== words[0] && n.startsWith(words[0])).slice(0, 3);
|
||||||
COMMANDS.find((c) => c.name === wanted && c.prefix);
|
return print(fail(`command not found: ${words[0]}`,
|
||||||
|
near.length ? `did you mean: ${near.join(", ")}` : "see: help"), "");
|
||||||
if (!found) {
|
|
||||||
const near = commandNames().filter((n) => n !== name && n[0] === name[0]).slice(0, 3);
|
|
||||||
return print(fail(`command not found: ${name}`,
|
|
||||||
near.length ? `did you mean: ${near.join(", ")}` : "see: help"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (found.name === "projects" && arg) {
|
// argument on a parent: either a known subcommand or a name error
|
||||||
const key = arg.toLowerCase();
|
if (arg && !cmd.params && !cmd.prefix) {
|
||||||
if (key === "list") return print(body("projects"));
|
const kids = commandNames().filter((n) => n.startsWith(cmd.name + " "));
|
||||||
return print(found.detail[key] ||
|
if (kids.length) {
|
||||||
fail(`no project called "${arg}"`, "names:", ...names("projects")));
|
const kid = INDEX.get(cmd.name + " " + arg);
|
||||||
|
if (kid) return print(resolve(kid.body, ""), "");
|
||||||
|
return print(fail(`no ${cmd.name} called "${arg}"`,
|
||||||
|
"names: " + kids.map((k) => k.slice(cmd.name.length + 1)).join(", ")), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return print(resolve(cmd.body, arg), arg);
|
||||||
return print(found.run ? found.run(arg) : found.body, arg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener("submit", (event) => {
|
form.addEventListener("submit", (event) => {
|
||||||
@@ -346,7 +278,7 @@ input.addEventListener("keydown", (event) => {
|
|||||||
if (event.key === "ArrowUp") { event.preventDefault(); recall(-1); }
|
if (event.key === "ArrowUp") { event.preventDefault(); recall(-1); }
|
||||||
if (event.key === "ArrowDown") { event.preventDefault(); recall(1); }
|
if (event.key === "ArrowDown") { event.preventDefault(); recall(1); }
|
||||||
if (event.key !== "Tab") return;
|
if (event.key !== "Tab") return;
|
||||||
const word = input.value.trim().split(/\s+/)[0];
|
const word = input.value.trim().toLowerCase().split(/\s+/)[0];
|
||||||
const hit = commandNames().filter((n) => word && n.startsWith(word));
|
const hit = commandNames().filter((n) => word && n.startsWith(word));
|
||||||
if (hit.length === 1) { event.preventDefault(); input.value = hit[0] + " "; }
|
if (hit.length === 1) { event.preventDefault(); input.value = hit[0] + " "; }
|
||||||
});
|
});
|
||||||
@@ -356,12 +288,16 @@ screen.addEventListener("click", () => {
|
|||||||
if (!getSelection().toString()) input.focus();
|
if (!getSelection().toString()) input.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll("[data-run]").forEach((button) =>
|
load().then(
|
||||||
button.addEventListener("click", () => {
|
() => {
|
||||||
execute(button.dataset.run);
|
document.querySelectorAll("[data-run]").forEach((button) =>
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
execute(button.dataset.run);
|
||||||
|
input.focus();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
print("hello — type help for the rest of it.", "");
|
||||||
input.focus();
|
input.focus();
|
||||||
})
|
},
|
||||||
|
() => print({ error: "commands.json did not load — did the site generator run?", more: [] }, "")
|
||||||
);
|
);
|
||||||
|
|
||||||
print(`hello — type help for the rest of it.\n`);
|
|
||||||
input.focus();
|
|
||||||
|
|||||||
+2
-136
File diff suppressed because one or more lines are too long
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""momme.world telnet shell.
|
||||||
|
|
||||||
|
Serves the same commands as the website, to real terminals:
|
||||||
|
telnet momme.world 23
|
||||||
|
|
||||||
|
Content lives in commands.json next to this file (generated from commands/*.md
|
||||||
|
by build/build.mjs) — this server is only a REPL. Standard library only. It
|
||||||
|
answers enough telnet negotiation to keep classic clients happy; it is not a
|
||||||
|
login shell, there is nothing here to log into.
|
||||||
|
|
||||||
|
Inside the cluster it listens on TELNET_PORT (2323); the public port 23
|
||||||
|
arrives via Envoy Gateway's TCPRoute on the tailscale funnel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
COMMANDS_FILE = os.environ.get("COMMANDS_FILE", os.path.join(HERE, "commands.json"))
|
||||||
|
PORT = int(os.environ.get("TELNET_PORT", "2323"))
|
||||||
|
IDLE_TIMEOUT = 300 # seconds; five idle minutes is a long enough hello
|
||||||
|
|
||||||
|
# telnet negotiation: answer WILL/WONT so clients stop asking
|
||||||
|
IAC, WONT, ECHO, SGA = 255, 252, 1, 3
|
||||||
|
|
||||||
|
ALIASES = {
|
||||||
|
"whois": "whoami", "about": "whoami", "openme": "whoami",
|
||||||
|
"tech": "stack", "tools": "stack",
|
||||||
|
"links": "social", "socials": "social",
|
||||||
|
"mail": "contact", "email": "contact",
|
||||||
|
"repos": "project", "projects": "project", "rm": "sudo", "quit": "exit",
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(COMMANDS_FILE, encoding="utf-8") as fh:
|
||||||
|
SITE = json.load(fh)
|
||||||
|
COMMANDS = {c["name"]: c for c in SITE["commands"]}
|
||||||
|
BANNER = SITE["banner"]
|
||||||
|
|
||||||
|
|
||||||
|
def usages():
|
||||||
|
return [
|
||||||
|
f" {c['name']:<22} {c['help']}"
|
||||||
|
for c in SITE["commands"]
|
||||||
|
if not c.get("hidden") and c.get("help")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def substitute(body, arg):
|
||||||
|
def rep(match):
|
||||||
|
key = match.group(1)
|
||||||
|
if key == "args":
|
||||||
|
return arg
|
||||||
|
if key == "date":
|
||||||
|
return time.strftime("%a %b %d %Y")
|
||||||
|
if key == "time":
|
||||||
|
return time.strftime("%H:%M:%S")
|
||||||
|
if key == "help":
|
||||||
|
return "\n".join(["usage: <command>", ""] + usages())
|
||||||
|
if key == "ls":
|
||||||
|
return " ".join(n.split(" ", 1)[1] for n in COMMANDS if " " in n)
|
||||||
|
return ""
|
||||||
|
return re.sub(r"\$\{(\w+)\}", rep, body)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(words):
|
||||||
|
for length in range(len(words), 0, -1):
|
||||||
|
joined = " ".join(words[:length])
|
||||||
|
cmd = COMMANDS.get(ALIASES.get(joined, joined))
|
||||||
|
if cmd:
|
||||||
|
return cmd, " ".join(words[length:])
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run(line):
|
||||||
|
"""Return (text, quit_after). Empty text means print nothing."""
|
||||||
|
words = [w for w in line.strip().lower().split() if w]
|
||||||
|
if not words:
|
||||||
|
return "", False
|
||||||
|
cmd, arg = lookup(words)
|
||||||
|
if not cmd:
|
||||||
|
near = [n for n in COMMANDS if n != words[0] and n.startswith(words[0])][:3]
|
||||||
|
hint = "did you mean: " + ", ".join(near) if near else "see: help"
|
||||||
|
return f"command not found: {words[0]}\n{hint}", False
|
||||||
|
if cmd["name"] == "clear":
|
||||||
|
return "\033[2J\033[H", False
|
||||||
|
if cmd["name"] == "exit":
|
||||||
|
return "bye — https://momme.world/", True
|
||||||
|
if arg and not cmd.get("params") and not cmd.get("prefix"):
|
||||||
|
kids = [n[len(cmd["name"]) + 1:] for n in COMMANDS if n.startswith(cmd["name"] + " ")]
|
||||||
|
if kids:
|
||||||
|
kid = COMMANDS.get(cmd["name"] + " " + arg)
|
||||||
|
if kid:
|
||||||
|
return substitute(kid["body"], ""), False
|
||||||
|
names = ", ".join(kids)
|
||||||
|
return f'no {cmd["name"]} called "{arg}"\nnames: {names}', False
|
||||||
|
return substitute(cmd["body"], arg), cmd["name"] == "quit"
|
||||||
|
|
||||||
|
|
||||||
|
class Shell(socketserver.BaseRequestHandler):
|
||||||
|
def send(self, text):
|
||||||
|
try:
|
||||||
|
self.request.sendall(text.replace("\n", "\r\n").encode("utf-8"))
|
||||||
|
except OSError:
|
||||||
|
raise ConnectionError
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
try:
|
||||||
|
self.request.sendall(bytes([IAC, WONT, ECHO, IAC, WONT, SGA]))
|
||||||
|
self.send(BANNER + "\n\nhello — type help for the rest of it.\n")
|
||||||
|
self.loop()
|
||||||
|
except (ConnectionError, ConnectionResetError, BrokenPipeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def loop(self):
|
||||||
|
line = ""
|
||||||
|
self.send("momme@world:~$ ")
|
||||||
|
while True:
|
||||||
|
ready, _, _ = select.select([self.request], [], [], IDLE_TIMEOUT)
|
||||||
|
if not ready:
|
||||||
|
return
|
||||||
|
data = self.request.recv(1024)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
i = 0
|
||||||
|
while i < len(data):
|
||||||
|
byte = data[i]
|
||||||
|
if byte == IAC:
|
||||||
|
i += 3 # skip a negotiation triple wholesale
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
if byte in (13, 10):
|
||||||
|
self.send("\n")
|
||||||
|
text, goodbye = run(line)
|
||||||
|
if text:
|
||||||
|
self.send(text + "\n")
|
||||||
|
if goodbye:
|
||||||
|
self.send("closing connection\n")
|
||||||
|
return
|
||||||
|
line = ""
|
||||||
|
self.send("momme@world:~$ ")
|
||||||
|
elif byte in (127, 8):
|
||||||
|
if line:
|
||||||
|
line = line[:-1]
|
||||||
|
self.send("\b \b")
|
||||||
|
elif 32 <= byte < 127:
|
||||||
|
line += chr(byte)
|
||||||
|
self.send(chr(byte))
|
||||||
|
|
||||||
|
|
||||||
|
class Server(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with Server(("0.0.0.0", PORT), Shell) as srv:
|
||||||
|
print(f"momme.world telnet shell on :{PORT}", file=sys.stderr, flush=True)
|
||||||
|
srv.serve_forever()
|
||||||
Generated
+1170
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "momme-world",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "momme.world — commands/*.md in, website out",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build/build.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"@tailwindcss/cli": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>momme.world — Momme Jürgensen</title>
|
||||||
|
<meta name="description" content="Hi, I'm Momme. I build and break infrastructure: Kubernetes, NixOS, self-hosting, and FPV drones. Say hello on the terminal.">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<meta property="og:type" content="profile">
|
||||||
|
<meta property="og:title" content="momme.world">
|
||||||
|
<meta property="og:description" content="Hi, I'm Momme. I build and break infrastructure: Kubernetes, NixOS, self-hosting, and FPV drones.">
|
||||||
|
<meta property="og:url" content="https://momme.world/">
|
||||||
|
<link rel="canonical" href="https://momme.world/">
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="min-h-dvh bg-bg text-fg font-mono antialiased flex flex-col items-center justify-center p-4 sm:p-8 selection:bg-accent/30">
|
||||||
|
|
||||||
|
<main class="w-full max-w-3xl">
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-line bg-panel shadow-[0_0_40px_rgba(0,0,0,.5)] overflow-hidden">
|
||||||
|
<div class="flex items-center gap-2 border-b border-line bg-bar px-3 py-2">
|
||||||
|
<span class="size-3 rounded-full bg-[#ff5f56]"></span>
|
||||||
|
<span class="size-3 rounded-full bg-[#ffbd2e]"></span>
|
||||||
|
<span class="size-3 rounded-full bg-[#27c93f]"></span>
|
||||||
|
<span class="ml-2 text-xs text-dim select-none">momme@world: ~</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="screen" tabindex="-1" class="h-[62dvh] overflow-y-auto px-4 py-4 text-[13.5px] sm:text-[15px] leading-[1.35] cursor-text">
|
||||||
|
<pre class="text-dim overflow-x-auto not-prose"><!--@banner--></pre>
|
||||||
|
|
||||||
|
<div id="out" class="whitespace-pre-wrap break-words"></div>
|
||||||
|
|
||||||
|
<form id="prompt" autocomplete="off" class="flex items-baseline gap-2 mt-1">
|
||||||
|
<span aria-hidden="true" class="text-accent shrink-0">momme@world:~$</span>
|
||||||
|
<input id="cmd" aria-label="Run a command" placeholder="type a command — try `help`"
|
||||||
|
class="flex-1 min-w-0 bg-transparent outline-none border-0 p-0 text-fg placeholder:text-dim/60 caret-transparent"
|
||||||
|
autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||||
|
<span aria-hidden="true" class="cursor -ml-[0.6em]"></span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="mt-4 flex flex-wrap justify-center gap-2" aria-label="Commands">
|
||||||
|
<!--@chips-->
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<p class="mt-4 text-center text-xs text-dim">
|
||||||
|
Terminal people: <code class="text-fg">curl momme.world</code>
|
||||||
|
· tailnet: <code class="text-fg">telnet momme.world 23</code>
|
||||||
|
· source: <a class="text-accent hover:underline" href="https://git.momme.cloud/momme/momme-world">git.momme.cloud/momme/momme-world</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<script src="/site.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Source scanning: the generated output (template + chips + runtime) carries
|
||||||
|
the class names; build order guarantees it exists before this runs. */
|
||||||
|
@source "../k8s/site";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-bg: #0a0e14;
|
||||||
|
--color-panel: #10161f;
|
||||||
|
--color-bar: #161d29;
|
||||||
|
--color-line: #1f2937;
|
||||||
|
--color-fg: #d7e3d7;
|
||||||
|
--color-dim: #5c6f8a;
|
||||||
|
--color-accent: #7dd3a0;
|
||||||
|
--font-mono: ui-monospace, "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* block caret: the blinking square, reused by the typewriter */
|
||||||
|
.cursor {
|
||||||
|
display: inline-block;
|
||||||
|
inline-size: 0.6em;
|
||||||
|
block-size: 1.1em;
|
||||||
|
translate: 0 0.2em;
|
||||||
|
background: var(--color-accent);
|
||||||
|
animation: blink 1.1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink { 50% { opacity: 0 } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cursor { animation: none; }
|
||||||
|
}
|
||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
// momme.world browser runtime. Content comes from commands.json — generated
|
||||||
|
// by build/build.mjs from commands/*.md; edit those files, not this one.
|
||||||
|
// This file only implements the loop, the typewriter and command dispatch.
|
||||||
|
|
||||||
|
let COMMANDS = [];
|
||||||
|
let INDEX = new Map();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await fetch("/commands.json", { cache: "no-store" });
|
||||||
|
const data = await res.json();
|
||||||
|
COMMANDS = data.commands;
|
||||||
|
INDEX = new Map(COMMANDS.map((c) => [c.name, c]));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- content helpers ----------
|
||||||
|
const visible = () => COMMANDS.filter((c) => !c.hidden);
|
||||||
|
const unique = () => COMMANDS.filter((c, i) => COMMANDS.findIndex((x) => x.name === c.name) === i);
|
||||||
|
const commandNames = () => unique().map((c) => c.name);
|
||||||
|
const usages = () =>
|
||||||
|
visible().filter((c) => c.help).map((c) => ` ${c.name.padEnd(22)} ${c.help}`);
|
||||||
|
const fail = (message, ...more) => ({ error: message, more: more.filter(Boolean) });
|
||||||
|
const split = (value) => String(value).split("\n");
|
||||||
|
|
||||||
|
const ALIASES = {
|
||||||
|
whois: "whoami", about: "whoami", openme: "whoami",
|
||||||
|
tech: "stack", tools: "stack",
|
||||||
|
links: "social", socials: "social",
|
||||||
|
mail: "contact", email: "contact",
|
||||||
|
repos: "project", projects: "project", rm: "sudo",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ${...} placeholders a command body may contain; resolved at run time.
|
||||||
|
const substitutions = (arg) => ({
|
||||||
|
args: arg,
|
||||||
|
date: () => new Date().toDateString(),
|
||||||
|
time: () => new Date().toLocaleTimeString("de-DE"),
|
||||||
|
help: () => ["usage: <command>", "", ...usages()].join("\n"),
|
||||||
|
ls: () =>
|
||||||
|
commandNames()
|
||||||
|
.filter((n) => n.includes(" "))
|
||||||
|
.map((n) => n.split(" ").slice(1).join(" "))
|
||||||
|
.join(" "),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolve = (body, arg) =>
|
||||||
|
String(body).replace(/\$\{(\w+)\}/g, (_, key) => {
|
||||||
|
const sub = substitutions(arg)[key];
|
||||||
|
return sub === undefined ? "" : typeof sub === "function" ? sub() : sub;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- dom ----------
|
||||||
|
const out = document.getElementById("out");
|
||||||
|
const screen = document.getElementById("screen");
|
||||||
|
const form = document.getElementById("prompt");
|
||||||
|
const input = document.getElementById("cmd");
|
||||||
|
|
||||||
|
const make = (tag, cls, value) => {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (cls) node.className = cls;
|
||||||
|
if (value != null) node.textContent = value;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollDown = () => (screen.scrollTop = screen.scrollHeight);
|
||||||
|
|
||||||
|
// ---------- typewriter ----------
|
||||||
|
// print() never renders directly: it queues fully-built line nodes and this
|
||||||
|
// machine writes them character by character. A keypress or click while
|
||||||
|
// typing is active dumps the pending output instantly; `clear` drops it.
|
||||||
|
const TYPE_MS = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 5;
|
||||||
|
let queue = [];
|
||||||
|
let typing = null;
|
||||||
|
let fast = false;
|
||||||
|
let gen = 0;
|
||||||
|
let dumpNow = null;
|
||||||
|
|
||||||
|
// finish the line being typed synchronously; the queue then drains instantly
|
||||||
|
const flatten = (node) =>
|
||||||
|
node.segs.forEach((seg) => node.append(seg.href ? anchor(seg) : seg.text));
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!typing && !queue.length) return;
|
||||||
|
fast = true;
|
||||||
|
if (dumpNow) dumpNow();
|
||||||
|
else if (typing) write();
|
||||||
|
else pump();
|
||||||
|
};
|
||||||
|
|
||||||
|
const kill = () => {
|
||||||
|
gen++;
|
||||||
|
queue.length = 0;
|
||||||
|
typing = null;
|
||||||
|
dumpNow = null;
|
||||||
|
fast = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function print(value, arg) {
|
||||||
|
const lines = [];
|
||||||
|
if (value && value.error) {
|
||||||
|
lines.push(build(value.error, "text-[#ff7b72]"));
|
||||||
|
value.more.forEach((line) => lines.push(build(line, "text-[#5c6f8a]")));
|
||||||
|
} else {
|
||||||
|
split(resolve(value, arg)).forEach((line) => lines.push(build(line, "")));
|
||||||
|
}
|
||||||
|
queue.push(lines);
|
||||||
|
pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a line into typed fragments: plain text, or a link that lands whole.
|
||||||
|
const LINK = /(https?:\/\/\S+|mailto:\S+)/;
|
||||||
|
|
||||||
|
function build(line, cls) {
|
||||||
|
const node = make("span", "block min-h-[1.35em] " + (cls || ""));
|
||||||
|
const segs = [];
|
||||||
|
if (!LINK.test(line)) {
|
||||||
|
segs.push({ text: line || "\u200b" });
|
||||||
|
} else {
|
||||||
|
const arrow = line.indexOf("->");
|
||||||
|
if (arrow > 0) segs.push({ text: line.slice(0, arrow) });
|
||||||
|
(arrow > 0 ? line.slice(arrow + 2) : line).split(LINK).forEach((part) => {
|
||||||
|
if (!part) return;
|
||||||
|
if (!LINK.test(part)) return segs.push({ text: part });
|
||||||
|
segs.push({ href: part, text: part });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
node.segs = segs;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = (seg) => {
|
||||||
|
const a = make("a", "text-accent hover:underline", seg.text);
|
||||||
|
a.href = seg.href;
|
||||||
|
if (!seg.href.startsWith("mailto:")) a.rel = "noopener me";
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
if (typing || !queue.length) return;
|
||||||
|
typing = { g: gen, lines: queue.shift(), i: 0 };
|
||||||
|
write();
|
||||||
|
}
|
||||||
|
|
||||||
|
function write() {
|
||||||
|
if (!typing) return;
|
||||||
|
if (typing.i >= typing.lines.length) {
|
||||||
|
typing = null;
|
||||||
|
if (!queue.length) fast = false;
|
||||||
|
scrollDown();
|
||||||
|
return pump();
|
||||||
|
}
|
||||||
|
const node = typing.lines[typing.i];
|
||||||
|
out.append(node);
|
||||||
|
if (fast) {
|
||||||
|
flatten(node);
|
||||||
|
typing.i++;
|
||||||
|
scrollDown();
|
||||||
|
return write();
|
||||||
|
}
|
||||||
|
const g = typing.g;
|
||||||
|
typeSegs(node, g, () => {
|
||||||
|
if (typing && typing.g === g) typing.i++;
|
||||||
|
write();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeSegs(node, g, ready) {
|
||||||
|
const segs = node.segs;
|
||||||
|
if (!TYPE_MS) return dump(segs, null);
|
||||||
|
const caret = make("span", "cursor");
|
||||||
|
let si = 0, ci = 0, tn = null;
|
||||||
|
node.append(caret);
|
||||||
|
dumpNow = () => { dumpNow = null; dump(segs, caret); };
|
||||||
|
|
||||||
|
(function tick() {
|
||||||
|
if (g !== gen) return;
|
||||||
|
if (fast) return dump(segs, caret);
|
||||||
|
if (si >= segs.length) return dump(segs, caret);
|
||||||
|
const seg = segs[si];
|
||||||
|
if (seg.href) {
|
||||||
|
node.insertBefore(anchor(seg), caret);
|
||||||
|
si++;
|
||||||
|
return setTimeout(tick, TYPE_MS * 6);
|
||||||
|
}
|
||||||
|
if (!tn) {
|
||||||
|
tn = document.createTextNode("");
|
||||||
|
node.insertBefore(tn, caret);
|
||||||
|
}
|
||||||
|
if (ci < seg.text.length) {
|
||||||
|
tn.appendData(seg.text[ci++]);
|
||||||
|
if (ci % 8 === 0) scrollDown();
|
||||||
|
return setTimeout(tick, TYPE_MS);
|
||||||
|
}
|
||||||
|
si++, (ci = 0), (tn = null);
|
||||||
|
tick();
|
||||||
|
})();
|
||||||
|
|
||||||
|
function dump(segs, caret) {
|
||||||
|
dumpNow = null;
|
||||||
|
if (tn) {
|
||||||
|
const seg = segs[si];
|
||||||
|
if (seg && !seg.href) tn.appendData(seg.text.slice(ci));
|
||||||
|
si++, (ci = 0);
|
||||||
|
}
|
||||||
|
caret && caret.remove();
|
||||||
|
for (; si < segs.length; si++) {
|
||||||
|
const seg = segs[si];
|
||||||
|
seg.href ? node.append(anchor(seg)) : node.append(seg.text);
|
||||||
|
}
|
||||||
|
scrollDown();
|
||||||
|
ready();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- the loop ----------
|
||||||
|
const history = { items: [], at: 0 };
|
||||||
|
|
||||||
|
function recall(step) {
|
||||||
|
history.at += step;
|
||||||
|
if (history.at < 0) history.at = 0;
|
||||||
|
if (history.at >= history.items.length) {
|
||||||
|
history.at = history.items.length;
|
||||||
|
input.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = history.items[history.at];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve input words to [command, argument]. Longest multi-word name wins so
|
||||||
|
// "project fpv" beats any one-word command; a command with params swallows
|
||||||
|
// the rest.
|
||||||
|
function lookup(words) {
|
||||||
|
for (let len = words.length; len > 0; len--) {
|
||||||
|
const joined = words.slice(0, len).join(" ");
|
||||||
|
const cmd = INDEX.get(ALIASES[joined] || joined);
|
||||||
|
if (cmd) return [cmd, words.slice(len).join(" ")];
|
||||||
|
}
|
||||||
|
return [null, ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute(raw) {
|
||||||
|
flush();
|
||||||
|
out.append(make("div", "block text-fg mt-4", raw));
|
||||||
|
const words = raw.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if (!words.length) return scrollDown();
|
||||||
|
if (words[0] === "clear") return kill(), (out.textContent = "");
|
||||||
|
|
||||||
|
const [cmd, arg] = lookup(words);
|
||||||
|
if (!cmd) {
|
||||||
|
const near = commandNames().filter((n) => n !== words[0] && n.startsWith(words[0])).slice(0, 3);
|
||||||
|
return print(fail(`command not found: ${words[0]}`,
|
||||||
|
near.length ? `did you mean: ${near.join(", ")}` : "see: help"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// argument on a parent: either a known subcommand or a name error
|
||||||
|
if (arg && !cmd.params && !cmd.prefix) {
|
||||||
|
const kids = commandNames().filter((n) => n.startsWith(cmd.name + " "));
|
||||||
|
if (kids.length) {
|
||||||
|
const kid = INDEX.get(cmd.name + " " + arg);
|
||||||
|
if (kid) return print(resolve(kid.body, ""), "");
|
||||||
|
return print(fail(`no ${cmd.name} called "${arg}"`,
|
||||||
|
"names: " + kids.map((k) => k.slice(cmd.name.length + 1)).join(", ")), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return print(resolve(cmd.body, arg), arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const raw = input.value;
|
||||||
|
input.value = "";
|
||||||
|
if (raw.trim()) history.items.push(raw.trim());
|
||||||
|
history.at = history.items.length;
|
||||||
|
execute(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener("keydown", (event) => {
|
||||||
|
flush();
|
||||||
|
if (event.key === "ArrowUp") { event.preventDefault(); recall(-1); }
|
||||||
|
if (event.key === "ArrowDown") { event.preventDefault(); recall(1); }
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const word = input.value.trim().toLowerCase().split(/\s+/)[0];
|
||||||
|
const hit = commandNames().filter((n) => word && n.startsWith(word));
|
||||||
|
if (hit.length === 1) { event.preventDefault(); input.value = hit[0] + " "; }
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.addEventListener("click", () => {
|
||||||
|
flush();
|
||||||
|
if (!getSelection().toString()) input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
load().then(
|
||||||
|
() => {
|
||||||
|
document.querySelectorAll("[data-run]").forEach((button) =>
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
execute(button.dataset.run);
|
||||||
|
input.focus();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
print("hello — type help for the rest of it.", "");
|
||||||
|
input.focus();
|
||||||
|
},
|
||||||
|
() => print({ error: "commands.json did not load — did the site generator run?", more: [] }, "")
|
||||||
|
);
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""momme.world telnet shell.
|
||||||
|
|
||||||
|
Serves the same commands as the website, to real terminals:
|
||||||
|
telnet momme.world 23
|
||||||
|
|
||||||
|
Content lives in commands.json next to this file (generated from commands/*.md
|
||||||
|
by build/build.mjs) — this server is only a REPL. Standard library only. It
|
||||||
|
answers enough telnet negotiation to keep classic clients happy; it is not a
|
||||||
|
login shell, there is nothing here to log into.
|
||||||
|
|
||||||
|
Inside the cluster it listens on TELNET_PORT (2323); the public port 23
|
||||||
|
arrives via Envoy Gateway's TCPRoute on the tailscale funnel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
COMMANDS_FILE = os.environ.get("COMMANDS_FILE", os.path.join(HERE, "commands.json"))
|
||||||
|
PORT = int(os.environ.get("TELNET_PORT", "2323"))
|
||||||
|
IDLE_TIMEOUT = 300 # seconds; five idle minutes is a long enough hello
|
||||||
|
|
||||||
|
# telnet negotiation: answer WILL/WONT so clients stop asking
|
||||||
|
IAC, WONT, ECHO, SGA = 255, 252, 1, 3
|
||||||
|
|
||||||
|
ALIASES = {
|
||||||
|
"whois": "whoami", "about": "whoami", "openme": "whoami",
|
||||||
|
"tech": "stack", "tools": "stack",
|
||||||
|
"links": "social", "socials": "social",
|
||||||
|
"mail": "contact", "email": "contact",
|
||||||
|
"repos": "project", "projects": "project", "rm": "sudo", "quit": "exit",
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(COMMANDS_FILE, encoding="utf-8") as fh:
|
||||||
|
SITE = json.load(fh)
|
||||||
|
COMMANDS = {c["name"]: c for c in SITE["commands"]}
|
||||||
|
BANNER = SITE["banner"]
|
||||||
|
|
||||||
|
|
||||||
|
def usages():
|
||||||
|
return [
|
||||||
|
f" {c['name']:<22} {c['help']}"
|
||||||
|
for c in SITE["commands"]
|
||||||
|
if not c.get("hidden") and c.get("help")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def substitute(body, arg):
|
||||||
|
def rep(match):
|
||||||
|
key = match.group(1)
|
||||||
|
if key == "args":
|
||||||
|
return arg
|
||||||
|
if key == "date":
|
||||||
|
return time.strftime("%a %b %d %Y")
|
||||||
|
if key == "time":
|
||||||
|
return time.strftime("%H:%M:%S")
|
||||||
|
if key == "help":
|
||||||
|
return "\n".join(["usage: <command>", ""] + usages())
|
||||||
|
if key == "ls":
|
||||||
|
return " ".join(n.split(" ", 1)[1] for n in COMMANDS if " " in n)
|
||||||
|
return ""
|
||||||
|
return re.sub(r"\$\{(\w+)\}", rep, body)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(words):
|
||||||
|
for length in range(len(words), 0, -1):
|
||||||
|
joined = " ".join(words[:length])
|
||||||
|
cmd = COMMANDS.get(ALIASES.get(joined, joined))
|
||||||
|
if cmd:
|
||||||
|
return cmd, " ".join(words[length:])
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run(line):
|
||||||
|
"""Return (text, quit_after). Empty text means print nothing."""
|
||||||
|
words = [w for w in line.strip().lower().split() if w]
|
||||||
|
if not words:
|
||||||
|
return "", False
|
||||||
|
cmd, arg = lookup(words)
|
||||||
|
if not cmd:
|
||||||
|
near = [n for n in COMMANDS if n != words[0] and n.startswith(words[0])][:3]
|
||||||
|
hint = "did you mean: " + ", ".join(near) if near else "see: help"
|
||||||
|
return f"command not found: {words[0]}\n{hint}", False
|
||||||
|
if cmd["name"] == "clear":
|
||||||
|
return "\033[2J\033[H", False
|
||||||
|
if cmd["name"] == "exit":
|
||||||
|
return "bye — https://momme.world/", True
|
||||||
|
if arg and not cmd.get("params") and not cmd.get("prefix"):
|
||||||
|
kids = [n[len(cmd["name"]) + 1:] for n in COMMANDS if n.startswith(cmd["name"] + " ")]
|
||||||
|
if kids:
|
||||||
|
kid = COMMANDS.get(cmd["name"] + " " + arg)
|
||||||
|
if kid:
|
||||||
|
return substitute(kid["body"], ""), False
|
||||||
|
names = ", ".join(kids)
|
||||||
|
return f'no {cmd["name"]} called "{arg}"\nnames: {names}', False
|
||||||
|
return substitute(cmd["body"], arg), cmd["name"] == "quit"
|
||||||
|
|
||||||
|
|
||||||
|
class Shell(socketserver.BaseRequestHandler):
|
||||||
|
def send(self, text):
|
||||||
|
try:
|
||||||
|
self.request.sendall(text.replace("\n", "\r\n").encode("utf-8"))
|
||||||
|
except OSError:
|
||||||
|
raise ConnectionError
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
try:
|
||||||
|
self.request.sendall(bytes([IAC, WONT, ECHO, IAC, WONT, SGA]))
|
||||||
|
self.send(BANNER + "\n\nhello — type help for the rest of it.\n")
|
||||||
|
self.loop()
|
||||||
|
except (ConnectionError, ConnectionResetError, BrokenPipeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def loop(self):
|
||||||
|
line = ""
|
||||||
|
self.send("momme@world:~$ ")
|
||||||
|
while True:
|
||||||
|
ready, _, _ = select.select([self.request], [], [], IDLE_TIMEOUT)
|
||||||
|
if not ready:
|
||||||
|
return
|
||||||
|
data = self.request.recv(1024)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
i = 0
|
||||||
|
while i < len(data):
|
||||||
|
byte = data[i]
|
||||||
|
if byte == IAC:
|
||||||
|
i += 3 # skip a negotiation triple wholesale
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
if byte in (13, 10):
|
||||||
|
self.send("\n")
|
||||||
|
text, goodbye = run(line)
|
||||||
|
if text:
|
||||||
|
self.send(text + "\n")
|
||||||
|
if goodbye:
|
||||||
|
self.send("closing connection\n")
|
||||||
|
return
|
||||||
|
line = ""
|
||||||
|
self.send("momme@world:~$ ")
|
||||||
|
elif byte in (127, 8):
|
||||||
|
if line:
|
||||||
|
line = line[:-1]
|
||||||
|
self.send("\b \b")
|
||||||
|
elif 32 <= byte < 127:
|
||||||
|
line += chr(byte)
|
||||||
|
self.send(chr(byte))
|
||||||
|
|
||||||
|
|
||||||
|
class Server(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with Server(("0.0.0.0", PORT), Shell) as srv:
|
||||||
|
print(f"momme.world telnet shell on :{PORT}", file=sys.stderr, flush=True)
|
||||||
|
srv.serve_forever()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
_ _
|
||||||
|
| | | |
|
||||||
|
_ __ ___ ___ _ __ ___ _ __ ___ ___ __ _____ _ __| | __| |
|
||||||
|
| '_ ` _ \ / _ \| '_ ` _ \| '_ ` _ \ / _ \ \ \ /\ / / _ \| '__| |/ _` |
|
||||||
|
| | | | | | (_) | | | | | | | | | | | __/ _ \ V V / (_) | | | | (_| |
|
||||||
|
|_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_) \_/\_/ \___/|_| |_|\__,_|
|
||||||
|
|
||||||
|
0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100
|
||||||
Reference in New Issue
Block a user