site generator: commands/*.md with front matter -> k8s/site/
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:
momme
2026-10-14 01:14:28 +02:00
parent 5852efafd7
commit 8d33460580
47 changed files with 2773 additions and 424 deletions
+38
View File
@@ -63,6 +63,41 @@ spec:
readOnlyRootFilesystem: true
capabilities:
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:
- name: site
configMap:
@@ -70,5 +105,8 @@ spec:
- name: config
configMap:
name: config
- name: telnet
configMap:
name: telnet
- name: tmp
emptyDir: {}
+4
View File
@@ -10,9 +10,13 @@ configMapGenerator:
- site/index.html
- site/style.css
- site/site.js
- site/commands.json
- site/hello.txt
- site/favicon.svg
- site/robots.txt
- name: config
files:
- site/nginx.conf
- name: telnet
files:
- site/telnet.py
+19
View File
@@ -11,3 +11,22 @@ spec:
- name: http
port: 80
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
+265
View File
@@ -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
View File
@@ -5,37 +5,45 @@
| | | | | | (_) | | | | | | | | | | | __/ _ \ V V / (_) | | | | (_| |
|_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_) \_/\_/ \___/|_| |_|\__,_|
0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100
0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100
contact
=======
mail -> mailto:momme@juergensen.me
matrix -> https://matrix.to/#/@momme:chaos.jetzt
hello
=====
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.
whoami
------
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.
project
=======
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
social
======
github -> https://github.com/supermomme
mastodon -> https://cybre.town/@momme
instagram -> https://instagram.com/supermommej
stack
-----
=====
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
projects
--------
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
contact
-------
mail momme@juergensen.me
matrix @momme:juergensen.me
github https://github.com/supermomme
mastodon https://cybre.town/@momme
whoami
======
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.
The interactive version of this page is at https://momme.world/
+28 -53
View File
@@ -13,81 +13,56 @@
<link rel="canonical" href="https://momme.world/">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css">
<script>document.documentElement.classList.add("js")</script>
</head>
<body>
<main>
<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">
<div class="term" id="term">
<div class="titlebar">
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
<span class="title">momme@world: ~</span>
<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 class="screen" id="screen" tabindex="-1">
<pre class="hello">
_ _
<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"> _ _
| | | |
_ __ ___ ___ _ __ ___ _ __ ___ ___ __ _____ _ __| | __| |
| '_ ` _ \ / _ \| '_ ` _ \| '_ ` _ \ / _ \ \ \ /\ / / _ \| '__| |/ _` |
| | | | | | (_) | | | | | | | | | | | __/ _ \ V V / (_) | | | | (_| |
|_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_) \_/\_/ \___/|_| |_|\__,_|
0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100
</pre>
0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100</pre>
<div id="out"></div>
<div id="out" class="whitespace-pre-wrap break-words"></div>
<form id="prompt" autocomplete="off">
<span class="ps1" aria-hidden="true">momme@world:~$</span>
<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 class="caret" aria-hidden="true"></span>
<span aria-hidden="true" class="cursor -ml-[0.6em]"></span>
</form>
</div>
</div>
<nav class="chips" aria-label="Commands">
<button data-run="whoami">whoami</button>
<button data-run="projects">projects</button>
<button data-run="stack">stack</button>
<button data-run="social">social</button>
<button data-run="contact">contact</button>
<nav class="mt-4 flex flex-wrap justify-center gap-2" aria-label="Commands">
<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="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="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" 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="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>
<p class="curl">
Terminal people: <code>curl momme.world</code>
&nbsp;·&nbsp; source: <a href="https://git.momme.cloud/momme-world/momme-world">git.momme.cloud/momme-world/momme-world</a>
<p class="mt-4 text-center text-xs text-dim">
Terminal people: <code class="text-fg">curl momme.world</code>
&nbsp;·&nbsp; tailnet: <code class="text-fg">telnet momme.world 23</code>
&nbsp;·&nbsp; source: <a class="text-accent hover:underline" href="https://git.momme.cloud/momme/momme-world">git.momme.cloud/momme/momme-world</a>
</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>
<script src="/site.js"></script>
</body>
+5
View File
@@ -32,6 +32,11 @@ server {
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)$ {
add_header Cache-Control "public, max-age=604800";
}
+92 -156
View File
@@ -1,127 +1,52 @@
// Content lives in COMMANDS only: every entry renders in the terminal, and the
// names double as the `help` listing. A `label -> url` line becomes a real link.
// Adding a section to this site means adding one object here.
const COMMANDS = [
{
name: "hello",
help: "the welcome message",
body: `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.`,
},
{
name: "whoami",
help: "who is typing",
body: `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.`,
},
{
name: "projects",
help: "projects <name> — what I'm building",
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 :)" },
];
// 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: "projects", rm: "sudo",
repos: "project", projects: "project", rm: "sudo",
};
// ---------- content helpers ----------
const byName = (name) => COMMANDS.filter((c) => c.name === name && !c.prefix)[0];
const body = (name) => render(byName(name).body, "");
const render = (value, arg) => {
const v = typeof value === "function" ? value(arg) : value;
return Array.isArray(v) ? v.join("\n") : String(v);
};
const names = (name) =>
split(body(name)).map((line) => line.trim().split(/\s{2,}/)[0]).filter(Boolean);
const commandNames = () => unique().map((c) => c.name);
const unique = () =>
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 split = (value) => value.split("\n");
// ${...} 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");
@@ -149,7 +74,7 @@ let fast = false;
let gen = 0;
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) =>
node.segs.forEach((seg) => node.append(seg.href ? anchor(seg) : seg.text));
@@ -170,13 +95,12 @@ const kill = () => {
};
function print(value, arg) {
const resolved = typeof value === "function" ? value(arg) : value;
const lines = [];
if (resolved && resolved.error) {
lines.push(build(resolved.error, "line err"));
resolved.more.forEach((line) => lines.push(build(line, "line dim")));
if (value && value.error) {
lines.push(build(value.error, "text-[#ff7b72]"));
value.more.forEach((line) => lines.push(build(line, "text-[#5c6f8a]")));
} 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);
pump();
@@ -186,7 +110,7 @@ function print(value, arg) {
const LINK = /(https?:\/\/\S+|mailto:\S+)/;
function build(line, cls) {
const node = make("span", cls);
const node = make("span", "block min-h-[1.35em] " + (cls || ""));
const segs = [];
if (!LINK.test(line)) {
segs.push({ text: line || "\u200b" });
@@ -201,10 +125,10 @@ function build(line, cls) {
}
node.segs = segs;
return node;
};
}
const anchor = (seg) => {
const a = make("a", null, seg.text);
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;
@@ -301,35 +225,43 @@ function recall(step) {
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) {
const [word = "", ...rest] = raw.trim().split(/\s+/);
const arg = rest.join(" ");
const name = word.toLowerCase();
flush();
out.append(make("div", "line cmd", raw));
if (!name) return scrollDown();
if (name === "clear") return kill(), (out.textContent = "");
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 wanted = ALIASES[name] || name;
const found =
COMMANDS.find((c) => c.name === wanted && !c.prefix) ||
COMMANDS.find((c) => c.name === wanted && c.prefix);
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"));
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"), "");
}
if (found.name === "projects" && arg) {
const key = arg.toLowerCase();
if (key === "list") return print(body("projects"));
return print(found.detail[key] ||
fail(`no project called "${arg}"`, "names:", ...names("projects")));
// 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(found.run ? found.run(arg) : found.body, arg);
return print(resolve(cmd.body, arg), arg);
}
form.addEventListener("submit", (event) => {
@@ -346,7 +278,7 @@ input.addEventListener("keydown", (event) => {
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().split(/\s+/)[0];
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] + " "; }
});
@@ -356,12 +288,16 @@ screen.addEventListener("click", () => {
if (!getSelection().toString()) input.focus();
});
document.querySelectorAll("[data-run]").forEach((button) =>
button.addEventListener("click", () => {
execute(button.dataset.run);
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: [] }, "")
);
print(`hello — type help for the rest of it.\n`);
input.focus();
+2 -136
View File
File diff suppressed because one or more lines are too long
+165
View File
@@ -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()