@jaato/web-coder-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # jaato-web-coder-ui
2
+
3
+ Browser client for a jaato daemon — the web counterpart of `jaato-tui`.
4
+ It connects to `python -m server --web-socket …` over the daemon's
5
+ WebSocket transport through the TypeScript SDK (`@jaato/sdk`, in
6
+ `../jaato-sdk-ts`), and renders the same session a TUI can be attached to.
7
+
8
+ Design notes, stack rationale and the input model live in
9
+ [`docs/web-client-design.md`](../docs/web-client-design.md).
10
+
11
+ ## Stack
12
+
13
+ | Layer | Choice | Why |
14
+ |---|---|---|
15
+ | UI | React 19 + Vite 7 | largest widget ecosystem for the pieces a client like this needs (virtualised lists, docking, terminals) |
16
+ | State | Zustand, one event-sourced reducer | the protocol is a stream of typed events; the store folds them, components subscribe to slices |
17
+ | Protocol | `@jaato/sdk` (workspace `file:` dep, source-aliased) | codegen'd event types stay in lockstep with `events.py`; no hand-written event mirror |
18
+ | Styling | Tailwind v4 + CSS variables | the eleven base colours of the TUI's `themes/*.json` become CSS custom properties, so both clients share one palette definition |
19
+ | Rendering | own `<j-*>` + markdown parsers, no `innerHTML` | the server emits neutral markup (`<j-code>`, `<j-table>`, Pygments token classes); the client only maps classes to theme colours |
20
+ | Tests | Vitest (protocol, store) + Playwright (UI against a scripted mock daemon) | |
21
+
22
+ ## Commands are words, not `/verbs`
23
+
24
+ jaato commands are typed as bare words: `model gpt-4o`, `tools enable cli`,
25
+ `permissions status`. The composer proposes matching commands while the
26
+ first word is typed; **Esc on the proposal means "I mean this word
27
+ verbatim"** and the line is sent to the model as text even though it starts
28
+ with a command word. A hint under the box always states what Enter will do.
29
+ `@path`, `@@path`, `%prompt` and `/name` (workspace commands under
30
+ `.jaato/commands/`) pass through untouched and disable command proposals,
31
+ as in the TUI. See `src/protocol/commands.ts` for the routing rules, which
32
+ are a port of `jaato-tui/client_commands.py`.
33
+
34
+ ## Run it
35
+
36
+ The client is published on npm as `@jaato/web-coder-ui`; the package is the built
37
+ bundle plus a dependency-free launcher, so `npx` fetches it in one go:
38
+
39
+ ```bash
40
+ .venv/bin/python -m server --web-socket :8080 --daemon # the daemon, on this machine
41
+ npx @jaato/web-coder-ui # serves the client, opens the browser
42
+ ```
43
+
44
+ The launcher serves `dist/` on `http://127.0.0.1:5180/`, hands the page the
45
+ daemon URL and — for a local daemon — the token from `~/.jaato/ws.token`, and
46
+ the page connects on its own. Against a daemon elsewhere:
47
+
48
+ ```bash
49
+ npx @jaato/web-coder-ui --daemon ws://build-box:8080 --token-file ./ws.token
50
+ npx @jaato/web-coder-ui --daemon wss://jaato.example.org --no-token # type the token in the form
51
+ ```
52
+
53
+ | Flag | Default | Meaning |
54
+ |---|---|---|
55
+ | `--daemon URL` | `ws://127.0.0.1:8080` | the daemon's `--web-socket` address |
56
+ | `--token TOKEN` / `--token-file PATH` | `~/.jaato/ws.token` when the daemon is loopback and the file exists | bearer token handed to the page |
57
+ | `--no-token` | | hand out no token; the form asks for it (or leave it empty for `--ws-unsafe-no-auth`) |
58
+ | `--host HOST` / `--port PORT` | `127.0.0.1` / `5180` (`0` = any free port) | where the client is served |
59
+ | `--no-open` | | do not open a browser |
60
+ | `--root DIR` | the package's `dist/` | serve another build |
61
+
62
+ The token is only ever handed to the page on a loopback bind: with
63
+ `--host 0.0.0.0` the launcher serves the client to the network and the
64
+ person at the browser types the token. On loopback the `Host` header must
65
+ name the bound address, so a DNS-rebinding page cannot fetch it either.
66
+
67
+ ### Hosting the bundle yourself
68
+
69
+ `dist/` is static (assets are referenced relatively, so it can sit under any
70
+ path) and connects to whatever daemon URL the form is given. Put it behind
71
+ nginx, a CDN or a file server, and optionally publish a `config.json` next to
72
+ `index.html` to pre-fill the form:
73
+
74
+ ```json
75
+ {"daemon": "wss://jaato.example.org", "autoConnect": true}
76
+ ```
77
+
78
+ `token` is also accepted there, but a token in a file every visitor can read
79
+ is only right when the file server is as private as the daemon. Every
80
+ publish run of the npm workflow attaches `jaato-web-coder-ui-dist-<version>.tar.gz`
81
+ (the contents of `dist/`) to the GitHub Release `web-coder-ui-v<version>` for
82
+ this use; `npm run build` produces the same directory from a checkout.
83
+
84
+ ### Knowing what you deployed
85
+
86
+ The SDK is compiled into the bundle, so the build stamps what it speaks:
87
+ `dist/build-info.json` carries the UI version, the `@jaato/sdk` revision,
88
+ its protocol floor and the commit. The connect screen and the status bar
89
+ show the same line, and the launcher prints it:
90
+
91
+ ```bash
92
+ npx @jaato/web-coder-ui --version
93
+ # 0.1.0
94
+ # @jaato/sdk 0.6.0 · protocol ≥ 1.0 · cb65e5e · built 2026-09-15T20:40:00.000Z
95
+ ```
96
+
97
+ ## Develop
98
+
99
+ ```bash
100
+ # once: build the SDK declarations the type-check uses
101
+ npm --prefix ../jaato-sdk-ts install && npm --prefix ../jaato-sdk-ts run build
102
+
103
+ npm install
104
+ npm run dev # http://localhost:5173, proxies /ws → ws://127.0.0.1:8080
105
+ ```
106
+
107
+ Against a real daemon:
108
+
109
+ ```bash
110
+ .venv/bin/python -m server --web-socket :8080 --workspace-root /srv/workspaces # or without --workspace-root
111
+ cat ~/.jaato/ws.token # paste into the connect form
112
+ ```
113
+
114
+ Against the scripted mock (no provider needed):
115
+
116
+ ```bash
117
+ npm run mock-daemon # ws://127.0.0.1:8090 — try: code, tool, permit, ask, fail, subagent
118
+ ```
119
+
120
+ Connect the form to `ws://127.0.0.1:8090`. `MOCK_WORKSPACES=1` turns on the
121
+ workspace-first flow, `MOCK_TOKEN=x` enforces bearer auth.
122
+
123
+ ## Verify
124
+
125
+ ```bash
126
+ npm run typecheck # builds the SDK, then tsc
127
+ npm test # vitest (protocol, store) + node --test (launcher)
128
+ npm run build # production bundle in dist/
129
+ npm start -- --no-open # the launcher, serving that dist/
130
+ npm run e2e # Playwright, starts the mock daemon and Vite itself
131
+ npm pack # the npm tarball (refuses without a dist/)
132
+ ```
133
+
134
+ Publishing is manual: the *Publish @jaato/web-coder-ui to npm* workflow
135
+ (`.github/workflows/publish-npm-web-coder-ui.yml`) runs the same gates as CI,
136
+ refuses a version already on the registry, builds, and **stages** the version
137
+ with the `@jaato` org's stage-only token; a maintainer with 2FA promotes it
138
+ (`npm stage list @jaato/web-coder-ui`, then `npm stage approve <stage-id>
139
+ --otp <code>`). It needs `@jaato/sdk` published first only at build time, from
140
+ the sibling checkout. Bump `version` in `package.json` first.
141
+
142
+ Set `PLAYWRIGHT_CHROMIUM=/path/to/chrome` to use a pre-installed browser
143
+ instead of Playwright's download.
144
+
145
+ ## Layout
146
+
147
+ ```
148
+ bin/ jaato-web-coder-ui.js — the launcher shipped as the package's `bin` (static server + config.json + browser open), tested with node --test
149
+ src/
150
+ app/ actions.ts (what a submitted line does), launcherConfig.ts (the page's side of config.json)
151
+ protocol/ commands.ts (routing + completion), jmarkup.ts, markdown.ts, pygments.ts — pure, unit-tested
152
+ store/ types.ts, store.ts (reduce(state, event)) — event-sourced client state
153
+ sdk/ connection.ts — JaatoClient lifecycle, frame-batched dispatch, workspace verbs
154
+ app/ actions.ts — what a submitted line does (prompt answers, client/server commands, messages)
155
+ components/ output/ (JMarkup, ToolBlockView, OutputPane, ToolOutputPopup, MediaView)
156
+ input/Composer.tsx prompts/ (Permission, Clarification, ReferenceSelection)
157
+ panels/ (Plan, Budget, Workspace, AgentTabs) layout/StatusBar.tsx
158
+ screens/ ConnectScreen, WorkspaceScreen, SessionScreen
159
+ theme/ themes.ts (imports ../jaato-tui/themes/*.json), theme.css
160
+ mock/daemon.ts scripted daemon speaking the wire protocol, for dev + e2e
161
+ e2e/ Playwright smoke suite
162
+ ```
163
+
164
+ ## Keys
165
+
166
+ `Enter` send · `Shift+Enter` newline · `Tab` complete / re-arm proposals ·
167
+ `Esc` dismiss proposal (send verbatim) · `Ctrl+P` plan · `Ctrl+B` budget ·
168
+ `Alt+W` files · `Ctrl+T` expand/collapse tools · `Ctrl+A` next agent ·
169
+ `Ctrl+O` next running tool in the popup · `Ctrl+C` (nothing selected) stop.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Type declarations for the package's `exports` entry (`bin/jaato-web-coder-ui.js`).
3
+ * Hand-written: the launcher is plain JavaScript so that `npx @jaato/web-coder-ui`
4
+ * installs nothing but the bundle, and this file is what lets a TypeScript host
5
+ * (`jaato-web-coder-server`) import it.
6
+ */
7
+ import type { IncomingMessage, Server, ServerResponse } from "node:http";
8
+
9
+ /** The bundle this package ships (`<package>/dist`). */
10
+ export declare const DIST_DIR: string;
11
+
12
+ /** What `GET /config.json` answers; see `src/app/launcherConfig.ts` for the page's reading of it. */
13
+ export interface StaticConfig {
14
+ daemon?: string;
15
+ token?: string;
16
+ ticketUrl?: string;
17
+ loginUrl?: string;
18
+ autoConnect?: boolean;
19
+ }
20
+
21
+ export interface StaticOptions {
22
+ /** Directory holding `index.html` and `assets/`. */
23
+ root: string;
24
+ config: StaticConfig;
25
+ /** `Host` header values accepted (lower-case `host:port`); `null` accepts any. */
26
+ allowedHosts: Set<string> | null;
27
+ }
28
+
29
+ export type StaticHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
30
+
31
+ export declare function createStaticHandler(opts: StaticOptions): StaticHandler;
32
+ export declare function createStaticServer(opts: StaticOptions): Server;
33
+
34
+ export interface LauncherOptions {
35
+ daemon: string;
36
+ token?: string;
37
+ tokenFile?: string;
38
+ noToken: boolean;
39
+ host: string;
40
+ port: number;
41
+ open: boolean;
42
+ root: string;
43
+ help: boolean;
44
+ version: boolean;
45
+ }
46
+ export declare function parseArgs(argv: string[]): LauncherOptions;
47
+ export declare function isLoopbackHost(host: string): boolean;
48
+ export declare function resolveToken(opts: Partial<LauncherOptions>, io?: { readFile?: (p: string) => string; exists?: (p: string) => boolean }): { token: string | undefined; source: string };
49
+
50
+ export interface BuildInfo { ui: string; sdk: string; protocolMin: string; commit: string; builtAt: string }
51
+ export declare function readBuildInfo(root?: string): BuildInfo | null;
52
+
53
+ export declare function main(opts: LauncherOptions): Promise<Server | null>;
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ``jaato-web-coder-ui`` — the launcher that ships in the ``@jaato/web-coder-ui`` package.
4
+ *
5
+ * The browser client is a static bundle (``dist/``, produced by
6
+ * ``vite build``); all this script does is serve that bundle from a
7
+ * local port, tell the page which daemon to talk to, and open a browser
8
+ * on it. It is deliberately dependency-free (Node's ``http`` and ``fs``
9
+ * only) so ``npx @jaato/web-coder-ui`` installs nothing but the bundle itself.
10
+ *
11
+ * npx @jaato/web-coder-ui # ws://127.0.0.1:8080, token from ~/.jaato/ws.token
12
+ * npx @jaato/web-coder-ui --daemon ws://build-box:8080 --token-file ./ws.token
13
+ * npx @jaato/web-coder-ui --port 9000 --no-open
14
+ *
15
+ * How the page learns about the daemon
16
+ * ------------------------------------
17
+ * The bundle knows nothing about this launcher; it fetches ``./config.json``
18
+ * from wherever it was served and, when that answers with
19
+ * ``{"daemon": ..., "token": ..., "autoConnect": true}``, pre-fills the
20
+ * connect form and connects. Anyone hosting ``dist/`` behind nginx or
21
+ * similar can publish the same file by hand, or none at all, in which
22
+ * case the form starts empty. See ``src/app/launcherConfig.ts``.
23
+ *
24
+ * Importing this module
25
+ * ---------------------
26
+ * It is also the package's ``exports`` entry: ``createStaticServer``,
27
+ * ``createStaticHandler``, ``parseArgs``, ``resolveToken``, ``readBuildInfo``
28
+ * and ``DIST_DIR`` are
29
+ * what a host that serves the bundle itself (``jaato-web-coder-server``)
30
+ * reuses, and importing has no side effects — the CLI runs only when this
31
+ * file is the entry point.
32
+ *
33
+ * Where the token goes
34
+ * --------------------
35
+ * The daemon's bearer token grants full control of the agent, so the
36
+ * launcher only ever hands it to the page on a **loopback** bind: with
37
+ * ``--host 0.0.0.0`` the token is left out of ``config.json`` and the
38
+ * person at the browser types it. Two more guards apply even on
39
+ * loopback: ``config.json`` is served as JSON with ``nosniff`` (a page on
40
+ * another origin cannot read it — a cross-origin ``fetch`` has no CORS
41
+ * grant and a ``<script src>`` of a JSON document does not execute), and
42
+ * every request must carry a ``Host`` header naming the address we bound,
43
+ * which closes the DNS-rebinding route to the same file.
44
+ */
45
+ import { createServer } from "node:http";
46
+ import { promises as fs, existsSync, readFileSync, realpathSync } from "node:fs";
47
+ import { homedir } from "node:os";
48
+ import { dirname, extname, join, resolve, sep } from "node:path";
49
+ import { fileURLToPath } from "node:url";
50
+ import { spawn } from "node:child_process";
51
+
52
+ const HERE = dirname(fileURLToPath(import.meta.url));
53
+ /** The bundle this package ships; importable by a host that serves it itself (jaato-web-coder-server). */
54
+ export const DIST_DIR = join(HERE, "..", "dist");
55
+ const DEFAULT_ROOT = DIST_DIR;
56
+ const DEFAULT_DAEMON = "ws://127.0.0.1:8080";
57
+ const DEFAULT_TOKEN_FILE = join(homedir(), ".jaato", "ws.token");
58
+
59
+ const MIME = {
60
+ ".html": "text/html; charset=utf-8",
61
+ ".js": "text/javascript; charset=utf-8",
62
+ ".mjs": "text/javascript; charset=utf-8",
63
+ ".css": "text/css; charset=utf-8",
64
+ ".json": "application/json; charset=utf-8",
65
+ ".map": "application/json; charset=utf-8",
66
+ ".svg": "image/svg+xml",
67
+ ".png": "image/png",
68
+ ".ico": "image/x-icon",
69
+ ".woff": "font/woff",
70
+ ".woff2": "font/woff2",
71
+ ".txt": "text/plain; charset=utf-8",
72
+ };
73
+
74
+ const USAGE = `Usage: jaato-web-coder-ui [options]
75
+
76
+ Serve the jaato browser client and open it against a daemon started with
77
+ \`python -m server --web-socket [HOST:]PORT\`.
78
+
79
+ Options:
80
+ --daemon URL WebSocket URL of the daemon (default ${DEFAULT_DAEMON})
81
+ --token TOKEN Bearer token to hand to the page
82
+ --token-file PATH Read the token from PATH (default ~/.jaato/ws.token when
83
+ the daemon URL is a loopback address and the file exists)
84
+ --no-token Do not pass any token; type it in the connect form
85
+ --host HOST Address to serve on (default 127.0.0.1). The token is
86
+ only handed out on a loopback bind.
87
+ --port PORT Port to serve on (default 5180; 0 picks a free port)
88
+ --no-open Do not open a browser
89
+ --root DIR Serve this directory instead of the bundled dist/
90
+ -h, --help Show this help
91
+ -v, --version Print the package version
92
+ `;
93
+
94
+ /** Parse ``argv`` into launcher options; throws on an unknown flag. */
95
+ export function parseArgs(argv) {
96
+ const opts = {
97
+ daemon: DEFAULT_DAEMON,
98
+ token: undefined,
99
+ tokenFile: undefined,
100
+ noToken: false,
101
+ host: "127.0.0.1",
102
+ port: 5180,
103
+ open: true,
104
+ root: DEFAULT_ROOT,
105
+ help: false,
106
+ version: false,
107
+ };
108
+ const takeValue = (i, flag) => {
109
+ const v = argv[i + 1];
110
+ if (v === undefined) throw new Error(`${flag} needs a value`);
111
+ return v;
112
+ };
113
+ for (let i = 0; i < argv.length; i++) {
114
+ const a = argv[i];
115
+ const eq = a.indexOf("=");
116
+ const flag = eq > 0 && a.startsWith("--") ? a.slice(0, eq) : a;
117
+ const inline = eq > 0 && a.startsWith("--") ? a.slice(eq + 1) : undefined;
118
+ const value = () => (inline !== undefined ? inline : (i++, takeValue(i - 1, flag)));
119
+ switch (flag) {
120
+ case "--daemon": opts.daemon = value(); break;
121
+ case "--token": opts.token = value(); break;
122
+ case "--token-file": opts.tokenFile = value(); break;
123
+ case "--no-token": opts.noToken = true; break;
124
+ case "--host": opts.host = value(); break;
125
+ case "--port": {
126
+ const n = Number(value());
127
+ if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error("--port must be an integer between 0 and 65535");
128
+ opts.port = n;
129
+ break;
130
+ }
131
+ case "--open": opts.open = true; break;
132
+ case "--no-open": opts.open = false; break;
133
+ case "--root": opts.root = resolve(value()); break;
134
+ case "-h": case "--help": opts.help = true; break;
135
+ case "-v": case "--version": opts.version = true; break;
136
+ default: throw new Error(`unknown option ${a}\n\n${USAGE}`);
137
+ }
138
+ }
139
+ return opts;
140
+ }
141
+
142
+ /** ``true`` for the hosts a loopback bind answers to. */
143
+ export function isLoopbackHost(host) {
144
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
145
+ return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h);
146
+ }
147
+
148
+ function daemonHost(url) {
149
+ try { return new URL(url).hostname; } catch { return ""; }
150
+ }
151
+
152
+ /**
153
+ * Decide which token (if any) reaches the page.
154
+ *
155
+ * Explicit ``--token`` / ``--token-file`` win; otherwise the default
156
+ * token file is used only for a loopback daemon, because that file
157
+ * belongs to the daemon on *this* machine and would be the wrong secret
158
+ * for any other.
159
+ */
160
+ export function resolveToken(opts, { readFile = (p) => readFileSync(p, "utf8"), exists = existsSync } = {}) {
161
+ if (opts.noToken) return { token: undefined, source: "disabled" };
162
+ if (opts.token !== undefined) return { token: opts.token, source: "flag" };
163
+ if (opts.tokenFile !== undefined) return { token: readFile(opts.tokenFile).trim(), source: opts.tokenFile };
164
+ if (isLoopbackHost(daemonHost(opts.daemon)) && exists(DEFAULT_TOKEN_FILE)) {
165
+ return { token: readFile(DEFAULT_TOKEN_FILE).trim(), source: DEFAULT_TOKEN_FILE };
166
+ }
167
+ return { token: undefined, source: "none" };
168
+ }
169
+
170
+ function send(res, status, body, headers = {}) {
171
+ res.writeHead(status, { "X-Content-Type-Options": "nosniff", ...headers });
172
+ res.end(body);
173
+ }
174
+
175
+ /**
176
+ * Build (but do not start) the static server.
177
+ *
178
+ * ``config`` is what ``GET /config.json`` answers; the caller decides
179
+ * whether it carries a token. Everything under ``root`` is served
180
+ * read-only, with the SPA fallback (any path without a file behind it
181
+ * gets ``index.html``) and immutable caching for Vite's hashed assets.
182
+ * ``allowedHosts`` is the set of ``Host`` header values accepted (read
183
+ * on every request, so it may be filled after ``listen``); ``null``
184
+ * accepts any host. A request naming another host is refused with 421.
185
+ */
186
+ export function createStaticServer({ root, config, allowedHosts }) {
187
+ return createServer(createStaticHandler({ root, config, allowedHosts }));
188
+ }
189
+
190
+ /**
191
+ * The request listener behind :func:`createStaticServer`, for a host that
192
+ * answers its own routes first and falls through to the bundle
193
+ * (jaato-web-coder-server mounts it after ``/auth/*`` and ``/api/*``).
194
+ * Same contract: ``config`` answers ``GET /config.json``, ``allowedHosts``
195
+ * ``null`` accepts any ``Host``.
196
+ */
197
+ export function createStaticHandler({ root, config, allowedHosts }) {
198
+ const rootAbs = resolve(root);
199
+ const configBody = JSON.stringify(config);
200
+ const hostOk = (req) => {
201
+ if (!allowedHosts) return true;
202
+ const h = (req.headers.host ?? "").toLowerCase();
203
+ return allowedHosts.has(h);
204
+ };
205
+
206
+ return async (req, res) => {
207
+ if (!hostOk(req)) return send(res, 421, "Misdirected Request\n", { "Content-Type": "text/plain" });
208
+ if (req.method !== "GET" && req.method !== "HEAD") {
209
+ return send(res, 405, "Method Not Allowed\n", { "Content-Type": "text/plain", Allow: "GET, HEAD" });
210
+ }
211
+ let pathname;
212
+ try { pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname); }
213
+ catch { return send(res, 400, "Bad Request\n", { "Content-Type": "text/plain" }); }
214
+
215
+ if (pathname === "/config.json") {
216
+ return send(res, 200, configBody, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" });
217
+ }
218
+
219
+ let file = resolve(rootAbs, "." + pathname);
220
+ if (file !== rootAbs && !file.startsWith(rootAbs + sep)) {
221
+ return send(res, 403, "Forbidden\n", { "Content-Type": "text/plain" });
222
+ }
223
+ let stat = await fs.stat(file).catch(() => null);
224
+ if (stat?.isDirectory()) { file = join(file, "index.html"); stat = await fs.stat(file).catch(() => null); }
225
+ if (!stat?.isFile()) { file = join(rootAbs, "index.html"); stat = await fs.stat(file).catch(() => null); }
226
+ if (!stat?.isFile()) return send(res, 404, "Not Found\n", { "Content-Type": "text/plain" });
227
+
228
+ const ext = extname(file).toLowerCase();
229
+ const hashed = file.startsWith(join(rootAbs, "assets") + sep);
230
+ const headers = {
231
+ "Content-Type": MIME[ext] ?? "application/octet-stream",
232
+ "Content-Length": String(stat.size),
233
+ "Cache-Control": hashed ? "public, max-age=31536000, immutable" : "no-cache",
234
+ };
235
+ if (req.method === "HEAD") return send(res, 200, undefined, headers);
236
+ send(res, 200, await fs.readFile(file), headers);
237
+ };
238
+ }
239
+
240
+ function openBrowser(url) {
241
+ const [cmd, args] =
242
+ process.platform === "darwin" ? ["open", [url]]
243
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
244
+ : ["xdg-open", [url]];
245
+ try {
246
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
247
+ } catch { /* no opener on this box; the URL is printed anyway */ }
248
+ }
249
+
250
+ function packageVersion() {
251
+ try { return JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version; }
252
+ catch { return "unknown"; }
253
+ }
254
+
255
+ /**
256
+ * The build stamp vite writes beside the bundle (``dist/build-info.json``):
257
+ * UI version, the @jaato/sdk revision compiled in, its protocol floor and
258
+ * the commit. ``null`` when the root carries none (a foreign ``--root``).
259
+ */
260
+ export function readBuildInfo(root = DEFAULT_ROOT) {
261
+ try { return JSON.parse(readFileSync(join(root, "build-info.json"), "utf8")); }
262
+ catch { return null; }
263
+ }
264
+
265
+ function versionLines(root) {
266
+ const lines = [packageVersion()];
267
+ const b = readBuildInfo(root);
268
+ if (b) lines.push(`@jaato/sdk ${b.sdk} · protocol ≥ ${b.protocolMin} · ${b.commit} · built ${b.builtAt}`);
269
+ return lines.join("\n") + "\n";
270
+ }
271
+
272
+ /** Start serving per ``opts`` (from ``parseArgs``); resolves to the listening server. */
273
+ export async function main(opts) {
274
+ if (opts.help) { process.stdout.write(USAGE); return null; }
275
+ if (opts.version) { process.stdout.write(versionLines(opts.root)); return null; }
276
+ if (!existsSync(join(opts.root, "index.html"))) {
277
+ throw new Error(`no index.html under ${opts.root} — run \`npm run build\` first, or pass --root`);
278
+ }
279
+
280
+ const loopback = isLoopbackHost(opts.host);
281
+ const { token, source } = resolveToken(opts);
282
+ const config = { daemon: opts.daemon, autoConnect: true };
283
+ if (token && loopback) config.token = token;
284
+
285
+ // On a loopback bind only the loopback names are accepted in ``Host``,
286
+ // so a request that names any other host (a rebinding attack) never
287
+ // sees the token. A non-loopback bind serves no token, and the names
288
+ // people reach it by are not ours to know, so it takes any ``Host``.
289
+ const allowed = loopback ? new Set() : null;
290
+ const server = createStaticServer({ root: opts.root, config, allowedHosts: allowed });
291
+ await new Promise((ok, fail) => server.once("error", fail).listen(opts.port, opts.host, ok));
292
+ const { port } = server.address();
293
+ if (allowed) for (const h of ["localhost", "127.0.0.1", "[::1]", opts.host]) allowed.add(`${h}:${port}`.toLowerCase());
294
+
295
+ const wildcard = opts.host === "0.0.0.0" || opts.host === "::";
296
+ const hostForUrl = wildcard ? "localhost" : opts.host.includes(":") ? `[${opts.host}]` : opts.host;
297
+ const url = `http://${hostForUrl}:${port}/`;
298
+ const lines = [`jaato-web-coder-ui ${packageVersion()} serving ${opts.root}`, ` open ${url}${wildcard ? " (bound to all interfaces)" : ""}`, ` daemon ${opts.daemon}`];
299
+ if (config.token) lines.push(` token from ${source}`);
300
+ else if (token && !loopback) lines.push(` token NOT handed to the page: ${opts.host} is not a loopback bind; type it in the form`);
301
+ else lines.push(` token none (${source}); type it in the form, or leave empty for --ws-unsafe-no-auth`);
302
+ process.stdout.write(lines.join("\n") + "\n");
303
+ if (opts.open) openBrowser(url);
304
+ return server;
305
+ }
306
+
307
+ // npm installs the bin as a symlink under node_modules/.bin, so argv[1]
308
+ // must be resolved through the link before it can equal this module.
309
+ function invokedDirectly() {
310
+ if (!process.argv[1]) return false;
311
+ try { return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
312
+ catch { return false; }
313
+ }
314
+ const isMain = invokedDirectly();
315
+ if (isMain) {
316
+ let opts;
317
+ try { opts = parseArgs(process.argv.slice(2)); }
318
+ catch (err) { process.stderr.write(`jaato-web-coder-ui: ${err.message}\n`); process.exit(2); }
319
+ main(opts).then((server) => {
320
+ if (!server) return;
321
+ const stop = () => server.close(() => process.exit(0));
322
+ process.on("SIGINT", stop);
323
+ process.on("SIGTERM", stop);
324
+ }).catch((err) => {
325
+ process.stderr.write(`jaato-web-coder-ui: ${err.message}\n`);
326
+ process.exit(1);
327
+ });
328
+ }