From e8ea3b8f34d9159f33a62229b32f6f9a9f657396 Mon Sep 17 00:00:00 2001 From: thankywal <229465291+thankywal@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:53:39 +0700 Subject: [PATCH 1/2] The site is a build, and GitHub Pages serves it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console, the product page and the guide were served by an express process on a VPS. Everything that process did which needed a process was rendering the README into the guide, and that can happen once, at build time. The machine it ran on was the last thing in Muxel's own path that an owner had to trust for a page which, by design, talks to nothing but their own Worker. scripts/build-site.mjs writes the whole site as files: the product page at the root, the console at /console/, and the guide in five languages plus the two documents the READMEs link to. The hostname the old process used to pick between the product page and the console becomes a path, which is free because the console has no router, no history and no hash — nothing in it reads the URL. The base path is the one thing that differs between hosts: a project site lives under /muxel/ and a custom domain lives at /. The workflow reads it from GitHub rather than deciding it here, so pointing a domain at this later changes nothing in the repository. Every root-absolute URL the site uses is moved onto that base on the way out, and what may be moved is the set of names the build actually emits — a link to something that is not there cannot be quietly repointed, and one nobody taught the build about fails it. server.mjs now serves what the build wrote instead of building on demand, so the copy it hands a browser and the copy Pages hands a browser are the same bytes rather than two renderings that can drift. The build's own test found one bug worth naming: an open bracket in the URL pattern ate `text.replace(/docs/g, x)`, because a regular expression in JavaScript starts exactly the way a path does. Also: .dev.vars.example still offered live web search and reading a document as data, which were removed at revision 20. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LoYEEHLJc4CifTLHjKpcDF --- .dev.vars.example | 5 +- .github/workflows/pages.yml | 69 ++++++++ .gitignore | 1 + packages/console/deploy.sh | 24 ++- packages/console/guide.mjs | 2 +- packages/console/public/index.html | 8 +- packages/console/server.mjs | 81 +++------ .../runtime/test/notice-and-update.test.ts | 7 +- .../test/the-docs-are-the-readme.test.ts | 21 +-- .../runtime/test/the-site-is-a-build.test.ts | 152 +++++++++++++++++ scripts/build-site.mjs | 156 ++++++++++++++++++ 11 files changed, 433 insertions(+), 93 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 packages/runtime/test/the-site-is-a-build.test.ts create mode 100644 scripts/build-site.mjs diff --git a/.dev.vars.example b/.dev.vars.example index 90cffab..e4b5806 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -11,9 +11,8 @@ # itself, and shows it to you on its own front page the first time you open it. # # Everything Muxel can be given — a console bot in Telegram, a key for a model -# from another provider, live web search, reading a document as data — is added -# afterwards, from the Worker's own Settings, Variables and Secrets, by someone -# who by then knows what it is for. +# from another provider — is added afterwards, from the Worker's own Settings, +# Variables and Secrets, by someone who by then knows what it is for. # # Copy this file to .dev.vars for local development. Never commit real values: # .dev.vars is ignored by git for that reason. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..eaeaa9a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,69 @@ +# The site, served by GitHub Pages. +# +# It used to be an express process on a VPS. Everything it did that needed a +# process — rendering the README into the guide — happens here instead, at +# build time, and what is served is files. Nothing of an owner's passes through +# it either way: the console is a page that talks to the owner's own Worker +# directly, so this is a static host and that is all it needs to be. +name: Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deploy at a time, and never cancel one halfway: a cancelled deploy leaves +# the site as it was, but a queued one is worth waiting for. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + # The console is outside the workspace, so its one dependency — the + # markdown renderer the guide is built with — is installed on its own. + - name: Install the console's own dependencies + run: npm --prefix packages/console install --omit=dev --no-package-lock + + # Turns Pages on the first time this runs, and reports where the site + # will live. A project site sits under // and a custom domain sits + # at the root; base_path is that difference, from GitHub rather than + # guessed here, so adding a domain later changes nothing in this file. + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + with: + enablement: true + + - name: Build the site + run: node scripts/build-site.mjs + env: + SITE_BASE: ${{ steps.pages.outputs.base_path }} + + - uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index acab36d..44f34c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +site/ *.tsbuildinfo .wrangler/ diff --git a/packages/console/deploy.sh b/packages/console/deploy.sh index 8b4941e..8bc7862 100755 --- a/packages/console/deploy.sh +++ b/packages/console/deploy.sh @@ -1,23 +1,21 @@ #!/bin/bash -# Copies the console from this repository onto the host that serves it. +# Copies the built site onto a host that serves it with the file server here. # -# The repository is the source of truth and this is the only direction, so the -# two copies cannot drift into disagreeing with each other. +# GitHub Pages is the site's home and builds it itself; this is for a host that +# is not Pages. Both serve what scripts/build-site.mjs writes, so there is one +# site and two places it can be put, rather than two sites. set -euo pipefail TARGET="${1:-/opt/muxel-console}" HERE="$(cd "$(dirname "$0")" && pwd)" - ROOT="$(cd "$HERE/../.." && pwd)" -mkdir -p "$TARGET/public/assets" "$TARGET/public/docs/media" "$TARGET/guide" -cp "$HERE/server.mjs" "$HERE/guide.mjs" "$HERE/package.json" "$TARGET/" -cp "$HERE/public/"*.html "$HERE/public/"*.css "$HERE/public/"*.js "$HERE/public/"*.json "$TARGET/public/" -cp "$HERE/public/assets/"* "$TARGET/public/assets/" -# The guide is the README, so the README travels with the console: all five -# languages, the two documents it links to, and the images all of them show. -cp "$ROOT"/README.md "$ROOT"/README.*.md "$TARGET/guide/" -cp "$ROOT/docs/DEPLOY-RECOVERY.md" "$ROOT/docs/TELEGRAM-SETUP.md" "$TARGET/guide/" -cp "$ROOT/docs/media/"* "$TARGET/public/docs/media/" +# Served at the root here, so the site is built to think it lives there. +SITE_BASE=/ node "$ROOT/scripts/build-site.mjs" + +mkdir -p "$TARGET" +rm -rf "$TARGET/site" +cp -r "$ROOT/site" "$TARGET/site" +cp "$HERE/server.mjs" "$HERE/package.json" "$TARGET/" cd "$TARGET" npm install --omit=dev --silent diff --git a/packages/console/guide.mjs b/packages/console/guide.mjs index 67f0799..fdb71d0 100644 --- a/packages/console/guide.mjs +++ b/packages/console/guide.mjs @@ -175,7 +175,7 @@ export function renderGuide({ markdown, key = "en" }) {
Muxel - ConsoleGitHub + ConsoleGitHub
diff --git a/packages/console/public/index.html b/packages/console/public/index.html index ff43b69..5808353 100644 --- a/packages/console/public/index.html +++ b/packages/console/public/index.html @@ -51,7 +51,7 @@
Muxel
@@ -71,7 +71,7 @@

We cannot show you
a list of our users.
We do not have one. @@ -180,8 +180,8 @@

Bring your own cloud

© 2026 Muxel, from Oro Shin.
diff --git a/packages/console/server.mjs b/packages/console/server.mjs index 8269a86..a75b22b 100644 --- a/packages/console/server.mjs +++ b/packages/console/server.mjs @@ -1,29 +1,27 @@ /** - * Muxel landing page and web console. + * Serves the built site, for local preview and for a host that is not Pages. * - * Deliberately isolated: its own directory outside /opt/oro-agent, its own - * pm2 process, its own port and its own nginx block, so that day to day work - * on the agent cannot take the demo down and vice versa. + * This used to be the site: it read the console's files, rendered the guide + * from the README on request, and picked between the product page and the + * console by hostname. All of that now happens once, in + * `scripts/build-site.mjs`, and what is left here is a file server pointed at + * what that wrote. There is one implementation of the site again, so the copy + * a browser gets from this process and the copy GitHub Pages serves cannot + * disagree — they are the same bytes. * - * This process serves files. That is the whole of it. + * Run `node scripts/build-site.mjs` first. SITE_BASE decides where the site + * thinks it lives; this serves the root, so build with SITE_BASE=/. * - * It used to proxy the console's calls through to the owner's Worker, which - * put it in the path of every message, every uploaded file and every bearer - * token, and made the promise on the front page false: there WAS a server of - * ours in the path. The Worker already answers the browser directly, so the - * proxy was removed rather than defended. Switching this process off now stops - * new people from loading the page and does not interrupt a single deployment. - * - * It keeps no database, no session and no record of which deployments exist. + * It keeps no database, no session and no record of which deployments exist, + * and it is not in the path of anything an owner does: the console is a page + * that talks to the owner's own Worker directly. */ import express from "express"; -import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { fileFor, renderGuide } from "./guide.mjs"; - const DIR = path.dirname(fileURLToPath(import.meta.url)); +const SITE = process.env.SITE_DIR ?? path.join(DIR, "site"); const PORT = Number(process.env.PORT ?? 4400); const app = express(); @@ -34,51 +32,14 @@ app.get("/healthz", (_req, res) => { res.json({ service: "muxel-console" }); }); -// No directory redirects: public/docs/ holds the guide's images, and without -// this express answered /docs itself with a 301 to /docs/ before the guide -// route below ever saw it. -app.use(express.static(path.join(DIR, "public"), { index: false, redirect: false })); - -/** - * The guide, which is the README rendered. - * - * Read once per process from the copy deploy.sh puts beside this file. The - * copy changes only when a deploy replaces it, and a deploy restarts this - * process, so there is nothing to invalidate. - */ -const GUIDE_DIR = path.join(DIR, "guide"); -const guides = new Map(); -async function guide(key) { - const file = fileFor(key); - if (file === null) return null; - if (!guides.has(key)) { - const markdown = await readFile(path.join(GUIDE_DIR, file), "utf8"); - guides.set(key, renderGuide({ markdown, key }).html); - } - return guides.get(key); -} -app.get(["/docs", "/docs/:key"], async (req, res, next) => { - try { - const html = await guide(req.params.key ?? "en"); - if (html === null) return next(); - res.type("html").send(html); - } catch (error) { - next(error); - } -}); - -/** - * One deployment, two faces, chosen by hostname so each owns a clean root: - * muxel.site is the product page a stranger lands on, app.muxel.site is where - * an owner learns, deploys and then works. - */ -const CONSOLE_HOSTS = new Set(["app.muxel.site"]); -app.get(/.*/, (req, res) => { - const host = (req.hostname || "").toLowerCase(); - const wantsConsole = CONSOLE_HOSTS.has(host) || req.path.startsWith("/console"); - res.sendFile(path.join(DIR, "public", wantsConsole ? "console.html" : "index.html")); +// The same shape Pages serves: /docs/ is the directory's index.html, and /docs +// redirects to it. Anything else is answered by the page the build wrote for +// exactly that, which is the product page. +app.use(express.static(SITE)); +app.get(/.*/, (_req, res) => { + res.status(404).sendFile(path.join(SITE, "404.html")); }); app.listen(PORT, "127.0.0.1", () => { - console.log(`[muxel-console] serving files on 127.0.0.1:${PORT}`); + console.log(`[muxel-console] serving ${SITE} on 127.0.0.1:${PORT}`); }); diff --git a/packages/runtime/test/notice-and-update.test.ts b/packages/runtime/test/notice-and-update.test.ts index 47c688a..576c82e 100644 --- a/packages/runtime/test/notice-and-update.test.ts +++ b/packages/runtime/test/notice-and-update.test.ts @@ -53,8 +53,11 @@ describe("the notice", () => { }); it("is deployed with the console", () => { - expect(readFileSync(new URL("../../console/deploy.sh", import.meta.url), "utf8")) - .toContain('"$HERE/public/"*.json'); + // The build copies the console's directory whole rather than naming the + // files in it, so a file the console fetches cannot be left behind by + // somebody forgetting to add it to a list. + expect(readFileSync(new URL("../../../scripts/build-site.mjs", import.meta.url), "utf8")) + .toContain("for (const name of await readdir(publicDir))"); }); }); diff --git a/packages/runtime/test/the-docs-are-the-readme.test.ts b/packages/runtime/test/the-docs-are-the-readme.test.ts index 1a7d441..8f0d646 100644 --- a/packages/runtime/test/the-docs-are-the-readme.test.ts +++ b/packages/runtime/test/the-docs-are-the-readme.test.ts @@ -29,19 +29,20 @@ describe("the links that promise a guide", () => { it("send the product page's guide link there too", () => { const index = consoleFile("public/index.html"); - expect(index).toContain('href="https://app.muxel.site/docs"'); + // On this site, now that the site is one build rather than two hosts. + expect(index).toContain('href="/docs"'); expect(index).not.toContain("docs/TELEGRAM-SETUP.md"); }); - it("are served by the console, which reads the README beside it", () => { - const server = consoleFile("server.mjs"); - expect(server).toContain('"/docs/:key"'); - expect(server).toContain('import { fileFor, renderGuide } from "./guide.mjs"'); - // deploy.sh carries what the render needs, or the route reads nothing. - const deploy = consoleFile("deploy.sh"); - for (const needed of ['"$HERE/guide.mjs"', "README.md", "README.*.md", "DEPLOY-RECOVERY.md", "TELEGRAM-SETUP.md", "docs/media/"]) { - expect(deploy, `deploy.sh does not carry ${needed}`).toContain(needed); - } + it("are written by the build, which reads the READMEs of the repository", () => { + const build = readFileSync(new URL("../../../scripts/build-site.mjs", import.meta.url), "utf8"); + expect(build).toContain('from "../packages/console/guide.mjs"'); + // Every language and both documents, from the lists rather than a copy of + // them, so a language added to the guide cannot be left out of the site. + expect(build).toContain("[...Object.keys(LANGS), ...Object.keys(PAGES)]"); + expect(build).toContain("renderGuide({ markdown, key })"); + // The pictures the READMEs show, where the rendered README looks for them. + expect(build).toContain('path.join(ROOT, "docs/media")'); }); }); diff --git a/packages/runtime/test/the-site-is-a-build.test.ts b/packages/runtime/test/the-site-is-a-build.test.ts new file mode 100644 index 0000000..7f0cb3b --- /dev/null +++ b/packages/runtime/test/the-site-is-a-build.test.ts @@ -0,0 +1,152 @@ +/** + * The site is files, and one build writes all of them. + * + * It used to be a process on a VPS: it served the console's files, rendered the + * guide from the README on request, and chose between the product page and the + * console by hostname. That machine was the last thing in Muxel's own path an + * owner had to trust for a page that, by design, talks only to their own + * Worker. The same site is now written out once and served by GitHub Pages. + * + * These read the site the build actually wrote rather than reading the build + * script and taking its word for it, because the failure worth catching is a + * page that is missing or a link that points where the site is not. + */ +import { afterAll, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +// @ts-expect-error the build is plain JavaScript, typed by nothing. +import { buildSite, missedRoots, normalizeBase, withBase } from "../../../scripts/build-site.mjs"; +// @ts-expect-error the console is plain JavaScript outside the workspace, typed by nothing. +import { LANGS, PAGES } from "../../console/guide.mjs"; + +const out = mkdtempSync(join(tmpdir(), "muxel-site-")); +afterAll(() => rmSync(out, { recursive: true, force: true })); + +const BASE = "/muxel/"; +const built = (await buildSite({ base: BASE, out })) as { + base: string; + out: string; + pages: string[]; + rewritten: string[]; +}; +const file = (name: string): string => readFileSync(join(out, name), "utf8"); + +describe("where the site thinks it lives", () => { + it("reads the same base path out of every shape GitHub gives it", () => { + // A project site reports "/muxel"; a custom domain reports "". + expect(normalizeBase("/muxel")).toBe("/muxel/"); + expect(normalizeBase("muxel")).toBe("/muxel/"); + expect(normalizeBase("/muxel/")).toBe("/muxel/"); + expect(normalizeBase("")).toBe("/"); + expect(normalizeBase("/")).toBe("/"); + }); + + it("moves the site's own URLs and leaves everyone else's alone", () => { + const roots = new Set(["docs", "app.js"]); + expect(withBase('href="/docs"', BASE, roots)).toBe('href="/muxel/docs"'); + expect(withBase('src="/app.js"', BASE, roots)).toBe('src="/muxel/app.js"'); + // Not ours: another site's path, and a regular expression. + expect(withBase('href="/elsewhere"', BASE, roots)).toBe('href="/elsewhere"'); + expect(withBase("text.replace(/docs/g, x)", BASE, roots)).toBe("text.replace(/docs/g, x)"); + // At the root the rewrite is the identity, so one build serves both hosts. + expect(withBase('href="/docs"', "/", roots)).toBe('href="/docs"'); + }); + + it("finds a URL that was left at the root", () => { + const roots = new Set(["notice.json"]); + expect(missedRoots('fetch("/notice.json")', roots)).toEqual(["notice.json"]); + expect(missedRoots('fetch("/muxel/notice.json")', roots)).toEqual([]); + }); +}); + +describe("what the build writes", () => { + it("has the product page, the console and the guide in every language", () => { + const expected = [ + "index.html", + "console/index.html", + "docs/index.html", + ...Object.keys(LANGS as Record) + .filter((key) => key !== "en") + .map((key) => `docs/${key}/index.html`), + ...Object.keys(PAGES as Record).map((key) => `docs/${key}/index.html`), + "404.html", + ]; + expect([...built.pages].sort()).toEqual([...expected].sort()); + }); + + it("carries the console's own files, so nothing it fetches has to be listed here", () => { + // The notice the console polls travels because the whole directory does. + expect(() => file("notice.json")).not.toThrow(); + expect(() => file("app.js")).not.toThrow(); + expect(() => file("app.css")).not.toThrow(); + expect(() => file("assets/logo.png")).not.toThrow(); + // The README's pictures, where the rendered README looks for them. + expect(() => file("docs/media/assistant.webp")).not.toThrow(); + }); + + it("answers an unknown path with the page that says what this is", () => { + expect(file("404.html")).toBe(file("index.html")); + }); + + it("keeps Jekyll from taking a turn at it", () => { + expect(() => file(".nojekyll")).not.toThrow(); + }); +}); + +describe("every link the site makes to itself", () => { + it("goes through the base path, in the pages and in the files they load", () => { + for (const where of [...built.pages, ...built.rewritten]) { + const roots = new Set([ + "index.html", + "console", + "docs", + "app.js", + "app.css", + "demo.js", + "demo.css", + "styles.css", + "notice.json", + "assets", + ]); + expect(missedRoots(file(where), roots), `${where} points at the root`).toEqual([]); + } + }); + + it("sends the product page to the console and the guide on this site", () => { + const index = file("index.html"); + expect(index).toContain('href="/muxel/console/"'); + expect(index).toContain('href="/muxel/docs"'); + // The console used to be a hostname away, on a machine of ours. + expect(index).not.toContain("app.muxel.site"); + }); + + it("sends the console's footer to the guide", () => { + expect(file("app.js")).toMatch(/href="\/muxel\/docs"[^>]*>Docs { + const guide = file("docs/index.html"); + // The header only: the README's own prose is rendered below it verbatim, + // and what it says is the README's business, not this page's. + const header = guide.slice(guide.indexOf("
"), guide.indexOf("
")); + expect(header).toContain('href="/muxel/console/"'); + expect(header).toContain("https://github.com/thankywal/muxel"); + expect(header).not.toContain("app.muxel.site"); + }); +}); + +describe("the guide is the README", () => { + it("renders each language from the README of that language", () => { + // A heading that only the English README has, and one only the Burmese has. + expect(file("docs/index.html")).toContain("Before you start"); + expect(file("docs/my/index.html")).toContain(''); + expect(file("docs/index.html")).toContain(''); + }); + + it("keeps the deploy button, which is the whole point of the page", () => { + expect(file("docs/index.html")).toContain( + "deploy.workers.cloudflare.com/?url=https://github.com/thankywal/muxel", + ); + }); +}); diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs new file mode 100644 index 0000000..debd892 --- /dev/null +++ b/scripts/build-site.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * Builds the whole site as files, so a static host can serve it. + * + * There used to be a small express process on a VPS: it served the console's + * files, rendered the guide from the README, and picked between the product + * page and the console by hostname. Only the first of those needs a process at + * all, and the machine it ran on was the last thing in Muxel's own path that + * an owner had to trust. This writes the same site out as files instead, and + * GitHub Pages serves them. + * + * The two faces the hostname used to choose between become two paths: the + * product page at the root, the console under /console/. Nothing in the console + * reads the URL — it has no router, no history and no hash — so a path is free. + * + * The base path is the one thing that changes between hosts: a project site + * lives under //, a custom domain lives at /. It is read from the + * environment rather than decided here, because the workflow gets it from + * GitHub itself, and every root-absolute URL in the site is rewritten through + * it on the way out. What may be rewritten is the set of names this build + * actually emits, so a link to something that does not exist cannot be quietly + * repointed, and a new one that nobody taught this about fails the check below. + */ +import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { fileFor, LANGS, PAGES, renderGuide } from "../packages/console/guide.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const CONSOLE = path.join(ROOT, "packages/console"); + +/** Trailing slash always, leading slash always: "/muxel" and "muxel/" both mean "/muxel/". */ +export function normalizeBase(value) { + const trimmed = String(value ?? "").trim(); + if (trimmed === "" || trimmed === "/") return "/"; + return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`; +} + +/** + * Where a URL can begin: inside a quote, or inside a stylesheet's url(). + * + * A bare open bracket was here once and it ate `text.replace(/docs/g, x)`, + * because a regular expression in JavaScript starts exactly the way a path + * does. Naming url() rather than the bracket keeps the stylesheets and leaves + * the code alone. + */ +const URL_START = /(["'`]|url\()\/([A-Za-z0-9._-]+)/g; + +/** + * Rewrites the site's own root-absolute URLs onto the base path. + * + * `roots` is the first path segment of everything this build emits. Anything + * else beginning with a slash is somebody else's URL and is left alone. + */ +export function withBase(text, base, roots) { + return text.replace(URL_START, (whole, open, first) => + roots.has(first) ? `${open}${base}${first}` : whole, + ); +} + +/** Every root-absolute reference to one of the site's own names, unrewritten. */ +export function missedRoots(text, roots) { + const missed = new Set(); + for (const [, , first] of text.matchAll(URL_START)) { + if (roots.has(first)) missed.add(first); + } + return [...missed]; +} + +const REWRITTEN = new Set([".html", ".css", ".js", ".json"]); + +/** + * Writes the site. Returns what it wrote, so a test can read the site rather + * than read this file and take its word for it. + */ +export async function buildSite({ base = "/muxel/", out = path.join(ROOT, "site") } = {}) { + base = normalizeBase(base); + const OUT = out; + + await rm(OUT, { recursive: true, force: true }); + await mkdir(OUT, { recursive: true }); + + // The console's own files, flat at the root, exactly as they were served. + const publicDir = path.join(CONSOLE, "public"); + for (const name of await readdir(publicDir)) { + if (name === "index.html" || name === "console.html") continue; + await cp(path.join(publicDir, name), path.join(OUT, name), { recursive: true }); + } + // The README's pictures, where the rendered README looks for them. + await cp(path.join(ROOT, "docs/media"), path.join(OUT, "docs/media"), { recursive: true }); + + /** The pages this build writes, as path → HTML. */ + const pages = new Map(); + pages.set("index.html", await readFile(path.join(publicDir, "index.html"), "utf8")); + pages.set("console/index.html", await readFile(path.join(publicDir, "console.html"), "utf8")); + for (const key of [...Object.keys(LANGS), ...Object.keys(PAGES)]) { + const file = fileFor(key); + const from = file.startsWith("README") ? file : path.join("docs", file); + const markdown = await readFile(path.join(ROOT, from), "utf8"); + const where = key === "en" ? "docs/index.html" : `docs/${key}/index.html`; + pages.set(where, renderGuide({ markdown, key }).html); + } + // A static host answers an unknown path with this. The product page is the + // honest answer: it says what this is and links to everything else. + pages.set("404.html", pages.get("index.html")); + + // What may be rewritten: the first path segment of everything emitted. + const roots = new Set([ + ...(await readdir(OUT)), + ...[...pages.keys()].map((p) => p.split("/")[0]), + ]); + + for (const [where, html] of pages) { + const file = path.join(OUT, where); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, withBase(html, base, roots)); + } + + // The files copied above carry the same URLs, so they go through the same rewrite. + const rewritten = []; + const walk = async (dir) => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(full); + else if (REWRITTEN.has(path.extname(entry.name)) && !pages.has(path.relative(OUT, full))) { + const text = await readFile(full, "utf8"); + await writeFile(full, withBase(text, base, roots)); + rewritten.push(path.relative(OUT, full)); + } + } + }; + await walk(OUT); + + // Jekyll is not building this, and it would drop anything beginning with _. + await writeFile(path.join(OUT, ".nojekyll"), ""); + + // Nothing may still point at the root when the site does not live there. + if (base !== "/") { + for (const [where] of pages) { + const missed = missedRoots(await readFile(path.join(OUT, where), "utf8"), roots); + if (missed.length > 0) throw new Error(`${where} still points at ${missed.join(", ")}`); + } + for (const where of rewritten) { + const missed = missedRoots(await readFile(path.join(OUT, where), "utf8"), roots); + if (missed.length > 0) throw new Error(`${where} still points at ${missed.join(", ")}`); + } + } + + return { base, out: OUT, pages: [...pages.keys()], rewritten }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const built = await buildSite({ base: process.env.SITE_BASE ?? "/muxel/" }); + console.log(`site: ${built.pages.length} pages and ${built.rewritten.length} files under ${built.base}`); +} From 7da71a4c30cf099677f96eaf6cc9dd3ae26ac623 Mon Sep 17 00:00:00 2001 From: thankywal <229465291+thankywal@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:40:26 +0700 Subject: [PATCH 2/2] The console is published at one address, and one record decides it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.muxel.site was typed out in seventeen places — the Worker's own first screen, four languages of Telegram strings, the assistant's description of itself, the command line, two deploy scripts, the console's own HTML — and in five READMEs besides. The domain is going to another project, so all of it had to move, which is exactly the change seventeen copies of a string make expensive. CONSOLE_HOME lives in core, because the programs that name it cannot all import each other: the Worker puts it on its first screen, the command line prints it after a deploy, and the console links back to it. The translations take it as a variable rather than four copies of a sentence. The files that cannot import anything — the READMEs, the deploy scripts, the console's HTML — are held to the same value by a test, because a document that sends an owner somewhere empty is worse than one that says nothing at all. The console becomes the site rather than a page of it. It used to be chosen by hostname, and the hostname that chose it is the one leaving, so https://thankywal.github.io/muxel/ is the console and the product page moves to /product/, where the console's footer and an unknown path both still reach it. The build learned about the top of the site on the way: a link to it has no path segment to be recognised by, and a lone slash is far too common in code to rewrite on sight, so only an attribute counts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LoYEEHLJc4CifTLHjKpcDF --- README.ja.md | 6 +- README.md | 6 +- README.my.md | 6 +- README.th.md | 6 +- README.zh.md | 6 +- docs/DEPLOY-RECOVERY.md | 4 +- docs/TELEGRAM-SETUP.md | 10 +-- packages/cli/src/commands/init.ts | 10 +-- packages/console/README.md | 2 +- packages/console/guide.mjs | 2 +- packages/console/public/app.js | 1 + packages/console/public/index.html | 6 +- packages/core/src/console-home.ts | 27 ++++++ packages/core/src/index.ts | 1 + packages/runtime/src/assistant/loop.ts | 3 +- packages/runtime/src/setup.ts | 6 +- packages/runtime/src/telegram/admin.ts | 3 +- packages/runtime/src/telegram/i18n.ts | 10 ++- .../test/the-console-key-is-a-door.test.ts | 8 +- .../test/the-docs-are-the-readme.test.ts | 4 +- .../runtime/test/the-site-is-a-build.test.ts | 90 +++++++++++++++++-- ...terminal-deploy-asks-for-one-thing.test.ts | 4 +- scripts/build-site.mjs | 28 ++++-- scripts/deploy.mjs | 2 +- scripts/install.mjs | 4 +- 25 files changed, 188 insertions(+), 67 deletions(-) create mode 100644 packages/core/src/console-home.ts diff --git a/README.ja.md b/README.ja.md index ae730b3..4e5e565 100644 --- a/README.ja.md +++ b/README.ja.md @@ -40,7 +40,7 @@ Muxel のサーバーも、Muxel のデータベースも、Muxel のアカウ ## できること * すべてボタンだけで操作できるコンソールがあります。ブラウザの - [app.muxel.site](https://app.muxel.site) で開き、ご希望なら Telegram の bot + [thankywal.github.io/muxel](https://thankywal.github.io/muxel/) で開き、ご希望なら Telegram の bot としても使えます。どちらでも、セットアップのあとは dashboard も設定ファイルも 必要ありません。 * お店ごとにお客様用の bot があり、アップロードした価格表、ポリシー、商品情報を @@ -136,7 +136,7 @@ webhook をつなぐのもこれです。 見ていない人が考えるパスワードよりよいからです。 そのキーをコピーして、ほかのパスワードと同じ場所に保管してください。それから -[app.muxel.site](https://app.muxel.site) を開き、Worker のアドレスを貼り付けて、 +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/) を開き、Worker のアドレスを貼り付けて、 キーを貼り付けます。そのコンソールがあなた専用の操作画面です。そこでお店を追加 すると、お店の名前を尋ねられます。 @@ -343,7 +343,7 @@ Cloudflare のログインで届くのは Workers AI のモデルだけです。 ## コンソール セットアップのあとは、すべてコンソールの中で、ボタンを押して行います。ブラウザの -[app.muxel.site](https://app.muxel.site) で、そして足してあればコンソール bot でも。 +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/) で、そして足してあればコンソール bot でも。 | 画面 | 内容 | | --- | --- | diff --git a/README.md b/README.md index a30d84f..bca4f08 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ repository and drawn by its own CSS; the widget is on an ordinary website that i ## What it does * A console you drive with buttons: in a browser at - [app.muxel.site](https://app.muxel.site), and in a Telegram bot as well if + [thankywal.github.io/muxel](https://thankywal.github.io/muxel/), and in a Telegram bot as well if you want one. No dashboard and no configuration files after setup either way. * A customer bot per business that answers from your uploaded price lists, policies and product information. @@ -155,7 +155,7 @@ invented by a machine that has just been created is a better password than one invented by a person who has not seen the thing yet. Copy that key and keep it where you keep your other passwords. Then open -[app.muxel.site](https://app.muxel.site), paste your Worker's address in, and +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/), paste your Worker's address in, and paste the key. That console is your private control panel. Add a business there and it will ask what the business is called. @@ -419,7 +419,7 @@ own token, so the control panel cannot quietly become a customer's chat. ## The console Everything after setup happens in the console, in buttons: at -[app.muxel.site](https://app.muxel.site) in a browser, and in the console bot +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/) in a browser, and in the console bot if you added one. | Screen | What it holds | diff --git a/README.my.md b/README.my.md index 4dff9c1..6e5d9ea 100644 --- a/README.my.md +++ b/README.my.md @@ -42,7 +42,7 @@ Muxel server မရှိ၊ Muxel database မရှိ၊ Muxel account လည ## ဘာလုပ်ပေးသလဲ * ခလုတ်များဖြင့်သာ လုံးဝ အသုံးပြုနိုင်သော console တစ်ခု ပါဝင်ပါသည်။ browser ထဲတွင် - [app.muxel.site](https://app.muxel.site) မှ ဖွင့်နိုင်ပြီး လိုချင်ပါက Telegram bot + [thankywal.github.io/muxel](https://thankywal.github.io/muxel/) မှ ဖွင့်နိုင်ပြီး လိုချင်ပါက Telegram bot အဖြစ်လည်း ထပ်ထည့်နိုင်ပါသည်။ နှစ်မျိုးစလုံးတွင် setup ပြီးနောက် dashboard သို့မဟုတ် configuration file များကို ကိုင်တွယ်စရာ မလိုတော့ပါ။ * လုပ်ငန်းတစ်ခုစီအတွက် customer bot တစ်ခုစီ ရှိပြီး သင် upload တင်ထားသော @@ -148,7 +148,7 @@ password သည် အရာဝတ္ထုကို မမြင်ရသေး သောကြောင့် ဖြစ်သည်။ ထို key ကို ကူးယူပြီး သင့်အခြား password များ ထားသည့်နေရာတွင် သိမ်းထားပါ။ ထို့နောက် -[app.muxel.site](https://app.muxel.site) ကို ဖွင့်၍ သင့် Worker လိပ်စာကို ကူးထည့်ကာ +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/) ကို ဖွင့်၍ သင့် Worker လိပ်စာကို ကူးထည့်ကာ key ကို ကူးထည့်ပါ။ ထို console သည် သင့်ကိုယ်ပိုင် ထိန်းချုပ်ရာ နေရာ ဖြစ်ပါသည်။ ထိုနေရာတွင် လုပ်ငန်းတစ်ခု ထည့်လိုက်လျှင် လုပ်ငန်း၏ အမည်ကို တောင်းပါလိမ့်မည်။ @@ -370,7 +370,7 @@ web console တွင် လုပ်ငန်းတစ်ခုကို အမ ## console setup ပြီးနောက် လုပ်ဆောင်ရသမျှ အားလုံးကို console ထဲတွင် ခလုတ်များဖြင့်သာ လုပ်ရပါသည်။ -browser ထဲက [app.muxel.site](https://app.muxel.site) တွင်လည်းကောင်း၊ console bot +browser ထဲက [thankywal.github.io/muxel](https://thankywal.github.io/muxel/) တွင်လည်းကောင်း၊ console bot ထည့်ထားပါက ထိုနေရာတွင်လည်းကောင်း ဖြစ်ပါသည်။ | ဖန်သားပြင် | ဘာတွေ ပါဝင်သလဲ | diff --git a/README.th.md b/README.th.md index 17469f8..57b68bd 100644 --- a/README.th.md +++ b/README.th.md @@ -38,7 +38,7 @@ repository นี้และวาดด้วย CSS ของตัวมั ## ทำอะไรได้บ้าง * console ที่คุณสั่งงานด้วยปุ่มทั้งหมด เปิดในเบราว์เซอร์ที่ - [app.muxel.site](https://app.muxel.site) และถ้าต้องการก็เพิ่มเป็น bot บน Telegram + [thankywal.github.io/muxel](https://thankywal.github.io/muxel/) และถ้าต้องการก็เพิ่มเป็น bot บน Telegram ได้อีกทาง ทั้งสองทางเหมือนกันคือ หลังติดตั้งเสร็จไม่ต้องเข้า dashboard และไม่ต้องแก้ ไฟล์ตั้งค่าใด ๆ * bot สำหรับลูกค้าหนึ่งตัวต่อหนึ่งธุรกิจ ซึ่งตอบคำถามจากรายการราคา นโยบาย และข้อมูล @@ -130,7 +130,7 @@ Cloudflare ลงในบัญชีนั้น กรุณาอนุม เป็นคนคิด ย่อมดีกว่ารหัสที่คนซึ่งยังไม่เคยเห็นระบบเลยเป็นคนคิด คัดลอกรหัสนั้นไปเก็บไว้ที่เดียวกับรหัสผ่านอื่น ๆ ของคุณ จากนั้นเปิด -[app.muxel.site](https://app.muxel.site) วางที่อยู่ Worker ของคุณลงไป แล้ววางรหัสนั้น +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/) วางที่อยู่ Worker ของคุณลงไป แล้ววางรหัสนั้น console นั้นคือแผงควบคุมส่วนตัวของคุณ เพิ่มธุรกิจในนั้นแล้วระบบจะถามชื่อธุรกิจ หน้านั้นให้บริการจากโดเมนของเรา แต่ไม่ได้เก็บอะไรของคุณไว้เลย มันต้องถามที่อยู่ของคุณ @@ -327,7 +327,7 @@ Gemma 4 เป็นค่าเริ่มต้น จากการวั ## console ทุกอย่างหลังการติดตั้งเกิดขึ้นใน console ด้วยการกดปุ่มทั้งหมด ทั้งบนเบราว์เซอร์ที่ -[app.muxel.site](https://app.muxel.site) และใน console bot ถ้าคุณเพิ่มไว้ +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/) และใน console bot ถ้าคุณเพิ่มไว้ | หน้าจอ | มีอะไรอยู่ในนั้น | | --- | --- | diff --git a/README.zh.md b/README.zh.md index 915737f..cfcc3d3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -37,7 +37,7 @@ CSS 绘制;那个对话气泡则在一个并不属于我们的普通网站上。 ## 它能做什么 * 一个完全用按钮操作的 console:在浏览器里打开 - [app.muxel.site](https://app.muxel.site),想要的话再加一个 Telegram bot 也 + [thankywal.github.io/muxel](https://thankywal.github.io/muxel/),想要的话再加一个 Telegram bot 也 可以。两种方式都一样,设置完成之后不需要后台面板,也不需要改配置文件。 * 每家店铺配一个面向客户的 bot,它会根据你上传的价目表、店铺规定和商品信息 来回答问题。 @@ -124,7 +124,7 @@ https://deploy.workers.cloudflare.com/?url=https://github.com/thankywal/muxel 想出来的密码更好。 把那串密钥复制下来,存在你放其他密码的地方。然后打开 -[app.muxel.site](https://app.muxel.site),把 Worker 的地址粘进去,再把密钥粘进去。 +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/),把 Worker 的地址粘进去,再把密钥粘进去。 这个 console 就是你的私人控制面板。在里面添加一家店铺,它会问你店铺叫什么名字。 那个页面由我们的域名提供,但它不保存你的任何东西。它必须问你要地址,因为它 @@ -313,7 +313,7 @@ token,免得那个控制面板悄悄变成客户的聊天窗口。 ## console 里有什么 设置完成之后,所有操作都在 console 里通过按钮完成:在浏览器里打开 -[app.muxel.site](https://app.muxel.site),加过 console bot 的话在那里也一样。 +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/),加过 console bot 的话在那里也一样。 | 页面 | 里面有什么 | | --- | --- | diff --git a/docs/DEPLOY-RECOVERY.md b/docs/DEPLOY-RECOVERY.md index 94b5ffb..2525504 100644 --- a/docs/DEPLOY-RECOVERY.md +++ b/docs/DEPLOY-RECOVERY.md @@ -27,7 +27,7 @@ Otherwise: 2. Add a secret named `CONSOLE_KEY` — or edit it, if you already set one — and put in a new phrase of at least 16 characters. A key you set there wins over the one your deployment issued itself. -3. Open [app.muxel.site](https://app.muxel.site), paste your deployment's +3. Open [thankywal.github.io/muxel](https://thankywal.github.io/muxel/), paste your deployment's address, and enter the new key. **Changing the key takes the old one back.** Signing in hands your browser a @@ -199,7 +199,7 @@ what you get is the same Worker the button installs. A test holds the two to each other. When it finishes it prints your address and what to do with it: open -[app.muxel.site](https://app.muxel.site), paste that address in and enter your +[thankywal.github.io/muxel](https://thankywal.github.io/muxel/), paste that address in and enter your console key — which is printed on your deployment's own address — or, if you set up a bot instead, open it in Telegram and send `/start`. diff --git a/docs/TELEGRAM-SETUP.md b/docs/TELEGRAM-SETUP.md index ee5053e..5d1c8ff 100644 --- a/docs/TELEGRAM-SETUP.md +++ b/docs/TELEGRAM-SETUP.md @@ -52,7 +52,7 @@ When it finishes, open the page Cloudflare shows you. If it says your code copy That page also shows your console key, a long random string your deployment made for itself. Copy it and keep it safe: the page stops showing it once you have signed in. -Copy the address of that page too. Then open https://app.muxel.site, paste the address in, and paste the key. That is your console. +Copy the address of that page too. Then open https://thankywal.github.io/muxel/, paste the address in, and paste the key. That is your console. It will ask you to add a business. Give it the name of your shop. @@ -99,7 +99,7 @@ Form မှာ: အဲဒီ စာမျက်နှာမှာ သင့် console key ကိုပါ ပြပါလိမ့်မယ်။ deployment ကိုယ်တိုင် ဆောက်ထားတဲ့ ကျပန်း စာလုံးရှည်တစ်ခုပါ။ ကူးယူပြီး သေချာသိမ်းထားပါ။ တစ်ခါ ဝင်ပြီးရင် အဲဒီစာမျက်နှာက ပြတော့မှာ မဟုတ်ပါဘူး။ -စာမျက်နှာရဲ့ လိပ်စာကိုလည်း ကူးယူပါ။ ပြီးရင် https://app.muxel.site ကို ဖွင့်ပြီး လိပ်စာနဲ့ key ကို ကူးထည့်ပါ။ အဲဒါ သင့် console ပါ။ +စာမျက်နှာရဲ့ လိပ်စာကိုလည်း ကူးယူပါ။ ပြီးရင် https://thankywal.github.io/muxel/ ကို ဖွင့်ပြီး လိပ်စာနဲ့ key ကို ကူးထည့်ပါ။ အဲဒါ သင့် console ပါ။ Business တစ်ခု ထည့်ဖို့ တောင်းပါလိမ့်မယ်။ သင့်ဆိုင်နာမည်ကို ပေးလိုက်ပါ။ @@ -146,7 +146,7 @@ Cloudflare จะให้คุณเข้าสู่ระบบ เชื หน้านั้นจะแสดงรหัสคอนโซลของคุณด้วย เป็นสตริงสุ่มยาว ๆ ที่ deployment สร้างขึ้นเอง คัดลอกไว้และเก็บให้ดี เพราะหน้านั้นจะหยุดแสดงเมื่อคุณเข้าสู่ระบบครั้งแรกแล้ว -คัดลอกที่อยู่ของหน้านั้นไว้ด้วย แล้วเปิด https://app.muxel.site วางที่อยู่ลงไป และวางรหัสนั้น นั่นคือคอนโซลของคุณ +คัดลอกที่อยู่ของหน้านั้นไว้ด้วย แล้วเปิด https://thankywal.github.io/muxel/ วางที่อยู่ลงไป และวางรหัสนั้น นั่นคือคอนโซลของคุณ ระบบจะให้คุณเพิ่มธุรกิจ ใส่ชื่อร้านของคุณลงไป @@ -193,7 +193,7 @@ Cloudflare 会让你登录、连接 GitHub,并安装它的 GitHub 应用。请 那个页面上还会显示你的控制台密钥,是你的部署自己生成的一串长随机字符。复制下来好好保存:你第一次登录之后,那个页面就不再显示它了。 -把那个页面的地址也复制下来。然后打开 https://app.muxel.site,把地址粘贴进去,再把密钥粘贴进去。那就是你的控制台。 +把那个页面的地址也复制下来。然后打开 https://thankywal.github.io/muxel/,把地址粘贴进去,再把密钥粘贴进去。那就是你的控制台。 它会让你添加一个商家,填你的店名就行。 @@ -240,7 +240,7 @@ Cloudflare がログイン、GitHub の連携、そして GitHub アプリのイ そのページには、deployment が自分で作った長いランダムなコンソールキーも表示されます。コピーして大切に保管してください。一度サインインすると、そのページはもう表示しなくなります。 -そのページのアドレスもコピーします。次に https://app.muxel.site を開き、アドレスとキーを貼り付けてください。それがあなたのコンソールです。 +そのページのアドレスもコピーします。次に https://thankywal.github.io/muxel/ を開き、アドレスとキーを貼り付けてください。それがあなたのコンソールです。 ビジネスの追加を求められます。お店の名前を入れてください。 diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 44b1343..9b1fe3b 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -20,7 +20,7 @@ import { randomBytes } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { CONSOLE_KEY_MIN_LENGTH, MuxelError } from "@muxel/core"; +import { CONSOLE_HOME, CONSOLE_KEY_MIN_LENGTH, MuxelError } from "@muxel/core"; import { emit, progress, table } from "../output.js"; import { identity, requireWrangler, runWrangler } from "../wrangler.js"; @@ -295,8 +295,8 @@ function nextSteps(doors: ConsoleDoors, workerUrl: string | null): string[] { if (doors.consoleKey !== null) { steps.push( workerUrl === null - ? "Deploy, then open app.muxel.site, paste the deployment's address and enter your console key." - : `Open app.muxel.site, paste ${workerUrl}, and enter your console key.`, + ? `Deploy, then open ${CONSOLE_HOME}, paste the deployment's address and enter your console key.` + : `Open ${CONSOLE_HOME}, paste ${workerUrl}, and enter your console key.`, ); } else { // No key was given, so the deployment made one. It is on the setup page and @@ -305,9 +305,9 @@ function nextSteps(doors: ConsoleDoors, workerUrl: string | null): string[] { steps.push( workerUrl === null ? "Open the deployment's address in a browser. It shows the console key it made for " - + "itself; take it to app.muxel.site with the address." + + `itself; take it to ${CONSOLE_HOME} with the address.` : `Open ${workerUrl} in a browser. It shows the console key it made for itself; take it ` - + `to app.muxel.site with ${workerUrl}.`, + + `to ${CONSOLE_HOME} with ${workerUrl}.`, ); } diff --git a/packages/console/README.md b/packages/console/README.md index 8e65b3b..bfaf220 100644 --- a/packages/console/README.md +++ b/packages/console/README.md @@ -35,4 +35,4 @@ range. own deployment, which is the normal path. Two faces, chosen by hostname: the product page for `muxel.site`, the console for -`app.muxel.site`. Any other host gets the product page. +`thankywal.github.io/muxel`. Any other host gets the product page. diff --git a/packages/console/guide.mjs b/packages/console/guide.mjs index fdb71d0..e32168e 100644 --- a/packages/console/guide.mjs +++ b/packages/console/guide.mjs @@ -175,7 +175,7 @@ export function renderGuide({ markdown, key = "en" }) {
Muxel - ConsoleGitHub + ConsoleGitHub
diff --git a/packages/console/public/app.js b/packages/console/public/app.js index 51bc6ff..1879994 100644 --- a/packages/console/public/app.js +++ b/packages/console/public/app.js @@ -443,6 +443,7 @@ function shell() { GitHub Docs + What Muxel is ` } diff --git a/packages/console/public/index.html b/packages/console/public/index.html index 5808353..d3f6624 100644 --- a/packages/console/public/index.html +++ b/packages/console/public/index.html @@ -51,7 +51,7 @@
Muxel
@@ -71,7 +71,7 @@

We cannot show you
a list of our users.
We do not have one. @@ -181,7 +181,7 @@

Bring your own cloud

diff --git a/packages/core/src/console-home.ts b/packages/core/src/console-home.ts new file mode 100644 index 0000000..24a9db4 --- /dev/null +++ b/packages/core/src/console-home.ts @@ -0,0 +1,27 @@ +/** + * Where the console is published. + * + * The console is a page, not a service: it holds no data, keeps no session and + * talks to nothing but the owner's own Worker. So the address it is published + * at is a convenience, not a dependency — an owner can serve the same files + * themselves and lose nothing — but it is the address every document, every + * setup page and every command line message sends a new owner to, and those + * have to agree. + * + * It lives here rather than in the runtime because the programs that name it + * cannot all import each other: the Worker puts it on its own first screen, + * the command line puts it in what it prints after a deploy, and the console + * itself links back to it. Two copies of an address are two addresses, and the + * one that drifted would be the one an owner met first. + * + * The plain files this repository also ships — the READMEs, the deploy + * scripts, the console's own HTML — cannot import anything. A test holds them + * to this value instead. + */ +export const CONSOLE_HOME = "thankywal.github.io/muxel"; + +/** The same address as a link. */ +export const CONSOLE_URL = `https://${CONSOLE_HOME}/`; + +/** The guide, which is the README rendered, published beside the console. */ +export const GUIDE_URL = `${CONSOLE_URL}docs/`; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d66680..de10db7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,5 +2,6 @@ export * from "./errors.js"; export * from "./ids.js"; export * from "./callback.js"; export * from "./chunk.js"; +export * from "./console-home.js"; export * from "./console-key.js"; export * from "./types.js"; diff --git a/packages/runtime/src/assistant/loop.ts b/packages/runtime/src/assistant/loop.ts index 6de09e2..b094edd 100644 --- a/packages/runtime/src/assistant/loop.ts +++ b/packages/runtime/src/assistant/loop.ts @@ -10,6 +10,7 @@ * tool added later cannot arrive without one. See assistant/tools.ts. */ +import { CONSOLE_HOME } from "@muxel/core"; import { converse, type ChatMessage } from "../ai/gateway.js"; import type { Env } from "../env.js"; import { listBusinesses } from "../db/queries.js"; @@ -49,7 +50,7 @@ function aboutMuxel(): string { "Muxel answers an owner's customers on Telegram and through a chat widget on their website. It", "runs entirely inside the owner's own Cloudflare account — Workers, D1, KV and Vectorize — which", "is why there is no Muxel account to sign up for and no server of ours between a customer and", - "their data. The console at app.muxel.site is a page of files; it talks to the owner's own", + `their data. The console at ${CONSOLE_HOME} is a page of files; it talks to the owner's own`, "deployment directly from their browser.", "", "A business is one agent: one set of material, one voice, and the channels it answers on. It", diff --git a/packages/runtime/src/setup.ts b/packages/runtime/src/setup.ts index 95be659..3fb6a9b 100644 --- a/packages/runtime/src/setup.ts +++ b/packages/runtime/src/setup.ts @@ -25,7 +25,7 @@ * to a custom domain. */ -import { generateId, generateShortId, MuxelError } from "@muxel/core"; +import { CONSOLE_HOME, generateId, generateShortId, MuxelError } from "@muxel/core"; import { consoleClaimed, ensureConsoleKey } from "./console-key.js"; import { open, seal, sha256Hex } from "./crypto.js"; @@ -462,7 +462,7 @@ function renderKeyCard(outcome: SetupOutcome): string {

Your console key

${escapeHtml(key)}

-

Open app.muxel.site, paste the address of this page, +

Open ${CONSOLE_HOME}, paste the address of this page, and paste that key. Nothing else is needed and nothing else was asked of you: your deployment made this key itself.

Keep it where you keep passwords. It is shown here until the first @@ -494,7 +494,7 @@ export function renderSetupPage(outcome: SetupOutcome): string { ${renderKeyCard(outcome)} ${ outcome.issuedKey === undefined - ? `

Open app.muxel.site, paste the address of this page, and + ? `

Open ${CONSOLE_HOME}, paste the address of this page, and enter your console key. That is your private control panel: add a business there and it will ask for the bot your customers will write to.

` : "" diff --git a/packages/runtime/src/telegram/admin.ts b/packages/runtime/src/telegram/admin.ts index d4755a5..d4f8932 100644 --- a/packages/runtime/src/telegram/admin.ts +++ b/packages/runtime/src/telegram/admin.ts @@ -13,6 +13,7 @@ import { callbackRefKey, + CONSOLE_HOME, decodeCallback, generateId, generateShortId, @@ -1017,7 +1018,7 @@ export async function screenFor( // Telegram's word, who is asking. const code = await issuePairingCode(env, userId); return { - text: `${t(locale, "webConTitle")}\n\n${t(locale, "webConBody")}\n\n${code}\n\n${t(locale, "webConExpiry")}`, + text: `${t(locale, "webConTitle")}\n\n${t(locale, "webConBody", { console: CONSOLE_HOME })}\n\n${code}\n\n${t(locale, "webConExpiry")}`, rows: [ row({ text: t(locale, "btnWebConNew"), action: "webcon" }), row({ text: t(locale, "back"), action: "home" }), diff --git a/packages/runtime/src/telegram/i18n.ts b/packages/runtime/src/telegram/i18n.ts index 196ad07..26d5ace 100644 --- a/packages/runtime/src/telegram/i18n.ts +++ b/packages/runtime/src/telegram/i18n.ts @@ -126,11 +126,13 @@ export const STRINGS = { zh: "在电脑上打开控制台", my: "ကွန်ပျူတာမှာ console ဖွင့်ရန်", }, + // The address is a variable, not four copies of a string: it is decided in + // one place and this table is the last thing that should have an opinion. webConBody: { - en: "Go to app.muxel.site, paste your deployment address, then type this code.", - th: "ไปที่ app.muxel.site ใส่ที่อยู่ระบบของคุณ แล้วพิมพ์รหัสนี้", - zh: "打开 app.muxel.site,填入你的部署地址,然后输入此代码。", - my: "app.muxel.site ကို ဖွင့်ပြီး သင့် deployment လိပ်စာ ထည့်၊ ပြီးရင် ဤကုဒ်ကို ရိုက်ထည့်ပါ။", + en: "Go to {console}, paste your deployment address, then type this code.", + th: "ไปที่ {console} ใส่ที่อยู่ระบบของคุณ แล้วพิมพ์รหัสนี้", + zh: "打开 {console},填入你的部署地址,然后输入此代码。", + my: "{console} ကို ဖွင့်ပြီး သင့် deployment လိပ်စာ ထည့်၊ ပြီးရင် ဤကုဒ်ကို ရိုက်ထည့်ပါ။", }, webConExpiry: { en: "It works once, for ten minutes.", diff --git a/packages/runtime/test/the-console-key-is-a-door.test.ts b/packages/runtime/test/the-console-key-is-a-door.test.ts index d37b13d..8f2f97d 100644 --- a/packages/runtime/test/the-console-key-is-a-door.test.ts +++ b/packages/runtime/test/the-console-key-is-a-door.test.ts @@ -14,6 +14,8 @@ */ import { describe, expect, it, vi } from "vitest"; +import { CONSOLE_HOME } from "@muxel/core"; + const seen = vi.hoisted(() => ({ operators: [] as number[], wrote: [] as string[], @@ -170,7 +172,7 @@ describe("a deployment with a key and no Telegram", () => { const page = renderSetupPage(await setUp({ CONSOLE_KEY: KEY })); expect(page).toContain("Your console is connected"); expect(page).not.toContain("Not ready yet"); - expect(page).toContain("app.muxel.site"); + expect(page).toContain(CONSOLE_HOME); expect(page).toContain("Telegram is optional"); }); }); @@ -240,7 +242,7 @@ describe("a deployment nobody has been asked anything for", () => { const outcome = await setUp({}); const page = renderSetupPage(outcome); expect(page).toContain(outcome.issuedKey as string); - expect(page).toContain("app.muxel.site"); + expect(page).toContain(CONSOLE_HOME); expect(page).toContain("passwords"); }); @@ -297,7 +299,7 @@ describe("a deployment with both doors", () => { expect(seen.operators).toEqual([42, WEB_OWNER_ID]); const page = renderSetupPage(outcome); expect(page).toContain("@my_console_bot"); - expect(page).toContain("app.muxel.site"); + expect(page).toContain(CONSOLE_HOME); }); }); diff --git a/packages/runtime/test/the-docs-are-the-readme.test.ts b/packages/runtime/test/the-docs-are-the-readme.test.ts index 8f0d646..678b57c 100644 --- a/packages/runtime/test/the-docs-are-the-readme.test.ts +++ b/packages/runtime/test/the-docs-are-the-readme.test.ts @@ -4,7 +4,7 @@ * The console's footer said "Docs" and sent people to the product page, which * has no docs on it. There is a guide — the README, in five languages, held to * the deploy form by other tests — and it was only on GitHub. Rather than a - * second guide that would drift from the first, app.muxel.site/docs renders + * second guide that would drift from the first, the site's /docs renders * the first. These hold the render to the README, and the links to the render. */ import { describe, expect, it } from "vitest"; @@ -97,7 +97,7 @@ describe("the render of the README", () => { expect(rewriteHref("LICENSE")).toBe("https://github.com/thankywal/muxel/blob/main/LICENSE"); expect(rewriteHref("SECURITY.md")).toBe("https://github.com/thankywal/muxel/blob/main/SECURITY.md"); // Absolute and in-page links are not touched. - expect(rewriteHref("https://app.muxel.site")).toBe("https://app.muxel.site"); + expect(rewriteHref("https://example.com")).toBe("https://example.com"); expect(rewriteHref("#the-console")).toBe("#the-console"); for (const target of ["/docs/my", "/docs/th", "/docs/ja", "/docs/zh", "/docs/deploy-recovery", "/docs/telegram-setup"]) { expect(html).toContain(`href="${target}"`); diff --git a/packages/runtime/test/the-site-is-a-build.test.ts b/packages/runtime/test/the-site-is-a-build.test.ts index 7f0cb3b..c23537f 100644 --- a/packages/runtime/test/the-site-is-a-build.test.ts +++ b/packages/runtime/test/the-site-is-a-build.test.ts @@ -13,6 +13,8 @@ */ import { afterAll, describe, expect, it } from "vitest"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; + +import { CONSOLE_HOME, CONSOLE_URL } from "@muxel/core"; import { tmpdir } from "node:os"; import { join } from "node:path"; // @ts-expect-error the build is plain JavaScript, typed by nothing. @@ -53,18 +55,28 @@ describe("where the site thinks it lives", () => { expect(withBase('href="/docs"', "/", roots)).toBe('href="/docs"'); }); + it("moves a link to the top of the site, which has no name to recognise it by", () => { + const roots = new Set(["docs"]); + expect(withBase('href="/"', BASE, roots)).toBe('href="/muxel/"'); + // Only in an attribute. A lone slash in code is every split and every join. + expect(withBase('parts.join("/")', BASE, roots)).toBe('parts.join("/")'); + }); + it("finds a URL that was left at the root", () => { const roots = new Set(["notice.json"]); expect(missedRoots('fetch("/notice.json")', roots)).toEqual(["notice.json"]); expect(missedRoots('fetch("/muxel/notice.json")', roots)).toEqual([]); + expect(missedRoots('href="/"', roots)).toEqual(['href="/"']); }); }); describe("what the build writes", () => { it("has the product page, the console and the guide in every language", () => { const expected = [ + // The console is the site: it used to be chosen by hostname, and the + // domain that chose it is going to another project. "index.html", - "console/index.html", + "product/index.html", "docs/index.html", ...Object.keys(LANGS as Record) .filter((key) => key !== "en") @@ -86,7 +98,8 @@ describe("what the build writes", () => { }); it("answers an unknown path with the page that says what this is", () => { - expect(file("404.html")).toBe(file("index.html")); + // Not the console, which would only ask a lost stranger for an address. + expect(file("404.html")).toBe(file("product/index.html")); }); it("keeps Jekyll from taking a turn at it", () => { @@ -114,15 +127,18 @@ describe("every link the site makes to itself", () => { }); it("sends the product page to the console and the guide on this site", () => { - const index = file("index.html"); - expect(index).toContain('href="/muxel/console/"'); - expect(index).toContain('href="/muxel/docs"'); + const product = file("product/index.html"); + expect(product).toContain('href="/muxel/"'); + expect(product).toContain('href="/muxel/docs"'); // The console used to be a hostname away, on a machine of ours. - expect(index).not.toContain("app.muxel.site"); + expect(product).not.toContain("app.muxel.site"); }); - it("sends the console's footer to the guide", () => { - expect(file("app.js")).toMatch(/href="\/muxel\/docs"[^>]*>Docs { + const app = file("app.js"); + expect(app).toMatch(/href="\/muxel\/docs"[^>]*>Docs { @@ -130,7 +146,7 @@ describe("every link the site makes to itself", () => { // The header only: the README's own prose is rendered below it verbatim, // and what it says is the README's business, not this page's. const header = guide.slice(guide.indexOf("
"), guide.indexOf("
")); - expect(header).toContain('href="/muxel/console/"'); + expect(header).toContain('href="/muxel/"'); expect(header).toContain("https://github.com/thankywal/muxel"); expect(header).not.toContain("app.muxel.site"); }); @@ -150,3 +166,59 @@ describe("the guide is the README", () => { ); }); }); + +/** + * The address every document sends a new owner to. + * + * It was app.muxel.site, typed out in seventeen places and in five languages, + * and the domain it belongs to is going to another project. The programs that + * name it now read it from one record. These hold the files that cannot import + * anything — the READMEs, the deploy scripts, the console's own HTML — to the + * same value, because a document that sends an owner somewhere empty is worse + * than one that says nothing. + */ +describe("the address the console is published at", () => { + const repo = (name: string): string => + readFileSync(new URL(`../../../${name}`, import.meta.url), "utf8"); + + const DOCUMENTS = [ + "README.md", + "README.my.md", + "README.th.md", + "README.ja.md", + "README.zh.md", + "docs/DEPLOY-RECOVERY.md", + "docs/TELEGRAM-SETUP.md", + "packages/console/README.md", + "scripts/install.mjs", + "scripts/deploy.mjs", + ]; + + it("is one value, and it is where the site is published", () => { + expect(CONSOLE_URL).toBe(`https://${CONSOLE_HOME}/`); + expect(CONSOLE_HOME).toBe("thankywal.github.io/muxel"); + }); + + it("is what every file that cannot import it says", () => { + for (const name of DOCUMENTS) { + const text = repo(name); + expect(text, `${name} does not name the console`).toContain(CONSOLE_HOME); + expect(text, `${name} still sends people to the old address`).not.toContain("app.muxel.site"); + } + }); + + it("is what the deployment's own first screen says, from the record", () => { + // Read off the source rather than the rendered page: the point is that the + // page has no address of its own to drift. + const setup = repo("packages/runtime/src/setup.ts"); + expect(setup).toContain("${CONSOLE_HOME}"); + expect(setup).not.toContain("app.muxel.site"); + }); + + it("is a variable in the translations, not four copies of a string", () => { + const i18n = repo("packages/runtime/src/telegram/i18n.ts"); + expect(i18n).toContain("{console}"); + expect(i18n).not.toContain("app.muxel.site"); + expect(repo("packages/runtime/src/telegram/admin.ts")).toContain("{ console: CONSOLE_HOME }"); + }); +}); diff --git a/packages/runtime/test/the-terminal-deploy-asks-for-one-thing.test.ts b/packages/runtime/test/the-terminal-deploy-asks-for-one-thing.test.ts index 15a1721..10a5b79 100644 --- a/packages/runtime/test/the-terminal-deploy-asks-for-one-thing.test.ts +++ b/packages/runtime/test/the-terminal-deploy-asks-for-one-thing.test.ts @@ -15,7 +15,7 @@ */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; -import { isMuxelError } from "@muxel/core"; +import { CONSOLE_HOME, isMuxelError } from "@muxel/core"; import { consoleDoors, secretsFor, type ConsoleDoors } from "../../cli/src/commands/init.js"; import { CONSOLE_KEY_MIN_LENGTH } from "../src/env.js"; @@ -165,7 +165,7 @@ describe("what the recovery document tells somebody who is locked out", () => { it("answers with the setting they can change, not an install they must redo", () => { expect(lockedOut()).toContain("CONSOLE_KEY"); - expect(lockedOut()).toContain("app.muxel.site"); + expect(lockedOut()).toContain(CONSOLE_HOME); // The length the Worker enforces, read off the Worker rather than typed // here, so the document cannot drift away from the rule it describes. expect(lockedOut()).toContain(String(CONSOLE_KEY_MIN_LENGTH)); diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index debd892..ef452b1 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -47,6 +47,15 @@ export function normalizeBase(value) { */ const URL_START = /(["'`]|url\()\/([A-Za-z0-9._-]+)/g; +/** + * The site's own root, which has no path segment to recognise it by. + * + * `"/"` on its own is far too common in code to rewrite on sight — it is every + * split and every join — so only an attribute counts, where it cannot be + * anything but a link to the top of this site. + */ +const ROOT_LINK = /\b(href|src)="\/"/g; + /** * Rewrites the site's own root-absolute URLs onto the base path. * @@ -54,9 +63,9 @@ const URL_START = /(["'`]|url\()\/([A-Za-z0-9._-]+)/g; * else beginning with a slash is somebody else's URL and is left alone. */ export function withBase(text, base, roots) { - return text.replace(URL_START, (whole, open, first) => - roots.has(first) ? `${open}${base}${first}` : whole, - ); + return text + .replace(URL_START, (whole, open, first) => (roots.has(first) ? `${open}${base}${first}` : whole)) + .replace(ROOT_LINK, (_whole, attribute) => `${attribute}="${base}"`); } /** Every root-absolute reference to one of the site's own names, unrewritten. */ @@ -65,6 +74,7 @@ export function missedRoots(text, roots) { for (const [, , first] of text.matchAll(URL_START)) { if (roots.has(first)) missed.add(first); } + for (const [, attribute] of text.matchAll(ROOT_LINK)) missed.add(`${attribute}="/"`); return [...missed]; } @@ -90,10 +100,13 @@ export async function buildSite({ base = "/muxel/", out = path.join(ROOT, "site" // The README's pictures, where the rendered README looks for them. await cp(path.join(ROOT, "docs/media"), path.join(OUT, "docs/media"), { recursive: true }); + // The console is the site. It used to be chosen by hostname — app.muxel.site + // was the console and muxel.site was the product page — and that domain is + // going to another project, so the address an owner is sent to is this one. /** The pages this build writes, as path → HTML. */ const pages = new Map(); - pages.set("index.html", await readFile(path.join(publicDir, "index.html"), "utf8")); - pages.set("console/index.html", await readFile(path.join(publicDir, "console.html"), "utf8")); + pages.set("index.html", await readFile(path.join(publicDir, "console.html"), "utf8")); + pages.set("product/index.html", await readFile(path.join(publicDir, "index.html"), "utf8")); for (const key of [...Object.keys(LANGS), ...Object.keys(PAGES)]) { const file = fileFor(key); const from = file.startsWith("README") ? file : path.join("docs", file); @@ -102,8 +115,9 @@ export async function buildSite({ base = "/muxel/", out = path.join(ROOT, "site" pages.set(where, renderGuide({ markdown, key }).html); } // A static host answers an unknown path with this. The product page is the - // honest answer: it says what this is and links to everything else. - pages.set("404.html", pages.get("index.html")); + // honest answer: it says what this is and links to everything else, which is + // more use to somebody who mistyped than a console asking for an address. + pages.set("404.html", pages.get("product/index.html")); // What may be rewritten: the first path segment of everything emitted. const roots = new Set([ diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs index f09abe4..f9be03a 100644 --- a/scripts/deploy.mjs +++ b/scripts/deploy.mjs @@ -133,7 +133,7 @@ async function attemptSetup(target) { done: true, note: bot === undefined - ? "Setup complete. Open app.muxel.site, paste this address, and enter your console key." + ? "Setup complete. Open thankywal.github.io/muxel, paste this address, and enter your console key." : `Setup complete. Open @${bot} in Telegram and send /start.`, }; } diff --git a/scripts/install.mjs b/scripts/install.mjs index 346736c..9dae05c 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -295,7 +295,7 @@ if (health.status === 200) { await get("/setup"); console.log(`Muxel is running at ${url}`); if (consoleKey) { - console.log(`Open app.muxel.site, paste ${url}, and enter your console key.`); + console.log(`Open thankywal.github.io/muxel, paste ${url}, and enter your console key.`); } if (adminBotToken && ownerTelegramId) { console.log("Open your console bot in Telegram and send /start."); @@ -317,7 +317,7 @@ if (health.status === 200) { "", "Open the address above in a browser. A deployment issues itself a console", "key on its first run and that page prints it, until the first time somebody", - "signs in; take it to app.muxel.site along with the address. To choose your", + "signs in; take it to thankywal.github.io/muxel along with the address. To choose your", "own key instead, set CONSOLE_KEY in the dashboard under Settings, Variables", "and Secrets, at least 16 characters.", "",