diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9558289..365e581 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,44 @@ jobs: path: dist/sql.html retention-days: 14 + # Release-bundle smoke test: assemble the curl|sh artifact, extract it, and boot + # the zero-dep Python runner exactly as an end user would — proving the bundle + # layout, the SPA-path discovery, and config.json generation all work before a + # tag ever cuts a real release. + bundle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm install --no-audit --no-fund + - name: Build release bundle + run: build/bundle.sh + - name: Extract + boot the runner (as a user would) + run: | + set -euo pipefail + tmp=$(mktemp -d) + tar -C "$tmp" -xzf dist/altinity-sql-browser.tar.gz --strip-components=1 + for f in sql.html local.py sql-browser.xml run.sh VERSION; do + test -f "$tmp/$f" || { echo "missing $f in bundle" >&2; exit 1; } + done + python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$tmp/local.py" + # No LOCAL_CH_CONFIG: the runner discovers the bundled sql-browser.xml + # next to local.py, proving the merge/discovery path end to end. + # SQL_BROWSER_PROBE=0: don't depend on reaching external demo hosts from CI. + SQL_BROWSER_PROBE=0 PORT=8901 "$tmp/run.sh" & + pid=$! + for i in $(seq 1 20); do curl -fsS "http://localhost:8901/sql" >/dev/null 2>&1 && break; sleep 0.5; done + curl -fsS "http://localhost:8901/sql" >/dev/null + curl -fsS "http://localhost:8901/config.json" \ + | python3 -c "import sys,json; assert json.load(sys.stdin)['hosts'], 'no hosts parsed'" + kill "$pid" + - name: Lint the installer (shellcheck) + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck install.sh build/bundle.sh + # Real-browser regression tests (Playwright). Runs on Linux runners where the # Gecko/Chromium binaries launch normally — separate from the unit suite so a # missing browser binary can't mask a unit failure, and vice versa. The diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4e59e06 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: release + +# Cut a release by pushing a tag, e.g.: git tag v0.1.0 && git push origin v0.1.0 +on: + push: + tags: ['v*'] + +permissions: + contents: write # create the GitHub Release + upload assets + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm install --no-audit --no-fund + - name: Test (coverage gate) + run: npm test + - name: Build release bundle + run: build/bundle.sh "${GITHUB_REF_NAME#v}" + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --generate-notes \ + dist/altinity-sql-browser.tar.gz \ + dist/altinity-sql-browser.tar.gz.sha256 \ + dist/sql.html diff --git a/README.md b/README.md index 5899d5e..5397512 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,31 @@ npm run dev # build + serve dist/ at http://localhost:8900 ### Run locally against your own ClickHouse -`npm run local` builds the SPA and serves it as a static page on localhost: +**Install (no clone, no Node — just `python3`):** + +```bash +curl -fsSL https://raw.githubusercontent.com/Altinity/altinity-sql-browser/main/install.sh | sh +altinity-sql-browser # serve → open http://localhost:8900/sql +``` + +This downloads the latest [release](https://github.com/Altinity/altinity-sql-browser/releases) +bundle (the prebuilt single-file SPA + the zero-dependency Python runner) into +`~/.altinity-sql-browser` and installs a launcher in `~/.local/bin`. Overrides: +`ASB_VERSION` (tag to install), `ASB_HOME`, `ASB_BIN`. + +The installer also writes a sample **`~/.clickhouse-client/sql-browser.xml`** (a few +public demo clusters) — under a separate name, so it **never replaces your real +`config.xml`**. The runner **merges** connections from both files (your `config.xml` +wins on a name clash), so a fresh machine has something to connect to immediately. +The picker uses `` if set; otherwise, since a cluster may serve the +HTTP interface on either port, at startup the runner **probes both standard ports** +(`443` then `8443` for secure, `8123` then `80` for plain) and uses whichever +answers `Ok.` on `/ping`. The native `` (9440/9000) is never used — it's a +different interface. The probe **prints a reachability table** and skips any host +with no HTTP interface on any port (e.g. a native-only endpoint) so it isn't a dead +pick. Set `SQL_BROWSER_PROBE=0` to skip probing and keep all hosts (`8443`/`8123`). + +**From a checkout** (also builds the SPA, needs Node): ```bash npm run local # build + serve → open http://localhost:8900/sql @@ -414,6 +438,21 @@ The harness (`tests/e2e/`) serves the repo over HTTP and imports the actual source as native ESM — no bundling, always current. It is **not** part of `npm test` or the coverage gate. +## Releasing + +Releases are cut by pushing a version tag — `.github/workflows/release.yml` then +runs the coverage gate, assembles the bundle, and publishes a GitHub Release: + +```bash +git tag v0.1.0 && git push origin v0.1.0 +``` + +The release attaches `altinity-sql-browser.tar.gz` (+ `.sha256`) and the raw +`sql.html`. The bundle is built by `build/bundle.sh` (also runnable locally), and +every PR smoke-tests it in CI (`bundle` job: extract → boot the runner → fetch +`/sql` + `/config.json`). The `curl | sh` `install.sh` resolves the latest tag and +installs that artifact. + ## License Apache-2.0. diff --git a/build/bundle.sh b/build/bundle.sh new file mode 100755 index 0000000..537ae1e --- /dev/null +++ b/build/bundle.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Assemble the release bundle for the local runner: +# dist/altinity-sql-browser.tar.gz (+ .sha256) +# +# Contents (under a single top dir so `tar --strip-components=1` is clean): +# altinity-sql-browser/ +# sql.html — the prebuilt single-file SPA +# local.py — the zero-dep Python runner (serves SPA + config.json) +# sql-browser.xml — sample public-demo connections (merged with the +# user's ~/.clickhouse-client/config.xml by the runner) +# run.sh — self-resolving launcher (python3 local.py) +# VERSION +# README.txt +# +# Builds the SPA first. Used by .github/workflows/release.yml and runnable +# locally. Pass a version as $1, else it's read from package.json. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="${1:-$(node -p "require('$ROOT/package.json').version")}" +OUT="$ROOT/dist" +STAGE="$OUT/bundle/altinity-sql-browser" + +echo "==> Building SPA" +node "$ROOT/build/build.mjs" + +echo "==> Staging bundle ($VERSION)" +rm -rf "$OUT/bundle" +mkdir -p "$STAGE" +cp "$OUT/sql.html" "$STAGE/sql.html" +cp "$ROOT/build/local.py" "$STAGE/local.py" +cp "$ROOT/deploy/sql-browser.xml" "$STAGE/sql-browser.xml" +printf '%s\n' "$VERSION" > "$STAGE/VERSION" + +cat > "$STAGE/run.sh" <<'EOF' +#!/bin/sh +# Launch the Altinity SQL Browser local runner. Serves the bundled sql.html and a +# config.json generated from your ~/.clickhouse-client/config.xml. +# PORT=8900 LOCAL_CH_CONFIG=~/.clickhouse-client/config.xml ./run.sh +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$DIR/local.py" "$@" +EOF +chmod +x "$STAGE/run.sh" + +cat > "$STAGE/README.txt" <<'EOF' +Altinity SQL Browser — local runner +=================================== + +Requires only python3 (preinstalled on macOS/Linux). + + ./run.sh # serve http://localhost:8900/sql + +It merges connections from your ~/.clickhouse-client/config.xml and the bundled +sql-browser.xml (public demos) and offers each in the login picker — your own +config wins on a name clash. Run ./install.sh (curl|sh) to also copy +sql-browser.xml into ~/.clickhouse-client/. + +Env: PORT (default 8900), LOCAL_CH_CONFIG (override with one explicit file), +SQL_BROWSER_SPA. + +Source & docs: https://github.com/Altinity/altinity-sql-browser +EOF + +echo "==> Archiving" +TARBALL="altinity-sql-browser.tar.gz" +tar -C "$OUT/bundle" -czf "$OUT/$TARBALL" altinity-sql-browser +( cd "$OUT" && { sha256sum "$TARBALL" 2>/dev/null || shasum -a 256 "$TARBALL"; } > "$TARBALL.sha256" ) +echo " $OUT/$TARBALL" +echo " $OUT/$TARBALL.sha256" diff --git a/build/local.py b/build/local.py index f5e4b2f..e13f4d2 100644 --- a/build/local.py +++ b/build/local.py @@ -6,7 +6,9 @@ and sends the bearer to the chosen cluster. So this server only serves the SPA + a generated config.json — there's nothing to proxy and no ClickHouse to run here. -It reads your `~/.clickhouse-client/config.xml` connections and offers them as a +It merges connections from `~/.clickhouse-client/config.xml` (your own) and +`~/.clickhouse-client/sql-browser.xml` (the demo file installed by install.sh), +de-duped by name (your config.xml wins), and offers them as a **Saved connection** dropdown on the login screen: • a plain connection (hostname/user/password) → prefills the credentials form. • a connection carrying clickhouse-client's OAuth keys (`oauth-url`, @@ -23,18 +25,75 @@ For OAuth connections you also register `http://localhost:8900/sql` as a redirect URI with the IdP and allow CORS from localhost on the cluster (see README). -Env: PORT (default 8900) · LOCAL_CH_CONFIG (default ~/.clickhouse-client/config.xml). +At startup it probes each connection's HTTP interface — trying both standard ports +(443 then 8443 for secure, 8123 then 80 for plain) and using whichever answers Ok. +on /ping — and prints a +reachability table; hosts with no HTTP interface on any port (native-only +endpoints) are skipped from the picker so they aren't dead picks. Set +SQL_BROWSER_PROBE=0 to keep all hosts. + +Env: PORT (default 8900) · LOCAL_CH_CONFIG (override with a single explicit file) + · SQL_BROWSER_SPA (override the sql.html path) · SQL_BROWSER_PROBE (0 to skip + the reachability probe) · SQL_BROWSER_PROBE_TIMEOUT (seconds, default 4). """ import json import os +import ssl import sys +import urllib.error +import urllib.request import xml.etree.ElementTree as ET +from concurrent.futures import ThreadPoolExecutor 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") +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _find_spa(): + """Locate the built sql.html across both layouts (explicit override wins): + • $SQL_BROWSER_SPA — explicit path + • /sql.html — release bundle (local.py + sql.html together) + • /../dist/sql.html — dev / in-repo checkout + Returns the first that exists, else the dev path (so the missing-file error + names the place a contributor expects to build into).""" + env = os.environ.get("SQL_BROWSER_SPA") + if env: + return env + bundle = os.path.join(HERE, "sql.html") + dev = os.path.join(HERE, "..", "dist", "sql.html") + return bundle if os.path.exists(bundle) else dev + + +SPA = _find_spa() PORT = int(os.environ.get("PORT", "8900")) -CH_CONFIG = os.environ.get("LOCAL_CH_CONFIG") or os.path.expanduser("~/.clickhouse-client/config.xml") +CH_DIR = os.path.expanduser("~/.clickhouse-client") + + +def config_paths(): + """Files to read connections from, in precedence order (first wins on a name + clash). LOCAL_CH_CONFIG overrides the list with a single explicit file; else + we merge the user's own `config.xml` with the SQL-browser demo file — + installed at ~/.clickhouse-client/sql-browser.xml and/or shipped next to this + runner in the release bundle. Missing files are skipped silently.""" + env = os.environ.get("LOCAL_CH_CONFIG") + if env: + return [env] + return [ + os.path.join(CH_DIR, "config.xml"), # the user's own connections — win on clash + os.path.join(CH_DIR, "sql-browser.xml"), # installed by install.sh + os.path.join(HERE, "sql-browser.xml"), # run-from-bundle fallback (same names → deduped) + ] + + +def _connections(): + """Yield elements from every configured file that parses.""" + for path in config_paths(): + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError): + continue + for conn in root.iter("connection"): + yield conn def _text(conn, *names): @@ -46,58 +105,123 @@ def _text(conn, *names): return "" -def build_config(): - """Generate config.json from the clickhouse-client connections (best-effort).""" - idps, hosts, seen = [], [], set() - try: - root = ET.parse(CH_CONFIG).getroot() - except (OSError, ET.ParseError): - root = None - for conn in (root.iter("connection") if root is not None else []): +def collect(): + """Parse + merge + de-dupe the connection files (first file wins on a name + clash). Pure — no network. Returns (idps_by_id, hosts).""" + idps_by_id, hosts, names = {}, [], set() + for conn in _connections(): name = _text(conn, "name") hostname = _text(conn, "hostname") - if not name or not hostname: + if not name or not hostname or name in names: continue + names.add(name) secure = _text(conn, "secure").lower() in ("1", "true", "yes") # A self-signed / wrong-host TLS cert. The browser can't bypass cert # validation from fetch(), so the SPA can't honour this on its own — it # flags the connection and walks the user through trusting the cert once # (see populateHosts in src/ui/login.js). insecure = _text(conn, "accept-invalid-certificate", "accept_invalid_certificate").lower() in ("1", "true", "yes") + # The browser talks to ClickHouse's HTTP interface, NOT the native + # (9440/9000) clickhouse-client uses — those are independent server ports, + # so we never derive one from the other. Read if given; else + # fall back to the HTTP-interface default keyed off `secure`. http_port = _text(conn, "http_port", "http-port") scheme = "https" if secure else "http" - # Default to ClickHouse's HTTP-interface ports (8443 TLS / 8123 plain), NOT - # 443/80 — mirrors the SPA's resolveTarget for a bare host. Managed endpoints - # often park an auth gateway on 443 (a browser GET 302s to an SSO login), so - # 443 wouldn't reach ClickHouse; 8443 is the direct HTTPS interface. Set an - # explicit to override (e.g. 443 for a proxy that fronts the HTTP - # interface there with no gateway). - # Don't double-append when already carries a port (clickhouse-client - # accepts host:port), else 'h:9000' would become 'h:9000:8443'. + # Candidate HTTP URLs to try, in order — the startup probe picks the first + # whose /ping answers (see probe()). An explicit or a port + # already embedded in is respected as the sole candidate; + # otherwise we try BOTH standard HTTP ports for the scheme, since a cluster + # may serve ClickHouse on 8443 (direct) or 443 (TLS-terminating proxy). The + # native (9440/9000) is never used — it's a different interface. tail = hostname.rsplit(":", 1) - has_port = len(tail) == 2 and tail[1].isdigit() - url = f"{scheme}://{hostname}" if has_port else f"{scheme}://{hostname}:{http_port or ('8443' if secure else '8123')}" + if len(tail) == 2 and tail[1].isdigit(): # hostname already has a port + alts = [f"{scheme}://{hostname}"] + elif http_port: # explicit override + alts = [f"{scheme}://{hostname}:{http_port}"] + else: # try both standard ports + # Secure: 443 first (the canonical public endpoint for managed clusters, + # and it dodges the 8443 timeout when only 443 is open) — the /ping=='Ok.' + # check still rejects a 443 auth-gateway and falls through to 8443. Plain: + # 8123 first (the ClickHouse default; 80 is rarely the HTTP interface). + alts = [f"{scheme}://{hostname}:{p}" for p in (("443", "8443") if secure else ("8123", "80"))] + url = alts[0] oauth_url = _text(conn, "oauth-url", "oauth_url") oauth_client = _text(conn, "oauth-client-id", "oauth_client_id") oauth_secret = _text(conn, "oauth-client-secret", "oauth_client_secret") oauth_aud = _text(conn, "oauth-audience", "oauth_audience") if oauth_url and oauth_client: - if name not in seen: - idps.append({ - "id": name, "label": name, "issuer": oauth_url, "client_id": oauth_client, - # Optional: a Web-client secret (e.g. Google) for the code exchange. - # Empty → public PKCE. clickhouse-client has no such flag, so this is - # a local-only convenience key read from the same connection. - "client_secret": oauth_secret, "audience": oauth_aud, - "bearer": "access_token" if oauth_aud else "id_token", - }) - seen.add(name) - hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name, "insecure": insecure}) + idps_by_id.setdefault(name, { + "id": name, "label": name, "issuer": oauth_url, "client_id": oauth_client, + # Optional: a Web-client secret (e.g. Google) for the code exchange. + # Empty → public PKCE. clickhouse-client has no such flag, so this is + # a local-only convenience key read from the same connection. + "client_secret": oauth_secret, "audience": oauth_aud, + "bearer": "access_token" if oauth_aud else "id_token", + }) + hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name, + "insecure": insecure, "_alts": alts}) else: hosts.append({"label": name, "url": url, "auth": "basic", "user": _text(conn, "user"), "password": _text(conn, "password"), - "insecure": insecure}) - return json.dumps({"basic_login": True, "idps": idps, "hosts": hosts}).encode() + "insecure": insecure, "_alts": alts}) + return idps_by_id, hosts + + +def serialize(idps_by_id, hosts): + """Render config.json bytes, keeping only IdPs still referenced by a kept host + (so dropping an unreachable OAuth host doesn't leave a dangling SSO button) and + stripping internal `_`-prefixed fields (e.g. `_alts`) from the hosts.""" + used = {h["idp"] for h in hosts if h.get("auth") == "oauth"} + idps = [idps_by_id[i] for i in idps_by_id if i in used] + clean = [{k: v for k, v in h.items() if not k.startswith("_")} for h in hosts] + return json.dumps({"basic_login": True, "idps": idps, "hosts": clean}).encode() + + +PROBE_TIMEOUT = float(os.environ.get("SQL_BROWSER_PROBE_TIMEOUT", "4")) + + +def _ping_ok(url, insecure): + """GET /ping and report whether ClickHouse answered 'Ok.'. Returns + (ok, detail).""" + ctx = ssl._create_unverified_context() if insecure else None + try: + req = urllib.request.Request(url.rstrip("/") + "/ping", method="GET") + with urllib.request.urlopen(req, timeout=PROBE_TIMEOUT, context=ctx) as r: + body = r.read(16).decode("ascii", "replace").strip() + return body == "Ok.", (f"HTTP {r.status}" if body == "Ok." else f"HTTP {r.status}, not ClickHouse") + except urllib.error.HTTPError as e: + return False, f"HTTP {e.code}" + except urllib.error.URLError as e: # refused / timeout / TLS / DNS + return False, str(getattr(e, "reason", "") or type(e).__name__) + except Exception as e: + return False, type(e).__name__ + + +def probe(host): + """Try the host's candidate ports in order; the first whose /ping answers 'Ok.' + wins. Returns (chosen_url|None, detail). None → no HTTP interface on any port + (e.g. a native-only endpoint like play.clickhouse.com on :8443), so the host is + skipped from the picker rather than offered as a dead pick.""" + notes = [] + for url in host["_alts"]: + ok, detail = _ping_ok(url, host.get("insecure")) + port = url.rsplit(":", 1)[-1] + if ok: + return url, f"port {port}" + notes.append(f"{port}: {detail}") + return None, "; ".join(notes) + + +def probe_all(hosts): + """Probe every host concurrently. Returns a list of (chosen_url|None, detail) + aligned with `hosts`.""" + with ThreadPoolExecutor(max_workers=min(8, max(1, len(hosts)))) as ex: + return list(ex.map(probe, hosts)) + + +def build_config(): + """All connections as config.json bytes (no probing) — used by tests/imports.""" + return serialize(*collect()) CONFIG = build_config() @@ -135,13 +259,43 @@ def log_message(self, *_a): # keep the console quiet def main(): if not os.path.exists(SPA): sys.exit("dist/sql.html not found - run `npm run build` first (or `npm run local`).") - n = json.loads(CONFIG)["hosts"] - print( - f"\n Altinity SQL Browser - local static server\n" - f" ▸ open http://localhost:{PORT}/sql\n" - f" ▸ {len(n)} saved connection(s) from {CH_CONFIG}\n" - f" ▸ Ctrl-C to stop\n" - ) + global CONFIG + idps_by_id, hosts = collect() + srcs = ", ".join(p for p in config_paths() if os.path.exists(p)) or "(no connection files found)" + probe_on = os.environ.get("SQL_BROWSER_PROBE", "1") != "0" and bool(hosts) + + # Probe each host's HTTP interface (trying both candidate ports) so the picker + # only offers ones the browser can actually reach — a native-only endpoint like + # play.clickhouse.com on :8443 is otherwise a dead pick. The full situation is + # printed so nothing is silently dropped. Disable with SQL_BROWSER_PROBE=0. + if probe_on: + results = probe_all(hosts) + else: + results = [(h["_alts"][0], "not probed") for h in hosts] + kept = [] + for h, (chosen, _d) in zip(hosts, results): + if chosen: + h["url"] = chosen # pin the port that actually answered + kept.append(h) + CONFIG = serialize(idps_by_id, kept) + + width = max((len(h["label"]) for h in hosts), default=0) + lines = ["", + " Altinity SQL Browser - local static server", + f" ▸ open http://localhost:{PORT}/sql", + f" ▸ connections from {srcs}:" if hosts else " ▸ no connections found"] + for h, (chosen, detail) in zip(hosts, results): + if chosen: + lines.append(f" ✓ {h['label']:<{width}} {h['auth']:<5} {chosen} ({detail})") + else: + lines.append(f" ✗ {h['label']:<{width}} {h['auth']:<5} no HTTP interface — skipped [{detail}]") + if probe_on: + n = sum(1 for c, _ in results if c) + extra = f", {len(hosts) - n} skipped" if n < len(hosts) else "" + lines.append(f" ▸ {n}/{len(hosts)} reachable{extra}") + lines += [" ▸ Ctrl-C to stop (SQL_BROWSER_PROBE=0 to skip the reachability check)", ""] + print("\n".join(lines), flush=True) # flush: serve_forever() never returns to flush for us + try: ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() except KeyboardInterrupt: diff --git a/deploy/sql-browser.xml b/deploy/sql-browser.xml new file mode 100644 index 0000000..d0fdb13 --- /dev/null +++ b/deploy/sql-browser.xml @@ -0,0 +1,66 @@ + + + + + + + + antalya + antalya.demo.altinity.cloud + 1 + 9440 + default + demo + demo + + + + + altinity-demo + github.demo.altinity.cloud + 1 + 9440 + default + demo + demo + + + + + clickhouse-sql + sql-clickhouse.clickhouse.com + 1 + 9440 + default + play + + + + + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..801deae --- /dev/null +++ b/install.sh @@ -0,0 +1,105 @@ +#!/bin/sh +# Altinity SQL Browser — LOCAL RUNNER installer (curl | sh). +# +# Downloads the latest release bundle (prebuilt single-file SPA + the zero-dep +# Python runner) and installs a launcher. Needs only python3 (preinstalled on +# macOS/Linux) plus curl or wget. +# +# curl -fsSL https://raw.githubusercontent.com/Altinity/altinity-sql-browser/main/install.sh | sh +# +# This installs the LOCAL runner (point the browser at your own clusters from +# ~/.clickhouse-client/config.xml). To deploy the app ONTO a ClickHouse cluster +# instead, use deploy/install.sh in the repo. +# +# Env overrides: +# ASB_VERSION release tag to install (default: latest) +# ASB_HOME install dir (default: ~/.altinity-sql-browser) +# ASB_BIN launcher dir (default: ~/.local/bin) +set -eu + +REPO="Altinity/altinity-sql-browser" +ASSET="altinity-sql-browser.tar.gz" +ASB_HOME="${ASB_HOME:-$HOME/.altinity-sql-browser}" +ASB_BIN="${ASB_BIN:-$HOME/.local/bin}" + +say() { printf '%s\n' "$*"; } +err() { printf 'error: %s\n' "$*" >&2; exit 1; } + +# --- prerequisites --- +command -v python3 >/dev/null 2>&1 || err "python3 not found — install Python 3 and re-run." +if command -v curl >/dev/null 2>&1; then + dl() { curl -fsSL "$1"; } # to stdout + dlo() { curl -fsSL -o "$1" "$2"; } # to file +elif command -v wget >/dev/null 2>&1; then + dl() { wget -qO- "$1"; } + dlo() { wget -qO "$1" "$2"; } +else + err "need curl or wget." +fi + +# --- resolve version --- +TAG="${ASB_VERSION:-}" +if [ -z "$TAG" ]; then + say "==> Resolving latest release…" + TAG=$(dl "https://api.github.com/repos/$REPO/releases/latest" \ + | grep -m1 '"tag_name"' \ + | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') || true + [ -n "$TAG" ] || err "could not resolve the latest release (set ASB_VERSION=vX.Y.Z)." +fi +say "==> Installing $REPO @ $TAG" + +BASE="https://github.com/$REPO/releases/download/$TAG" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +say "==> Downloading $ASSET" +dlo "$TMP/$ASSET" "$BASE/$ASSET" || err "download failed: $BASE/$ASSET" + +# --- verify checksum when published --- +if dlo "$TMP/$ASSET.sha256" "$BASE/$ASSET.sha256" 2>/dev/null; then + if command -v sha256sum >/dev/null 2>&1; then + ( cd "$TMP" && sha256sum -c "$ASSET.sha256" >/dev/null ) || err "checksum mismatch." + elif command -v shasum >/dev/null 2>&1; then + ( cd "$TMP" && shasum -a 256 -c "$ASSET.sha256" >/dev/null ) || err "checksum mismatch." + else + say " (no sha256 tool; skipping verification)" + fi + say " checksum OK" +else + say " (no checksum published; skipping verification)" +fi + +# --- install --- +say "==> Installing to $ASB_HOME" +rm -rf "$ASB_HOME" +mkdir -p "$ASB_HOME" +tar -xzf "$TMP/$ASSET" -C "$ASB_HOME" --strip-components=1 + +mkdir -p "$ASB_BIN" +LAUNCH="$ASB_BIN/altinity-sql-browser" +cat > "$LAUNCH" </dev/null || echo "$TAG"). Launcher: $LAUNCH" +case ":$PATH:" in + *":$ASB_BIN:"*) ;; + *) say ""; say "NOTE: $ASB_BIN is not on your PATH. Add it:"; say " export PATH=\"$ASB_BIN:\$PATH\"" ;; +esac +say "" +say "Sample connections written to $CHC/sql-browser.xml (your config.xml is untouched)." +say "The runner merges both files." +say "" +say "Run it:" +say " altinity-sql-browser # → http://localhost:8900/sql" diff --git a/src/ui/app.js b/src/ui/app.js index c015862..2e95b23 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -99,9 +99,11 @@ export function createApp(env = {}) { app.updateLibraryTitle = () => renderLibraryTitle(app); // --- identity ---------------------------------------------------------- - app.host = () => (app.authMode === 'basic' - ? originHost(chCtx.origin) || 'clickhouse' - : loc.host || 'clickhouse'); + // The host queries actually go to. chCtx.origin already resolves to the basic + // target, the picked OAuth cluster (oauth_origin), or the serving origin — so a + // cross-origin OAuth connection shows the cluster, not localhost. (URL.host drops + // a default :443, so a 443 cluster shows a bare hostname; an 8443 one shows :8443.) + app.host = () => originHost(chCtx.origin) || 'clickhouse'; app.activeTab = () => activeTab(app.state); // A `?host=` query param pre-fills the credential server address on the login // screen (and disables SSO, which only targets the serving host). diff --git a/src/ui/login.js b/src/ui/login.js index 928a7e2..032dd3b 100644 --- a/src/ui/login.js +++ b/src/ui/login.js @@ -33,7 +33,10 @@ export function renderLogin(app, errorMsg) { let advOpen = !!hostHint; let ssoBtns = []; - const hasCreds = () => userInput.value.trim().length > 0 && passInput.value.length > 0; + // A username is enough to connect — the password is optional, since passwordless + // users are common on demo/playground clusters (e.g. ClickHouse `play`). An empty + // password sends HTTP Basic `user:` which ClickHouse accepts. + const hasCreds = () => userInput.value.trim().length > 0; // --- credential fields --- const fld = (over) => h('input', { diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index dd306f8..f306599 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -622,6 +622,12 @@ describe('auth flows', () => { const e = env({ sessionStorage: memSession({ oauth_id_token: validToken, oauth_origin: 'https://antalya.demo.altinity.cloud' }) }); expect(createApp(e).chCtx.origin).toBe('https://antalya.demo.altinity.cloud'); }); + it('header shows the picked cluster, not the serving host, for cross-origin oauth', () => { + // Serving host is ch.example; the picked cluster is antalya on :443 (default + // https port → URL.host drops it, so the header is the bare cluster hostname). + const e = env({ sessionStorage: memSession({ oauth_id_token: validToken, oauth_origin: 'https://antalya.demo.altinity.cloud:443' }) }); + expect(createApp(e).host()).toBe('antalya.demo.altinity.cloud'); + }); }); describe('credentials (basic) sign-in', () => { diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js index f779cf1..d627dbc 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -72,6 +72,16 @@ describe('renderLogin — host picker', () => { expect([host.value, user.value, pass.value]).toEqual(['http://localhost:8123', 'default', 'pw']); expect(app.root.querySelector('.login-adv-field').style.display).toBe(''); }); + it('selecting a passwordless basic host (empty password) still enables Connect', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts: [ + { label: 'clickhouse-sql', url: 'https://sql-clickhouse.clickhouse.com:8443', auth: 'basic', user: 'play', password: '', idp: '' }, + ] }) }); + renderLogin(app); await tick(); + selectHost(app.root, '0'); + const [user, pass] = app.root.querySelectorAll('.login-input'); + expect([user.value, pass.value]).toEqual(['play', '']); + expect(app.root.querySelector('.login-creds .login-btn').disabled).toBe(false); + }); it('selecting an OAuth host starts SSO against that cluster', async () => { const login = vi.fn(async () => {}); const app = withHosts({ actions: { login } }); @@ -281,6 +291,18 @@ describe('renderLogin — credentials reactivity', () => { expect(sso.classList.contains('btn-ghost')).toBe(true); expect(app.root.querySelector('.lt-as').textContent).toBe('as default'); }); + it('a username alone enables Connect — password is optional (passwordless demos like `play`)', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) }); + renderLogin(app); + await tick(); + const [user] = app.root.querySelectorAll('.login-input'); + const connect = app.root.querySelector('.login-creds .login-btn'); + expect(connect.disabled).toBe(true); // nothing typed yet + type(user, 'play'); // username only, no password + expect(connect.disabled).toBe(false); + expect(connect.classList.contains('btn-primary')).toBe(true); + expect(app.root.querySelector('.lt-as').textContent).toBe('as play'); + }); it('the host field drives the target host', () => { const app = appWith(); renderLogin(app); @@ -336,16 +358,19 @@ describe('renderLogin — connect flow', () => { await tick(); expect(connect).toHaveBeenCalled(); }); - it('Enter without both credentials does nothing; other keys ignored', async () => { + it('Enter is a no-op with no username and for non-Enter keys; submits once a username is present', async () => { const connect = vi.fn(async () => {}); const app = appWith({ actions: { connect } }); renderLogin(app); const [user] = app.root.querySelectorAll('.login-input'); - type(user, 'only-user'); - user.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - user.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + user.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); // empty → no-op + type(user, 'play'); + user.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); // non-Enter → ignored await tick(); expect(connect).not.toHaveBeenCalled(); + user.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); // username present → submits (no password) + await tick(); + expect(connect).toHaveBeenCalledWith({ username: 'play', password: '', host: '' }); }); it('clicking Connect with empty fields is a no-op', async () => { const connect = vi.fn(async () => {});