From bba8d81137f81b4e7120f5537147b0ec17a988ed Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 26 Jun 2026 17:55:00 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(dev):=20npm=20run=20local=20=E2=80=94?= =?UTF-8?q?=20throwaway=20local=20ClickHouse,=20no=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a one-command way for anyone cloning the repo to run the whole app locally without a cluster or Docker: `npm run local` boots an already-installed `clickhouse` binary as a standalone server in a gitignored .local-ch/ dir and serves the built SPA same-origin from its user_files (the same static-handler trick as the production deploy), basic-auth, no IdP. The browser POSTs queries to "/", so the schema tree, results, charts, and the lineage graph all work offline. - build/local.mjs: dependency-free runner — finds the clickhouse binary (`clickhouse server` or `clickhouse-server`), scaffolds config.xml/users.xml, stages dist/sql.html + a {basic_login:true} config.json, boots the server, prints the URL, forwards Ctrl-C. Data dir persists across runs; env overrides LOCAL_CH_DIR / CH_HTTP_PORT (default 18123) / CH_TCP_PORT. - Bakes in the one non-obvious gotcha: CH 26.x null-pointer-crashes at startup if is missing/empty (Context::setClustersConfig), so the generated config includes one concrete cluster. - package.json: `local` script; .gitignore: /.local-ch/; README: a "Run locally (no Docker)" section under Quick start. No src/ changes — the coverage gate is unaffected (935 tests pass). Verified end-to-end: serves /sql (200), /sql/config.json, and a CREATE/INSERT/SELECT round-trip against the booted server. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XmDqY6Bxf5b1fyQE8XwA2u --- .gitignore | 3 + README.md | 20 +++++++ build/local.mjs | 153 ++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 build/local.mjs diff --git a/.gitignore b/.gitignore index 402d0ce..c762b55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Build output /dist/ +# Throwaway local ClickHouse data dir (npm run local) +/.local-ch/ + # Test / coverage /coverage/ /test-results/ diff --git a/README.md b/README.md index fcd9ebf..f9cbea2 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,26 @@ npm run build # → dist/sql.html (single file) npm run dev # build + serve dist/ at http://localhost:8900 ``` +`npm run dev` serves only the static shell — the app needs a *same-origin* +ClickHouse to query (see "How it works"). To run the whole thing locally: + +### Run locally against a throwaway ClickHouse (no Docker) + +If you have the `clickhouse` binary installed ([install +docs](https://clickhouse.com/docs/install), or `curl https://clickhouse.com/ | sh`): + +```bash +npm run local # build + boot a standalone ClickHouse, serving the SPA + # → open http://localhost:18123/sql +``` + +This boots `clickhouse server` in a gitignored `.local-ch/` data dir and serves +the built SPA same-origin from its `user_files` (the same static-handler trick as +the production deploy), so queries, the schema tree, and the lineage graph all +work offline. **Sign in as user `default` with an empty password.** The data dir +persists across runs (your tables survive) — delete `.local-ch/` to reset, or set +`LOCAL_CH_DIR` / `CH_HTTP_PORT` / `CH_TCP_PORT` to override. Ctrl-C stops it. + ## Installing on any ClickHouse cluster ```bash diff --git a/build/local.mjs b/build/local.mjs new file mode 100644 index 0000000..1f2af06 --- /dev/null +++ b/build/local.mjs @@ -0,0 +1,153 @@ +// Run the SQL browser against a throwaway local ClickHouse — no Docker. +// +// Boots an already-installed `clickhouse` binary as a standalone server in a +// gitignored data dir and serves the built SPA *same-origin* from its +// user_files (the same static-handler trick the production deploy uses), with +// basic-auth and no IdP. The browser POSTs queries to "/", which the server's +// built-in handler answers — so the schema tree, results, charts, and the +// schema lineage graph all work offline. +// +// npm run local # build + boot, then open http://localhost:18123/sql +// +// Sign in as user "default" with an empty password. The data dir persists across +// runs (your tables survive); delete it to reset. Ctrl-C stops the server. +// +// Env overrides: +// LOCAL_CH_DIR data dir (default: .local-ch/ in the repo) +// CH_HTTP_PORT HTTP port (default: 18123 — not 8123, to dodge a system CH) +// CH_TCP_PORT native port (default: 19000) + +import { spawn, spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, copyFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const dir = resolve(process.env.LOCAL_CH_DIR || resolve(repoRoot, '.local-ch')); +const httpPort = process.env.CH_HTTP_PORT || '18123'; +const tcpPort = process.env.CH_TCP_PORT || '19000'; +const spa = resolve(repoRoot, 'dist', 'sql.html'); + +// --- preconditions --------------------------------------------------------- +if (!existsSync(spa)) { + console.error('✗ dist/sql.html not found — run `npm run build` first (or use `npm run local`).'); + process.exit(1); +} +const ch = findClickhouse(); +if (!ch) { + console.error('✗ No `clickhouse` binary on PATH. Install it, then retry:\n' + + ' curl https://clickhouse.com/ | sh # or: brew install clickhouse\n' + + ' https://clickhouse.com/docs/install'); + process.exit(1); +} + +// --- scaffold (config + SPA rewritten each run; the DB store persists) ------ +const store = resolve(dir, 'store'); +const userFiles = resolve(store, 'user_files'); +mkdirSync(userFiles, { recursive: true }); +writeFileSync(resolve(dir, 'config.xml'), configXml()); +writeFileSync(resolve(dir, 'users.xml'), usersXml()); +copyFileSync(spa, resolve(userFiles, 'sql.html')); +writeFileSync(resolve(userFiles, 'sql-config.json'), JSON.stringify({ basic_login: true, idps: [] })); + +// --- boot ------------------------------------------------------------------ +const url = `http://localhost:${httpPort}/sql`; +process.stdout.write( + `\n Altinity SQL Browser — local ClickHouse (${ch.cmd})\n` + + ` ▸ open ${url}\n` + + ` ▸ sign in as user "default" with an empty password\n` + + ` ▸ data ${dir} (gitignored; delete to reset)\n` + + ` ▸ Ctrl-C to stop\n\n`, +); +const child = spawn(ch.cmd, [...ch.serverArgs, `--config-file=${resolve(dir, 'config.xml')}`], { stdio: 'inherit' }); +const stop = () => child.kill('SIGTERM'); +process.on('SIGINT', stop); +process.on('SIGTERM', stop); +child.on('exit', (code) => process.exit(code || 0)); + +// --- helpers --------------------------------------------------------------- +function findClickhouse() { + // `clickhouse server …` (the multi-tool) or a `clickhouse-server` symlink. + for (const [cmd, serverArgs] of [['clickhouse', ['server']], ['clickhouse-server', []]]) { + if (!spawnSync(cmd, ['--version'], { stdio: 'ignore' }).error) return { cmd, serverArgs }; + } + return null; +} + +function usersXml() { + return ` + + + + + + ::/0 + default + default + + + + +`; +} + +function configXml() { + return ` + + + + warning + ${resolve(dir, 'clickhouse.log')} + ${resolve(dir, 'clickhouse.err.log')} + + ${store}/ + ${store}/tmp/ + ${userFiles}/ + ${store}/user_scripts/ + users.xml + default + default + + 127.0.0.1 + ${httpPort} + ${tcpPort} + 268435456 + + + + 127.0.0.1${tcpPort} + + + + + + regex:^/sql/?$ + GET + + static + text/html; charset=UTF-8 + + no-store + default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src 'self'; frame-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none' + nosniff + + file://sql.html + + + + regex:^/sql/config\\.json$ + GET + + static + application/json; charset=UTF-8 + no-store + file://sql-config.json + + + + + +`; +} diff --git a/package.json b/package.json index d5a33c4..4fc23f3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "vitest run --coverage --config tests/vitest.config.ts", "test:watch": "vitest --config tests/vitest.config.ts", "test:e2e": "playwright test", - "dev": "node build/build.mjs && python3 -m http.server -d dist 8900" + "dev": "node build/build.mjs && python3 -m http.server -d dist 8900", + "local": "node build/build.mjs && node build/local.mjs" }, "devDependencies": { "@playwright/test": "^1.48.0", From 6a255677391d02a72299cf0247e941330f8dfdc9 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 26 Jun 2026 18:32:10 +0200 Subject: [PATCH 2/7] fix(dev): make `npm run local` a static server, not a local ClickHouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier approach booted a standalone `clickhouse server`, but that binary (26.3.5.12, macOS) crashes ~deterministically at startup with a null-pointer in Context::setClustersConfig during initial config load — reproduced independent of config content (minimal/full, ±remote_servers, ±jemalloc env). It's a ClickHouse binary bug, not our config, so spawning a server locally is a dead end. It's also unnecessary: the app's credentials login already takes a ClickHouse host and queries it cross-origin (app.js connect() → resolveTarget → chCtx.origin). So the local runner only needs to serve the static page; you point the login at whatever ClickHouse you want (local or remote). - build/local.py: tiny stdlib HTTP server — serves dist/sql.html at /sql and a {basic_login:true} config.json at */config.json. No deps, no ClickHouse, no proxy. PORT overridable (default 8900). - Remove build/local.mjs (the clickhouse-server launcher) and the /.local-ch/ gitignore; `npm run local` = build + python3 build/local.py. - README: "Run locally against your own ClickHouse" — sign in with your CH host (include the scheme) + credentials; note ClickHouse's default CORS makes the cross-origin queries work. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XmDqY6Bxf5b1fyQE8XwA2u --- .gitignore | 3 - README.md | 30 +++++----- build/local.mjs | 153 ------------------------------------------------ build/local.py | 79 +++++++++++++++++++++++++ package.json | 2 +- 5 files changed, 96 insertions(+), 171 deletions(-) delete mode 100644 build/local.mjs create mode 100644 build/local.py diff --git a/.gitignore b/.gitignore index c762b55..402d0ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ # Build output /dist/ -# Throwaway local ClickHouse data dir (npm run local) -/.local-ch/ - # Test / coverage /coverage/ /test-results/ diff --git a/README.md b/README.md index f9cbea2..b551145 100644 --- a/README.md +++ b/README.md @@ -168,25 +168,27 @@ npm run build # → dist/sql.html (single file) npm run dev # build + serve dist/ at http://localhost:8900 ``` -`npm run dev` serves only the static shell — the app needs a *same-origin* -ClickHouse to query (see "How it works"). To run the whole thing locally: +### Run locally against your own ClickHouse -### Run locally against a throwaway ClickHouse (no Docker) - -If you have the `clickhouse` binary installed ([install -docs](https://clickhouse.com/docs/install), or `curl https://clickhouse.com/ | sh`): +`npm run local` builds the SPA and serves it as a static page on localhost: ```bash -npm run local # build + boot a standalone ClickHouse, serving the SPA - # → open http://localhost:18123/sql +npm run local # build + serve → open http://localhost:8900/sql ``` -This boots `clickhouse server` in a gitignored `.local-ch/` data dir and serves -the built SPA same-origin from its `user_files` (the same static-handler trick as -the production deploy), so queries, the schema tree, and the lineage graph all -work offline. **Sign in as user `default` with an empty password.** The data dir -persists across runs (your tables survive) — delete `.local-ch/` to reset, or set -`LOCAL_CH_DIR` / `CH_HTTP_PORT` / `CH_TCP_PORT` to override. Ctrl-C stops it. +The app is a thin client: in **credentials** mode the login form takes a +ClickHouse host, and queries go straight from the browser to that host. So the +local server just serves the page — point the login at whatever ClickHouse you +like: + +- **Host** — e.g. `http://localhost:8123` (include the scheme; a bare host + defaults to `https://:8443`). +- **User / password** — your ClickHouse credentials. + +The target ClickHouse must allow cross-origin requests; ClickHouse's HTTP +interface sends `Access-Control-Allow-Origin` for requests carrying an `Origin` +header by default, so a stock server works as-is. Override the serve port with +`PORT`. Ctrl-C stops it. ## Installing on any ClickHouse cluster diff --git a/build/local.mjs b/build/local.mjs deleted file mode 100644 index 1f2af06..0000000 --- a/build/local.mjs +++ /dev/null @@ -1,153 +0,0 @@ -// Run the SQL browser against a throwaway local ClickHouse — no Docker. -// -// Boots an already-installed `clickhouse` binary as a standalone server in a -// gitignored data dir and serves the built SPA *same-origin* from its -// user_files (the same static-handler trick the production deploy uses), with -// basic-auth and no IdP. The browser POSTs queries to "/", which the server's -// built-in handler answers — so the schema tree, results, charts, and the -// schema lineage graph all work offline. -// -// npm run local # build + boot, then open http://localhost:18123/sql -// -// Sign in as user "default" with an empty password. The data dir persists across -// runs (your tables survive); delete it to reset. Ctrl-C stops the server. -// -// Env overrides: -// LOCAL_CH_DIR data dir (default: .local-ch/ in the repo) -// CH_HTTP_PORT HTTP port (default: 18123 — not 8123, to dodge a system CH) -// CH_TCP_PORT native port (default: 19000) - -import { spawn, spawnSync } from 'node:child_process'; -import { mkdirSync, writeFileSync, copyFileSync, existsSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const dir = resolve(process.env.LOCAL_CH_DIR || resolve(repoRoot, '.local-ch')); -const httpPort = process.env.CH_HTTP_PORT || '18123'; -const tcpPort = process.env.CH_TCP_PORT || '19000'; -const spa = resolve(repoRoot, 'dist', 'sql.html'); - -// --- preconditions --------------------------------------------------------- -if (!existsSync(spa)) { - console.error('✗ dist/sql.html not found — run `npm run build` first (or use `npm run local`).'); - process.exit(1); -} -const ch = findClickhouse(); -if (!ch) { - console.error('✗ No `clickhouse` binary on PATH. Install it, then retry:\n' - + ' curl https://clickhouse.com/ | sh # or: brew install clickhouse\n' - + ' https://clickhouse.com/docs/install'); - process.exit(1); -} - -// --- scaffold (config + SPA rewritten each run; the DB store persists) ------ -const store = resolve(dir, 'store'); -const userFiles = resolve(store, 'user_files'); -mkdirSync(userFiles, { recursive: true }); -writeFileSync(resolve(dir, 'config.xml'), configXml()); -writeFileSync(resolve(dir, 'users.xml'), usersXml()); -copyFileSync(spa, resolve(userFiles, 'sql.html')); -writeFileSync(resolve(userFiles, 'sql-config.json'), JSON.stringify({ basic_login: true, idps: [] })); - -// --- boot ------------------------------------------------------------------ -const url = `http://localhost:${httpPort}/sql`; -process.stdout.write( - `\n Altinity SQL Browser — local ClickHouse (${ch.cmd})\n` - + ` ▸ open ${url}\n` - + ` ▸ sign in as user "default" with an empty password\n` - + ` ▸ data ${dir} (gitignored; delete to reset)\n` - + ` ▸ Ctrl-C to stop\n\n`, -); -const child = spawn(ch.cmd, [...ch.serverArgs, `--config-file=${resolve(dir, 'config.xml')}`], { stdio: 'inherit' }); -const stop = () => child.kill('SIGTERM'); -process.on('SIGINT', stop); -process.on('SIGTERM', stop); -child.on('exit', (code) => process.exit(code || 0)); - -// --- helpers --------------------------------------------------------------- -function findClickhouse() { - // `clickhouse server …` (the multi-tool) or a `clickhouse-server` symlink. - for (const [cmd, serverArgs] of [['clickhouse', ['server']], ['clickhouse-server', []]]) { - if (!spawnSync(cmd, ['--version'], { stdio: 'ignore' }).error) return { cmd, serverArgs }; - } - return null; -} - -function usersXml() { - return ` - - - - - - ::/0 - default - default - - - - -`; -} - -function configXml() { - return ` - - - - warning - ${resolve(dir, 'clickhouse.log')} - ${resolve(dir, 'clickhouse.err.log')} - - ${store}/ - ${store}/tmp/ - ${userFiles}/ - ${store}/user_scripts/ - users.xml - default - default - - 127.0.0.1 - ${httpPort} - ${tcpPort} - 268435456 - - - - 127.0.0.1${tcpPort} - - - - - - regex:^/sql/?$ - GET - - static - text/html; charset=UTF-8 - - no-store - default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src 'self'; frame-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none' - nosniff - - file://sql.html - - - - regex:^/sql/config\\.json$ - GET - - static - application/json; charset=UTF-8 - no-store - file://sql-config.json - - - - - -`; -} diff --git a/build/local.py b/build/local.py new file mode 100644 index 0000000..0b27130 --- /dev/null +++ b/build/local.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Serve the built SQL browser locally as a static page — no ClickHouse here. + +The app is a thin client: in *credentials* (username/password) mode its login form +takes a ClickHouse host, and queries go straight from the browser to that host +(cross-origin). So this server only needs to serve the SPA + a +`{"basic_login": true}` config.json on localhost — there's nothing to proxy and no +ClickHouse to run locally. + + npm run local # build + serve, then open http://localhost:8900/sql + +On the login screen, sign in with: + • Host: http://localhost:8123 — your ClickHouse HTTP endpoint. Include the + scheme: a bare host defaults to https://:8443. + • User / password: your ClickHouse credentials. + +The target ClickHouse must allow cross-origin requests — ClickHouse's HTTP +interface sends `Access-Control-Allow-Origin` for requests carrying an `Origin` +header by default, so a stock server works as-is. + +Env: PORT (default 8900). +""" +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SPA = os.path.join(ROOT, "dist", "sql.html") +PORT = int(os.environ.get("PORT", "8900")) +CONFIG = json.dumps({"basic_login": True, "idps": []}).encode() + + +class Handler(BaseHTTPRequestHandler): + def _send(self, body, ctype, code=200): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path.endswith("/config.json"): + self._send(CONFIG, "application/json; charset=utf-8") + return + if path.rstrip("/") in ("", "/sql", "/sql.html"): + try: + with open(SPA, "rb") as f: + html = f.read() + except FileNotFoundError: + self._send(b"dist/sql.html missing - run `npm run build`.\n", "text/plain", 500) + return + self._send(html, "text/html; charset=utf-8") + return + self._send(b"not found\n", "text/plain", 404) + + def log_message(self, *_a): # keep the console quiet + pass + + +def main(): + if not os.path.exists(SPA): + sys.exit("dist/sql.html not found - run `npm run build` first (or `npm run local`).") + print( + f"\n Altinity SQL Browser - local static server\n" + f" ▸ open http://localhost:{PORT}/sql\n" + f" ▸ sign in with your ClickHouse host (e.g. http://localhost:8123) + credentials\n" + f" ▸ Ctrl-C to stop\n" + ) + try: + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/package.json b/package.json index 4fc23f3..636853d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test:watch": "vitest --config tests/vitest.config.ts", "test:e2e": "playwright test", "dev": "node build/build.mjs && python3 -m http.server -d dist 8900", - "local": "node build/build.mjs && node build/local.mjs" + "local": "node build/build.mjs && python3 build/local.py" }, "devDependencies": { "@playwright/test": "^1.48.0", From 8d504b212ef98b85c3ebc761758c7805337af4b3 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 26 Jun 2026 19:22:26 +0200 Subject: [PATCH 3/7] feat(local): host picker from clickhouse-client config (basic + OAuth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run local` now reads ~/.clickhouse-client/config.xml and offers your connections as a "Saved connection" dropdown on the login screen — basic (hostname/user/password) and OAuth (clickhouse-client's oauth-url/oauth-client-id/ oauth-audience keys) alike — so you pick a cluster instead of typing it. SPA (shipped, config-driven): - oauth-config.js: config.json gains an optional `hosts[]`; loadConfigDoc returns { idps, basicLogin, hosts } with normalizeHost → {label,url,auth,user,password,idp}. - app.js: OAuth can now target a cross-origin cluster — login(idpId, targetOrigin) stashes `oauth_origin` (survives the redirect like oauth_idp/oauth_state), chCtx.origin reads it in OAuth mode, clearTokens/sign-out clears it. redirect_uri stays the localhost SPA. The `login` action forwards the target origin. - login.js: a host `