From 7c21254f3146c4c9f1316ffd178c02aec7c4e263 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 12 Jun 2026 10:40:37 +0200 Subject: [PATCH 1/7] feat(cli): add chfx CLI with decode command (items 1 + 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A publish-ready npm bin (`chfx`) that decodes ClickHouse wire-format dumps to structured JSON for humans and agents. Reuses the src/core decoders (DOM-free) and bundles to a single ESM file via esbuild. - `chfx decode [file]`: decode a .chproto capture, raw Native body, or raw RowBinaryWithNamesAndTypes body. Autodetects .chproto by magic and raw bodies by trial decode; `--format` forces it. Reads stdin when no path (or `-`) is given. `--protocol-version` sets the Native client version. - Output: the web ParsedData/AstNode tree as JSON, a top-level `bytesHex` (whole decoded buffer once), and per-node inline `bytes` by default so a consumer can read a value's bytes without slicing by range (`--no-node-bytes` to omit). bigints → decimal strings, byte blobs → hex. - Agent-friendly: deterministic JSON on stdout, JSON error envelope on stderr, exit codes (0 ok / 2 usage / 1 io|decode), `--help`/`--version`, non-interactive. Packaging: `bin`/`files`/`prepublishOnly` wired; `npm run cli` (tsx, dev) and `npm run cli:build` (esbuild → dist/cli/index.js). Adds esbuild, tsx, @types/node devDeps. Tests: src/cli/cli.test.ts — arg parsing, decode of every protocol fixture (+ bigint-safe serialization), hand-built Native/RowBinary bodies, autodetect + override, per-node bytes match their range, and tsx end-to-end (stdin, exit codes). README + AGENTS.md + docs/cli-spec.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 12 + README.md | 68 ++++ docs/cli-spec.md | 23 +- package-lock.json | 738 +++++++++++++++++++++++++++++++------ package.json | 14 +- scripts/build-cli.mjs | 26 ++ src/cli/args.ts | 81 ++++ src/cli/cli.test.ts | 217 +++++++++++ src/cli/commands/decode.ts | 216 +++++++++++ src/cli/index.ts | 79 ++++ src/cli/output.ts | 69 ++++ src/cli/registry.ts | 47 +++ src/cli/version.ts | 10 + todo.md | 20 +- 14 files changed, 1493 insertions(+), 127 deletions(-) create mode 100644 scripts/build-cli.mjs create mode 100644 src/cli/args.ts create mode 100644 src/cli/cli.test.ts create mode 100644 src/cli/commands/decode.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/output.ts create mode 100644 src/cli/registry.ts create mode 100644 src/cli/version.ts diff --git a/AGENTS.md b/AGENTS.md index eaf9738..395e036 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,10 @@ npm run test # Run integration tests (uses testcontainers) npm run lint # ESLint check npm run test:e2e # Build Electron + run Playwright e2e tests +# CLI (chfx) — decode wire-format dumps to structured JSON +npm run cli -- decode capture.chproto # run from source via tsx +npm run cli:build # bundle the publishable binary → dist/cli/index.js + # Electron desktop app npm run electron:dev # Dev mode with hot reload npm run electron:build # Package desktop installer for current platform @@ -78,6 +82,14 @@ src/ │ └── request-params.ts # Shared request parameter builder ├── store/ │ └── store.ts # Zustand store (query, parsed data, UI state) +├── cli/ # chfx CLI (Node, bundled via esbuild; reuses src/core decoders) +│ ├── index.ts # Entry: command dispatch, --help/--version, JSON error envelope +│ ├── commands/decode.ts# `decode` — decodeBuffer() (chproto/Native/RowBinary, autodetect) + envelope +│ ├── args.ts # Dependency-free arg parser +│ ├── output.ts # CliError, JSON-safe serializer (bigint→string, bytes→hex) +│ ├── registry.ts # Command metadata for --help +│ ├── version.ts # Build-injected version (esbuild define) +│ └── cli.test.ts # Vitest unit + tsx e2e tests (uses fixtures/protocol/*.chproto) └── styles/ # CSS files electron/ ├── main.ts # Electron main process (window, IPC handlers) diff --git a/README.md b/README.md index a69934f..63f3ead 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A tool for visualizing ClickHouse RowBinary and Native format data. Features an - **Interactive Highlighting**: Selecting a node in the tree highlights corresponding bytes in the hex view (and vice versa) - **Full Type Support**: All ClickHouse types including Variant, Dynamic, JSON, Geo types, Nested, etc. - **Desktop App**: Electron app that connects to your existing ClickHouse server (no bundled DB) +- **CLI (`chfx`)**: Decode `.chproto` / Native / RowBinary dumps to structured JSON from the terminal — agent-friendly ## Quick Start (Docker) @@ -41,6 +42,73 @@ CH_VERSION=24.3 docker compose build The version is baked into the image — rebuild to change it. +## CLI (`chfx`) + +A command-line tool that decodes a binary wire-format dump into structured JSON — +the same AST the web UI renders, plus the raw bytes — so it can be scripted or +driven by an agent. + +### Quick start + +```bash +# from a checkout (no build needed) +npm install +npm run cli -- decode capture.chproto # decode a native-protocol capture + +# or build the standalone binary and run it +npm run cli:build +node dist/cli/index.js decode result.native --format native + +# pipe bytes in from anywhere +clickhouse-client -q "SELECT 1 FORMAT Native" | node dist/cli/index.js decode -f native - +``` + +Output is a single JSON document on **stdout**; diagnostics and a JSON error +envelope go to **stderr**. Exit codes: `0` success, `2` usage error, `1` I/O or +decode error. + +### Commands + +| Command | Description | +|---------|-------------| +| `chfx decode [file]` | Decode a `.chproto`, Native, or RowBinary dump to JSON. Reads stdin when no file (or `-`) is given. | +| `chfx --help` / `chfx --help` | Human-readable help. | +| `chfx --version` | Print the version. | + +### `decode` options + +| Option | Description | +|--------|-------------| +| `--format`, `-f` `` | Force the decoder. Omitted → autodetect: `.chproto` by magic header, raw bodies by trial decode (ambiguous input errors and asks for `--format`). | +| `--protocol-version ` | Native `client_protocol_version` used to interpret a raw Native body (default `0`). | +| `--no-node-bytes` | Omit each node's inline raw bytes (consumers slice `bytesHex` by range instead). Smaller output. | +| `--compact` | Emit single-line JSON instead of pretty-printed. | + +### Output shape + +```jsonc +{ + "chfx": { "tool": "chfx", "version": "...", "schemaVersion": 1, "command": "decode" }, + "source": { "kind": "file", "path": "...", "byteLength": 2417 }, + "format": "NativeProtocol", // | Native | RowBinaryWithNamesAndTypes + "formatDetected": true, // false when forced via --format + "protocolVersion": 54482, // negotiated (chproto) / requested (native) / null (rowbinary) + "nodeBytes": true, // false when --no-node-bytes was passed + "protocol": { "negotiatedVersion": 54482, "c2sLength": 191, "dumpMeta": { ... } }, + "bytesHex": "0011436c...", // the whole decoded buffer, encoded once + "data": { /* ParsedData: header, rows|blocks, trailingNodes, metadata */ } +} +``` + +Every node has a `byteRange` of `{start, end}` byte offsets into `bytesHex` (two +hex chars per byte; `start` inclusive, `end` exclusive). By default each node +**also carries its own raw bytes inline** as a `bytes` hex string, so a consumer +can read the bytes behind any value without slicing `bytesHex` itself — pass +`--no-node-bytes` to drop them for smaller output. + +> Decoded values are JSON-safe: 64-bit and larger integers become decimal +> strings, and raw byte blobs become hex. + ## Desktop App For developers who already run ClickHouse locally. Download the latest release for your platform from the [Releases](../../releases) page: diff --git a/docs/cli-spec.md b/docs/cli-spec.md index 867c299..30c3dba 100644 --- a/docs/cli-spec.md +++ b/docs/cli-spec.md @@ -66,17 +66,26 @@ through it — the proxy does not spawn the client itself. - **Plaintext/uncompressed only** (same constraint as today). TLS/compressed streams are unsupported — error clearly and document it. -#### `chfx schema` / `--help` -Machine-readable description of commands, flags, and the output JSON shape for -agent discovery. +#### `--help` +Human-readable help (`chfx --help`, `chfx --help`). A standalone +machine-readable `schema` command was considered but **dropped** while the CLI +has a single real command: `--help` covers human discovery and the `decode` +output is already self-describing (carries `schemaVersion` and the byteRange/bytes +conventions inline). Revisit a structured-discovery surface (a `schema` command +or `--help --json`) once `query`/`proxy` add a multi-command contract. ### Output (item 4) - **Reuse the web `ParsedData`/`AstNode` shape verbatim**, serialized to JSON, wrapped with top-level metadata: tool/schema version, format, negotiated - protocol version (for captures), and the raw bytes. -- **Raw bytes inline, once**, as a top-level **hex** string. Agents read a node's - `byteRange {start, end}` (exclusive end) and slice the hex to inspect bytes — - no second command or sidecar file. + protocol version (for captures), `nodeBytes`, and the raw bytes. +- **Top-level `bytesHex`**: the whole decoded buffer encoded once as hex + (for NativeProtocol this is the combined c2s+s2c stream the ranges index into). +- **Per-node inline bytes (default on)**: every node with a `byteRange {start, + end}` (exclusive end) also carries its own raw bytes as a `bytes` hex string, + so a consumer reads a value's bytes directly without slicing `bytesHex`. This + trades output size (parents duplicate children's bytes) for convenience; + `--no-node-bytes` omits them and falls back to range lookups against `bytesHex`. +- JSON-safe values: bigints → decimal strings, byte blobs → hex. ### Tests & docs (cross-cutting) - **Thorough CLI tests/fixtures**: decode against the existing diff --git a/package-lock.json b/package-lock.json index 384823a..aff3e2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,21 +14,27 @@ "react-window": "^1.8.10", "zustand": "^5.0.3" }, + "bin": { + "chfx": "dist/cli/index.js" + }, "devDependencies": { "@eslint/js": "^9.17.0", "@playwright/test": "^1.58.2", "@testcontainers/clickhouse": "^11.11.0", + "@types/node": "^25.9.3", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", "electron": "^40.8.5", "electron-builder": "^26.7.0", + "esbuild": "^0.28.1", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", "testcontainers": "^11.11.0", + "tsx": "^4.22.4", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.4.2", @@ -787,9 +793,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -804,9 +810,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -821,9 +827,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -838,9 +844,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -855,9 +861,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -872,9 +878,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -889,9 +895,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -906,9 +912,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -923,9 +929,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -940,9 +946,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -957,9 +963,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -974,9 +980,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -991,9 +997,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1008,9 +1014,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1025,9 +1031,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1042,9 +1048,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1059,9 +1065,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1076,9 +1082,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1093,9 +1099,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1110,9 +1116,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1127,9 +1133,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1144,9 +1150,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1161,9 +1167,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1178,9 +1184,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1195,9 +1201,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1212,9 +1218,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -2367,15 +2373,22 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.0.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", - "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -5064,9 +5077,9 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5077,32 +5090,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -8789,6 +8802,25 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -9104,6 +9136,490 @@ "dev": true, "license": "MIT" }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/vitest": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", diff --git a/package.json b/package.json index 4e78d83..cdfc4d4 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,12 @@ "author": "alex-clickhouse ", "type": "module", "main": "dist-electron/main.js", + "bin": { + "chfx": "dist/cli/index.js" + }, + "files": [ + "dist/cli" + ], "scripts": { "dev": "vite", "build": "eslint . && tsc -b && vite build", @@ -14,9 +20,12 @@ "test": "vitest run", "test:watch": "vitest", "capture": "node scripts/capture-native.mjs", + "cli": "tsx src/cli/index.ts", + "cli:build": "node scripts/build-cli.mjs", "electron:dev": "ELECTRON=true vite dev", "electron:build": "ELECTRON=true vite build && electron-builder", - "test:e2e": "ELECTRON=true vite build && npx playwright test" + "test:e2e": "ELECTRON=true vite build && npx playwright test", + "prepublishOnly": "npm run cli:build" }, "dependencies": { "react": "^18.3.1", @@ -29,17 +38,20 @@ "@eslint/js": "^9.17.0", "@playwright/test": "^1.58.2", "@testcontainers/clickhouse": "^11.11.0", + "@types/node": "^25.9.3", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", "electron": "^40.8.5", "electron-builder": "^26.7.0", + "esbuild": "^0.28.1", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", "testcontainers": "^11.11.0", + "tsx": "^4.22.4", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.4.2", diff --git a/scripts/build-cli.mjs b/scripts/build-cli.mjs new file mode 100644 index 0000000..0913712 --- /dev/null +++ b/scripts/build-cli.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// Bundle the chfx CLI into a single self-contained ESM file for publishing. +// The decoders in src/core are dependency- and DOM-free, so esbuild can bundle +// them for Node. The package version is injected via `define` so the binary +// reports it without reading package.json at runtime. + +import esbuild from 'esbuild'; +import { readFileSync } from 'node:fs'; +import { chmodSync } from 'node:fs'; + +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); +const outfile = 'dist/cli/index.js'; + +await esbuild.build({ + entryPoints: ['src/cli/index.ts'], + bundle: true, + platform: 'node', + target: 'node20', + format: 'esm', + outfile, + banner: { js: '#!/usr/bin/env node' }, + define: { __CHFX_VERSION__: JSON.stringify(pkg.version) }, + logLevel: 'info', +}); + +chmodSync(outfile, 0o755); diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..1316db1 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,81 @@ +import { CliError } from './output'; + +export interface ParsedArgs { + positionals: string[]; + options: Record; +} + +export interface ArgSpec { + /** Option names (canonical) that consume the following token as a value. */ + valueFlags?: string[]; + /** Short/alternate name → canonical name. */ + aliases?: Record; +} + +/** + * Minimal, dependency-free argument parser. Supports `--flag`, `--flag value`, + * `--flag=value`, single-dash aliases (`-f`), `--` to end option parsing, and a + * lone `-` as a positional (stdin marker). Boolean flags are any option not in + * `valueFlags`. Unknown flags are accepted (validated per-command) so the + * parser stays generic. + */ +export function parseArgs(argv: string[], spec: ArgSpec = {}): ParsedArgs { + const valueFlags = new Set(spec.valueFlags ?? []); + const aliases = spec.aliases ?? {}; + const positionals: string[] = []; + const options: Record = {}; + + let i = 0; + let positionalOnly = false; + while (i < argv.length) { + const tok = argv[i++]; + + if (positionalOnly || tok === '-' || !tok.startsWith('-')) { + positionals.push(tok); + continue; + } + if (tok === '--') { + positionalOnly = true; + continue; + } + + const isLong = tok.startsWith('--'); + let name = tok.slice(isLong ? 2 : 1); + let inlineValue: string | undefined; + if (isLong) { + const eq = name.indexOf('='); + if (eq !== -1) { + inlineValue = name.slice(eq + 1); + name = name.slice(0, eq); + } + } + name = aliases[name] ?? name; + + if (inlineValue !== undefined) { + options[name] = inlineValue; + } else if (valueFlags.has(name)) { + if (i >= argv.length) { + throw new CliError('usage', `option --${name} requires a value`); + } + options[name] = argv[i++]; + } else { + options[name] = true; + } + } + + return { positionals, options }; +} + +/** Read an option as a string, or undefined if absent. Errors if it's a bare boolean flag. */ +export function stringOption(args: ParsedArgs, name: string): string | undefined { + const v = args.options[name]; + if (v === undefined) return undefined; + if (typeof v === 'boolean') { + throw new CliError('usage', `option --${name} requires a value`); + } + return v; +} + +export function boolOption(args: ParsedArgs, name: string): boolean { + return args.options[name] === true || args.options[name] === 'true'; +} diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts new file mode 100644 index 0000000..4b9b7fd --- /dev/null +++ b/src/cli/cli.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +import { decodeBuffer, decodeCommand } from './commands/decode'; +import { parseArgs, stringOption, boolOption } from './args'; +import { stringify, CliError } from './output'; +import { ClickHouseFormat } from '../core/types/formats'; + +const FIXTURE_DIR = fileURLToPath(new URL('../core/decoder/fixtures/protocol/', import.meta.url)); +const CLI_ENTRY = fileURLToPath(new URL('./index.ts', import.meta.url)); + +const fixtures = readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.chproto')); +const fixturePath = (name: string) => `${FIXTURE_DIR}${name}`; +const readFixture = (name: string) => new Uint8Array(readFileSync(fixturePath(name))); + +const enc = (s: string) => [...s].map((c) => c.charCodeAt(0)); +// Minimal valid bodies for a single column `x UInt8` with one row valued 1. +const ROWBINARY_BODY = new Uint8Array([0x01, 0x01, ...enc('x'), 0x05, ...enc('UInt8'), 0x01]); +const NATIVE_BODY = new Uint8Array([0x01, 0x01, 0x01, ...enc('x'), 0x05, ...enc('UInt8'), 0x01]); + +describe('parseArgs', () => { + it('parses positionals, long/short flags, =values, and value flags', () => { + const args = parseArgs(['file.bin', '--format', 'native', '-f', 'rowbinary', '--compact', '--protocol-version=54483'], { + valueFlags: ['format', 'protocol-version'], + aliases: { f: 'format' }, + }); + expect(args.positionals).toEqual(['file.bin']); + expect(args.options.format).toBe('rowbinary'); // later -f overrides + expect(args.options.compact).toBe(true); + expect(stringOption(args, 'protocol-version')).toBe('54483'); + expect(boolOption(args, 'compact')).toBe(true); + }); + + it('treats lone - as a positional and -- as end-of-options', () => { + const args = parseArgs(['-', '--', '--not-a-flag']); + expect(args.positionals).toEqual(['-', '--not-a-flag']); + }); + + it('throws a usage error when a value flag is missing its value', () => { + expect(() => parseArgs(['--format'], { valueFlags: ['format'] })).toThrow(CliError); + }); +}); + +describe('decodeBuffer — protocol fixtures', () => { + it('has fixtures to test', () => { + expect(fixtures.length).toBeGreaterThan(0); + }); + + it.each(fixtures)('decodes %s as NativeProtocol with a negotiated version', (name) => { + const bytes = readFixture(name); + const result = decodeBuffer(bytes); + expect(result.format).toBe(ClickHouseFormat.NativeProtocol); + expect(result.formatDetected).toBe(true); + expect(typeof result.protocolVersion).toBe('number'); + expect(result.protocol?.c2sLength).toBeGreaterThan(0); + // outputBytes is the combined c2s+s2c stream the byteRanges index into, + // so it strictly exceeds the client→server portion alone. + expect(result.protocol!.c2sLength).toBeLessThan(result.outputBytes.length); + }); + + it.each(fixtures)('serializes %s to JSON without throwing (bigint/byte-safe)', (name) => { + const result = decodeBuffer(readFixture(name)); + // The whole ParsedData must survive serialization (decoded values include bigints). + const json = stringify({ data: result.parsed, bytesHex: Buffer.from(result.outputBytes).toString('hex') }, true); + const round = JSON.parse(json); + expect(typeof round.bytesHex).toBe('string'); + expect(round.bytesHex.length % 2).toBe(0); + expect(round.bytesHex.length / 2).toBe(result.outputBytes.length); + }); +}); + +describe('decodeBuffer — raw bodies', () => { + it('decodes a RowBinary body with explicit --format and via autodetect', () => { + const forced = decodeBuffer(ROWBINARY_BODY, { format: 'rowbinary' }); + expect(forced.format).toBe(ClickHouseFormat.RowBinaryWithNamesAndTypes); + expect(forced.formatDetected).toBe(false); + expect(forced.parsed.rows?.[0]?.values?.[0]?.value).toBe(1); + + const auto = decodeBuffer(ROWBINARY_BODY); + expect(auto.format).toBe(ClickHouseFormat.RowBinaryWithNamesAndTypes); + expect(auto.formatDetected).toBe(true); + }); + + it('decodes a Native body with explicit --format and via autodetect', () => { + const forced = decodeBuffer(NATIVE_BODY, { format: 'native' }); + expect(forced.format).toBe(ClickHouseFormat.Native); + expect(forced.protocolVersion).toBe(0); + + const auto = decodeBuffer(NATIVE_BODY); + expect(auto.format).toBe(ClickHouseFormat.Native); + expect(auto.formatDetected).toBe(true); + }); + + it('throws a usage error when nothing decodes', () => { + const garbage = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + expect(() => decodeBuffer(garbage)).toThrow(CliError); + }); + + it('propagates a decode failure when forced to the wrong format', () => { + const chproto = readFixture(fixtures[0]); + expect(() => decodeBuffer(chproto, { format: 'rowbinary' })).toThrow(); + }); +}); + +describe('decodeCommand (file + envelope)', () => { + it('builds the full envelope from a file path', async () => { + interface DecodeEnvelope { + chfx: Record; + source: Record; + format: string; + conventions: { byteRange: string }; + bytesHex: string; + protocol: { c2sLength: number }; + } + const { data: rawData, compact } = await decodeCommand([fixturePath(fixtures[0]), '--compact']); + const data = rawData as DecodeEnvelope; + expect(compact).toBe(true); + expect(data.chfx).toMatchObject({ tool: 'chfx', command: 'decode' }); + expect(data.source).toMatchObject({ kind: 'file' }); + expect(data.format).toBe('NativeProtocol'); + expect(data.conventions.byteRange).toContain('byteRange'); + expect(data.bytesHex.length / 2).toBeGreaterThan(0); + // bytesHex covers the combined stream the ranges index into (c2s + s2c). + expect(data.protocol.c2sLength).toBeLessThan(data.bytesHex.length / 2); + }); + + it('rejects an unknown --format value', async () => { + await expect(decodeCommand([fixturePath(fixtures[0]), '--format', 'bogus'])).rejects.toThrow(CliError); + }); +}); + +describe('per-node inline bytes', () => { + interface Envelope { + nodeBytes: boolean; + bytesHex: string; + data: unknown; + } + + function findNodeWithBytes(v: unknown): { start: number; end: number; bytes: string } | null { + if (Array.isArray(v)) { + for (const item of v) { + const found = findNodeWithBytes(item); + if (found) return found; + } + return null; + } + if (v && typeof v === 'object') { + const o = v as Record; + const br = o.byteRange as { start?: unknown; end?: unknown } | undefined; + if (br && typeof br.start === 'number' && typeof br.end === 'number' && typeof o.bytes === 'string') { + return { start: br.start, end: br.end, bytes: o.bytes }; + } + for (const key of Object.keys(o)) { + const found = findNodeWithBytes(o[key]); + if (found) return found; + } + } + return null; + } + + function anyBytesField(v: unknown): boolean { + if (Array.isArray(v)) return v.some(anyBytesField); + if (v && typeof v === 'object') { + const o = v as Record; + if ('bytes' in o) return true; + return Object.values(o).some(anyBytesField); + } + return false; + } + + it("attaches each node's bytes inline, matching its byteRange slice of bytesHex", async () => { + const { data } = await decodeCommand([fixturePath(fixtures[0])]); + const env = data as Envelope; + expect(env.nodeBytes).toBe(true); + const node = findNodeWithBytes(env.data); + expect(node).not.toBeNull(); + expect(node!.bytes).toBe(env.bytesHex.slice(node!.start * 2, node!.end * 2)); + }); + + it('omits inline bytes with --no-node-bytes', async () => { + const { data } = await decodeCommand([fixturePath(fixtures[0]), '--no-node-bytes']); + const env = data as Envelope; + expect(env.nodeBytes).toBe(false); + expect(anyBytesField(env.data)).toBe(false); + }); +}); + +describe('end-to-end via tsx (entry, stdin, exit codes)', () => { + const run = (args: string[], input?: Buffer) => + execFileSync('npx', ['tsx', CLI_ENTRY, ...args], { input, stdio: ['pipe', 'pipe', 'pipe'] }).toString(); + + it('decodes a fixture file and prints parseable JSON (exit 0)', () => { + const out = run(['decode', fixturePath(fixtures[0]), '--compact']); + expect(JSON.parse(out).format).toBe('NativeProtocol'); + }, 30000); + + it('decodes from stdin', () => { + const out = run(['decode', '--compact'], Buffer.from(readFixture(fixtures[0]))); + expect(JSON.parse(out).source.kind).toBe('stdin'); + }, 30000); + + it('exits non-zero with a JSON error envelope on bad input', () => { + let code = 0; + let stderr = ''; + try { + execFileSync('npx', ['tsx', CLI_ENTRY, 'decode'], { input: Buffer.from('garbage'), stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (err) { + const e = err as { status: number; stderr: Buffer }; + code = e.status; + stderr = e.stderr.toString(); + } + expect(code).toBe(2); + expect(JSON.parse(stderr).error.kind).toBe('usage'); + }, 30000); +}); diff --git a/src/cli/commands/decode.ts b/src/cli/commands/decode.ts new file mode 100644 index 0000000..db194bd --- /dev/null +++ b/src/cli/commands/decode.ts @@ -0,0 +1,216 @@ +import process from 'node:process'; +import { readFile } from 'node:fs/promises'; +import { Buffer } from 'node:buffer'; + +import { ClickHouseFormat } from '../../core/types/formats'; +import type { ParsedData } from '../../core/types/ast'; +import { createDecoder, ProtocolDecoder } from '../../core/decoder'; +import { parseChprotoDump } from '../../core/decoder/protocol-dump'; +import { DEFAULT_NATIVE_PROTOCOL_VERSION } from '../../core/types/native-protocol'; + +import { CliError } from '../output'; +import { CHFX_VERSION, CLI_SCHEMA_VERSION } from '../version'; +import { parseArgs, stringOption, boolOption } from '../args'; + +export const FORMAT_NAMES = ['chproto', 'native', 'rowbinary'] as const; +export type FormatName = (typeof FORMAT_NAMES)[number]; + +const CHPROTO_MAGIC = 'CHPROTO1'; + +interface DecodeCore { + format: ClickHouseFormat; + /** Version negotiated (chproto) or requested (native); null for RowBinary. */ + protocolVersion: number | null; + /** Buffer that every AstNode.byteRange indexes into (combined stream for chproto). */ + outputBytes: Uint8Array; + parsed: ParsedData; + /** Present only for NativeProtocol captures. */ + protocol?: { negotiatedVersion: number | null; c2sLength: number; dumpMeta: Record }; +} + +export interface DecodeResult extends DecodeCore { + formatDetected: boolean; +} + +function isChproto(bytes: Uint8Array): boolean { + if (bytes.length < CHPROTO_MAGIC.length) return false; + for (let i = 0; i < CHPROTO_MAGIC.length; i++) { + if (bytes[i] !== CHPROTO_MAGIC.charCodeAt(i)) return false; + } + return true; +} + +function decodeChproto(bytes: Uint8Array): DecodeCore { + const { c2s, s2c, meta } = parseChprotoDump(bytes); + const combined = new Uint8Array(c2s.length + s2c.length); + combined.set(c2s, 0); + combined.set(s2c, c2s.length); + const parsed = new ProtocolDecoder(combined, c2s.length, meta).decode(); + const negotiated = parsed.metadata?.negotiatedVersion; + return { + format: ClickHouseFormat.NativeProtocol, + protocolVersion: typeof negotiated === 'number' ? negotiated : null, + outputBytes: combined, + parsed, + protocol: { + negotiatedVersion: typeof negotiated === 'number' ? negotiated : null, + c2sLength: c2s.length, + dumpMeta: meta ?? {}, + }, + }; +} + +function decodeNative(bytes: Uint8Array, protocolVersion: number): DecodeCore { + const parsed = createDecoder(bytes, ClickHouseFormat.Native, { nativeProtocolVersion: protocolVersion }).decode(); + return { format: ClickHouseFormat.Native, protocolVersion, outputBytes: bytes, parsed }; +} + +function decodeRowBinary(bytes: Uint8Array): DecodeCore { + const parsed = createDecoder(bytes, ClickHouseFormat.RowBinaryWithNamesAndTypes).decode(); + return { format: ClickHouseFormat.RowBinaryWithNamesAndTypes, protocolVersion: null, outputBytes: bytes, parsed }; +} + +/** + * Decode a raw buffer. `format` forces a decoder; when omitted, `.chproto` is + * detected by its magic header and raw bodies are autodetected best-effort by + * trial decode (RowBinary vs Native). Ambiguous or unrecognized input is a + * usage error directing the caller to pass `--format`. + */ +export function decodeBuffer( + bytes: Uint8Array, + opts: { format?: FormatName; protocolVersion?: number } = {}, +): DecodeResult { + const version = opts.protocolVersion ?? DEFAULT_NATIVE_PROTOCOL_VERSION; + + if (opts.format) { + const core = + opts.format === 'chproto' + ? decodeChproto(bytes) + : opts.format === 'native' + ? decodeNative(bytes, version) + : decodeRowBinary(bytes); + return { ...core, formatDetected: false }; + } + + if (isChproto(bytes)) { + return { ...decodeChproto(bytes), formatDetected: true }; + } + + // Raw body: trial-decode each candidate; a wrong format almost always throws. + const matched: DecodeCore[] = []; + for (const run of [() => decodeRowBinary(bytes), () => decodeNative(bytes, version)]) { + try { + matched.push(run()); + } catch { + // not this format + } + } + if (matched.length === 1) { + return { ...matched[0], formatDetected: true }; + } + throw new CliError( + 'usage', + matched.length === 0 + ? 'could not autodetect format; pass --format chproto|native|rowbinary' + : 'format is ambiguous (raw Native and RowBinary bodies look alike); pass --format native|rowbinary', + { matched: matched.map((m) => m.format) }, + ); +} + +function asByteRange(v: unknown): { start: number; end: number } | null { + if (v && typeof v === 'object') { + const r = v as { start?: unknown; end?: unknown }; + if (typeof r.start === 'number' && typeof r.end === 'number') return { start: r.start, end: r.end }; + } + return null; +} + +/** + * Deep-clone the decoded tree, attaching each node's own raw bytes (hex) inline + * as `bytes` for every object carrying a {start, end} byteRange. Lets a consumer + * read a node's bytes directly without slicing the top-level bytesHex. Parent + * nodes contain their children's bytes, so this trades output size for + * convenience; disable with `--no-node-bytes`. + */ +function attachNodeBytes(value: unknown, buffer: Uint8Array): unknown { + if (Array.isArray(value)) return value.map((v) => attachNodeBytes(v, buffer)); + if (value instanceof Uint8Array || value instanceof Map || value instanceof Set) return value; + if (value && typeof value === 'object') { + const src = value as Record; + const out: Record = {}; + for (const key of Object.keys(src)) out[key] = attachNodeBytes(src[key], buffer); + const range = asByteRange(src.byteRange); + if (range) out.bytes = Buffer.from(buffer.subarray(range.start, range.end)).toString('hex'); + return out; + } + return value; +} + +async function readInput(path: string | undefined): Promise<{ bytes: Uint8Array; source: Record }> { + if (path && path !== '-') { + try { + const buf = await readFile(path); + return { bytes: new Uint8Array(buf), source: { kind: 'file', path, byteLength: buf.length } }; + } catch (err) { + throw new CliError('io', `cannot read file: ${path}`, { cause: (err as Error).message }); + } + } + if (process.stdin.isTTY) { + throw new CliError('usage', 'no input: pass a file path or pipe binary data to stdin'); + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + const buf = Buffer.concat(chunks); + return { bytes: new Uint8Array(buf), source: { kind: 'stdin', byteLength: buf.length } }; +} + +function parseProtocolVersion(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + throw new CliError('usage', `--protocol-version must be a non-negative integer, got: ${raw}`); + } + return n; +} + +export async function decodeCommand(rest: string[]): Promise<{ data: unknown; compact: boolean }> { + const args = parseArgs(rest, { + valueFlags: ['format', 'protocol-version'], + aliases: { f: 'format' }, + }); + + const format = stringOption(args, 'format') as FormatName | undefined; + if (format && !FORMAT_NAMES.includes(format)) { + throw new CliError('usage', `unknown --format '${format}'; expected one of ${FORMAT_NAMES.join(', ')}`); + } + const protocolVersion = parseProtocolVersion(stringOption(args, 'protocol-version')); + const compact = boolOption(args, 'compact'); + const includeNodeBytes = !boolOption(args, 'no-node-bytes'); + + const { bytes, source } = await readInput(args.positionals[0]); + if (bytes.length === 0) { + throw new CliError('usage', 'input is empty'); + } + + const result = decodeBuffer(bytes, { format, protocolVersion }); + + const data = { + chfx: { tool: 'chfx', version: CHFX_VERSION, schemaVersion: CLI_SCHEMA_VERSION, command: 'decode' }, + source, + format: result.format, + formatDetected: result.formatDetected, + protocolVersion: result.protocolVersion, + nodeBytes: includeNodeBytes, + ...(result.protocol ? { protocol: result.protocol } : {}), + conventions: { + byteRange: + 'Each node has byteRange {start, end} into bytesHex (2 hex chars per byte; start inclusive, end exclusive).', + bytes: + 'When nodeBytes is true, each node also carries its own raw bytes inline as "bytes" (hex); pass --no-node-bytes to omit and slice bytesHex by range instead.', + }, + bytesHex: Buffer.from(result.outputBytes).toString('hex'), + data: includeNodeBytes ? attachNodeBytes(result.parsed, result.outputBytes) : result.parsed, + }; + + return { data, compact }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..aee7c63 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,79 @@ +import process from 'node:process'; + +import { CliError, emitError, writeStdout, stringify } from './output'; +import { CHFX_VERSION } from './version'; +import { COMMANDS, findCommand, type CommandDoc } from './registry'; +import { decodeCommand } from './commands/decode'; + +function generalHelp(): string { + const lines = [ + 'chfx — ClickHouse Format Explorer CLI', + '', + 'Decode ClickHouse wire-format dumps into structured JSON for humans and agents.', + '', + 'Usage: chfx [options]', + '', + 'Commands:', + ...COMMANDS.map((c) => ` ${c.name.padEnd(8)} ${c.summary}`), + '', + 'Global:', + ' --help, -h Show help (per command: chfx --help)', + ' --version, -V Print version', + '', + 'Output: a single JSON document on stdout; diagnostics and a JSON error', + 'envelope on stderr. Run "chfx schema" for machine-readable documentation.', + ]; + return lines.join('\n'); +} + +function commandHelp(doc: CommandDoc): string { + const lines = [doc.summary, '', `Usage: ${doc.usage}`]; + if (doc.details) lines.push('', doc.details); + lines.push('', 'Options:'); + const width = Math.max(...doc.options.map((o) => `${o.flag}${o.value ? ` <${o.value}>` : ''}`.length)); + for (const o of doc.options) { + const left = `${o.flag}${o.value ? ` <${o.value}>` : ''}`.padEnd(width); + lines.push(` ${left} ${o.description}`); + } + return lines.join('\n'); +} + +async function run(argv: string[]): Promise { + const [command, ...rest] = argv; + + if (!command || command === 'help' || command === '--help' || command === '-h') { + writeStdout(generalHelp()); + return 0; + } + if (command === '--version' || command === '-V') { + writeStdout(CHFX_VERSION); + return 0; + } + + if (rest.includes('--help') || rest.includes('-h')) { + const doc = findCommand(command); + if (!doc) throw new CliError('usage', `unknown command: ${command} (try: chfx --help)`); + writeStdout(commandHelp(doc)); + return 0; + } + + let out: { data: unknown; compact: boolean }; + switch (command) { + case 'decode': + out = await decodeCommand(rest); + break; + default: + throw new CliError('usage', `unknown command: ${command} (try: chfx --help)`); + } + + writeStdout(stringify(out.data, out.compact)); + return 0; +} + +run(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((err) => { + process.exitCode = emitError(err); + }); diff --git a/src/cli/output.ts b/src/cli/output.ts new file mode 100644 index 0000000..14df31f --- /dev/null +++ b/src/cli/output.ts @@ -0,0 +1,69 @@ +import process from 'node:process'; +import { Buffer } from 'node:buffer'; + +/** + * Error type carrying a machine-readable kind and process exit code. Thrown + * anywhere in the CLI and rendered to stderr as a JSON envelope by the entry + * point. `usage` errors exit 2 (bad invocation); everything else exits 1. + */ +export class CliError extends Error { + constructor( + public readonly kind: 'usage' | 'io' | 'decode', + message: string, + public readonly details?: Record, + ) { + super(message); + this.name = 'CliError'; + } + + get exitCode(): number { + return this.kind === 'usage' ? 2 : 1; + } + + get code(): string { + return `E_${this.kind.toUpperCase()}`; + } +} + +/** + * JSON.stringify replacer that makes decoded values safe to serialize: + * bigint → decimal string, byte arrays → hex, Map/Set → plain structures. + */ +export function jsonReplacer(_key: string, value: unknown): unknown { + if (typeof value === 'bigint') return value.toString(); + if (value instanceof Uint8Array) return Buffer.from(value).toString('hex'); + if (value instanceof Map) return Object.fromEntries(value as Map); + if (value instanceof Set) return Array.from(value as Set); + return value; +} + +/** Serialize a result object. Pretty (2-space) by default; compact on request. */ +export function stringify(obj: unknown, compact: boolean): string { + return JSON.stringify(obj, jsonReplacer, compact ? undefined : 2); +} + +export function writeStdout(text: string): void { + process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); +} + +export function writeStderr(text: string): void { + process.stderr.write(text.endsWith('\n') ? text : `${text}\n`); +} + +/** Render any thrown value as a JSON error envelope on stderr; return exit code. */ +export function emitError(err: unknown): number { + const cli = + err instanceof CliError + ? err + : new CliError('decode', err instanceof Error ? err.message : String(err)); + const payload = { + error: { + code: cli.code, + kind: cli.kind, + message: cli.message, + ...(cli.details ? { details: cli.details } : {}), + }, + }; + writeStderr(JSON.stringify(payload, jsonReplacer, 2)); + return cli.exitCode; +} diff --git a/src/cli/registry.ts b/src/cli/registry.ts new file mode 100644 index 0000000..7fa98b0 --- /dev/null +++ b/src/cli/registry.ts @@ -0,0 +1,47 @@ +export interface OptionDoc { + flag: string; + value?: string; + description: string; +} + +export interface CommandDoc { + name: string; + summary: string; + usage: string; + details?: string; + options: OptionDoc[]; +} + +/** Single source of truth for `chfx schema` and the human `--help` text. */ +export const COMMANDS: CommandDoc[] = [ + { + name: 'decode', + summary: 'Decode a binary dump (.chproto / Native / RowBinary) to structured JSON.', + usage: 'chfx decode [file] [--format chproto|native|rowbinary] [--protocol-version N] [--compact]', + details: 'Reads from , or from stdin when no path is given (or path is "-").', + options: [ + { + flag: '--format, -f', + value: 'chproto|native|rowbinary', + description: + 'Force the decoder. Omit to autodetect: .chproto by magic header, raw bodies by trial decode (ambiguous → error asking for --format).', + }, + { + flag: '--protocol-version', + value: 'N', + description: 'Native client_protocol_version used to interpret a raw Native body (default 0).', + }, + { + flag: '--no-node-bytes', + description: + "Omit each node's inline raw bytes; consumers slice the top-level bytesHex by byteRange instead. Smaller output.", + }, + { flag: '--compact', description: 'Emit single-line JSON instead of pretty-printed (2-space) JSON.' }, + { flag: '--help, -h', description: 'Show help for this command.' }, + ], + }, +]; + +export function findCommand(name: string): CommandDoc | undefined { + return COMMANDS.find((c) => c.name === name); +} diff --git a/src/cli/version.ts b/src/cli/version.ts new file mode 100644 index 0000000..3c91900 --- /dev/null +++ b/src/cli/version.ts @@ -0,0 +1,10 @@ +// Injected at build time by scripts/build-cli.mjs via esbuild `define`. Under +// `tsx` (dev) the identifier is undeclared, so `typeof` safely yields the +// dev fallback rather than throwing. +declare const __CHFX_VERSION__: string; + +export const CHFX_VERSION: string = + typeof __CHFX_VERSION__ === 'string' ? __CHFX_VERSION__ : '0.0.0-dev'; + +/** Version of the CLI's JSON output envelope. Bump on breaking shape changes. */ +export const CLI_SCHEMA_VERSION = 1; diff --git a/todo.md b/todo.md index 3cf1418..848e1e2 100644 --- a/todo.md +++ b/todo.md @@ -2,17 +2,21 @@ Spec for items 1, 2, 4, 5: [docs/cli-spec.md](docs/cli-spec.md) -1. **CLI (`chfx`) usable by agent** — npm bin via Node/tsx, publish-ready. - Deterministic JSON on stdout, non-interactive, self-describing. Commands: - `decode`, `query`, `proxy`, `schema`/`--help`. -2. **Configurable server version in the image** — `ARG CH_VERSION` → +1. **CLI (`chfx`) usable by agent** — _implemented (branch `cli-foundation-decode`, + with item 4)._ npm bin via Node/tsx, publish-ready (esbuild bundle). Deterministic + JSON on stdout, JSON error envelope on stderr, non-interactive, self-describing. + Commands: `decode` + `--help`/`--version`. (A standalone `schema` command was + dropped — `--help` + self-describing output suffice for now; revisit when + `query`/`proxy` land.) +2. **Configurable server version in the image** — _done (#42)._ `ARG CH_VERSION` → `FROM clickhouse/clickhouse-server:${CH_VERSION}`, surfaced via docker-compose. 3. ~~Configurable protocol version in TCP + native web interface~~ — **dropped** (clickhouse-client can't force the negotiated version; HTTP selector suffices). -4. **Import binary dump in CLI → structured output** — `chfx decode` (and - `query`): autodetect `.chproto`/Native/RowBinary with `--format` override, - stdin supported. Emits the web `ParsedData`/`AstNode` JSON plus the full raw - buffer inline as one hex string; agents slice it via each node's `byteRange`. +4. **Import binary dump in CLI → structured output** — _implemented (with item 1)._ + `chfx decode`: autodetect `.chproto`/Native/RowBinary with `--format` override, + stdin supported. Emits the web `ParsedData`/`AstNode` JSON; top-level `bytesHex` + (whole buffer once) plus per-node inline `bytes` by default (`--no-node-bytes` + to omit). `query` transport deferred to a later branch. 5. **Use with external clients, in the CLI** — `chfx proxy`: standalone capture proxy any native client connects through. Single-shot by default; persistent and live-decode via flags. Plaintext/uncompressed only. From b193783eefa37edbb20c6fcd1f51c5f68fbf0d18 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 12 Jun 2026 11:23:22 +0200 Subject: [PATCH 2/7] feat(cli): add query + capture commands; UX one-shot and simpler surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the capture→file→decode dance into single commands under chfx, and make the dump file optional. - `chfx query --query ""`: run AND decode in one step (no temp file). - `--protocol tcp` (default): drive clickhouse-client through the capturing proxy and decode the native packet stream; `--save ` keeps the .chproto. - `--protocol http`: POST to ClickHouse HTTP requesting `--format` (native | RowBinaryWithNamesAndTypes, default native) and decode the body; `--protocol-version` sets the Native client version. Port defaults 8123; auth via X-ClickHouse-User/-Key headers. - `chfx capture --query ""`: capture to a .chproto dump only; `--out ` writes a file (+ JSON summary), otherwise streams raw bytes to stdout so `chfx capture … | chfx decode` works. `npm run capture` is now an alias to it; the standalone scripts/capture-native.mjs is folded in and removed. - Shared connection flags with env fallbacks (CH_NATIVE_HOST, CH_NATIVE_PORT / CH_HTTP_PORT, CH_USER, CH_PASSWORD, CH_DATABASE, CLICKHOUSE_CLIENT) and experimental type settings on by default (--no-experimental-settings, repeatable --setting k=v). Refactor: extract decodeCaptureStreams + buildDecodeEnvelope (shared by decode and query); commands return a JSON|raw CommandOutput union the entry point renders. Arg parser gains repeatable multiFlags (--setting). The TS CLI imports the JS proxy via a new scripts/native-proxy.d.mts declaration; query/capture reuse the same captureQuery the web/Electron paths use. Docs: README quick start now leads with `chfx query` and `npm link`; full transport/connection option tables. AGENTS.md + docs/cli-spec.md updated. Tests: query (tcp via injected capture; http via injected fetch for Native + RowBinary + error + flag-validation), capture (raw stdout + file summary), repeatable-flag parsing, and connection/env resolution. 44 tests pass; verified end-to-end against a live server on both transports. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 20 +++-- README.md | 51 +++++++++--- docs/cli-spec.md | 37 ++++++--- package.json | 2 +- scripts/capture-native.mjs | 66 --------------- scripts/native-proxy.d.mts | 42 ++++++++++ src/cli/args.ts | 40 +++++++-- src/cli/cli.test.ts | 141 +++++++++++++++++++++++++++++++- src/cli/commands/capture.ts | 61 ++++++++++++++ src/cli/commands/decode.ts | 74 +++++++++++------ src/cli/commands/query.ts | 157 ++++++++++++++++++++++++++++++++++++ src/cli/connection.ts | 102 +++++++++++++++++++++++ src/cli/index.ts | 18 ++++- src/cli/output.ts | 12 +++ src/cli/registry.ts | 35 ++++++++ todo.md | 24 ++++-- 16 files changed, 739 insertions(+), 143 deletions(-) delete mode 100644 scripts/capture-native.mjs create mode 100644 scripts/native-proxy.d.mts create mode 100644 src/cli/commands/capture.ts create mode 100644 src/cli/commands/query.ts create mode 100644 src/cli/connection.ts diff --git a/AGENTS.md b/AGENTS.md index 395e036..11bf3b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ A tool for visualizing ClickHouse RowBinary and Native wire format data. Feature The `NativeProtocol` format decodes a whole connection's packet stream rather than a single HTTP format body. A small TCP proxy (`scripts/native-proxy.mjs`, mirrored for the app in `electron/native-capture.ts`) sits between `clickhouse-client` and the server, forwarding bytes and teeing both directions into a capture. On localhost clickhouse-client disables compression, so the capture is plaintext, uncompressed packets (TLS/compression are out of scope). -- Capture a dump for tests/inspection: `npm run capture -- --query "SELECT 1" --out cap.chproto` (see `scripts/capture-native.mjs`). +- Capture a dump for tests/inspection: `npm run capture -- --query "SELECT 1" --out cap.chproto` (alias for `chfx capture`, in `src/cli/commands/capture.ts`). Or `chfx query --query "SELECT 1"` to capture **and** decode in one step. - `.chproto` dump format and parsing: `scripts/native-proxy.mjs` (writer) and `src/core/decoder/protocol-dump.ts` (reader). - Decoder: `src/core/decoder/protocol-decoder.ts` (`ProtocolDecoder`) reuses `NativeDecoder.decodeProtocolBlock()` for the Block inside Data-family packets. It derives the negotiated version from `min(client, server)` Hello and gates every field per `docs/full_native_protocol_spec.md`. - Capture from the **web UI**: the browser POSTs SQL to a `/capture` endpoint that runs the proxy server-side and returns the `.chproto` dump (the browser can't open raw TCP itself). Served by: `npm run dev` / `vite preview` (Vite plugin in `vite.config.ts` → `scripts/capture-middleware.mjs`), and the **Docker** image (standalone `scripts/capture-server.mjs` under supervisord, proxied by nginx). Native-connection defaults come from env: `CH_NATIVE_HOST`/`CH_NATIVE_PORT`/`CH_USER`/`CH_PASSWORD`/`CLICKHOUSE_CLIENT`; `CAPTURE_EXPERIMENTAL_SETTINGS=0` stops sending experimental type settings per-query (for read-only users that reject them — rely on the profile instead). @@ -36,8 +36,10 @@ npm run test # Run integration tests (uses testcontainers) npm run lint # ESLint check npm run test:e2e # Build Electron + run Playwright e2e tests -# CLI (chfx) — decode wire-format dumps to structured JSON -npm run cli -- decode capture.chproto # run from source via tsx +# CLI (chfx) — run/decode wire-format data to structured JSON +npm run cli -- query --query "SELECT 1" # capture over native protocol + decode (one step) +npm run cli -- decode capture.chproto # decode an existing dump (run from source via tsx) +npm run capture -- --query "SELECT 1" -o cap.chproto # capture only (alias for `chfx capture`) npm run cli:build # bundle the publishable binary → dist/cli/index.js # Electron desktop app @@ -83,13 +85,17 @@ src/ ├── store/ │ └── store.ts # Zustand store (query, parsed data, UI state) ├── cli/ # chfx CLI (Node, bundled via esbuild; reuses src/core decoders) -│ ├── index.ts # Entry: command dispatch, --help/--version, JSON error envelope -│ ├── commands/decode.ts# `decode` — decodeBuffer() (chproto/Native/RowBinary, autodetect) + envelope -│ ├── args.ts # Dependency-free arg parser -│ ├── output.ts # CliError, JSON-safe serializer (bigint→string, bytes→hex) +│ ├── index.ts # Entry: command dispatch, --help/--version, JSON|raw stdout, error envelope +│ ├── commands/decode.ts# `decode` — decodeBuffer/decodeCaptureStreams + shared buildDecodeEnvelope +│ ├── commands/query.ts # `query` — capture (proxy) + decode in one step; --save keeps the dump +│ ├── commands/capture.ts# `capture` — capture to .chproto (file or raw stdout); `npm run capture` alias +│ ├── connection.ts # Shared --host/port/user/... resolution + env fallbacks + experimental settings +│ ├── args.ts # Dependency-free arg parser (value + repeatable flags) +│ ├── output.ts # CliError, JSON-safe serializer (bigint→string, bytes→hex), CommandOutput │ ├── registry.ts # Command metadata for --help │ ├── version.ts # Build-injected version (esbuild define) │ └── cli.test.ts # Vitest unit + tsx e2e tests (uses fixtures/protocol/*.chproto) +│ # query/capture reuse scripts/native-proxy.mjs (+ native-proxy.d.mts for types) └── styles/ # CSS files electron/ ├── main.ts # Electron main process (window, IPC handlers) diff --git a/README.md b/README.md index 63f3ead..76d5308 100644 --- a/README.md +++ b/README.md @@ -44,25 +44,28 @@ The version is baked into the image — rebuild to change it. ## CLI (`chfx`) -A command-line tool that decodes a binary wire-format dump into structured JSON — -the same AST the web UI renders, plus the raw bytes — so it can be scripted or -driven by an agent. +A command-line tool that runs or decodes ClickHouse wire-format data and prints +structured JSON — the same AST the web UI renders, plus the raw bytes — so it +can be scripted or driven by an agent. ### Quick start ```bash -# from a checkout (no build needed) npm install -npm run cli -- decode capture.chproto # decode a native-protocol capture +npm link # makes `chfx` available on your PATH (uses dist/cli/index.js) +npm run cli:build # build the binary `chfx` runs -# or build the standalone binary and run it -npm run cli:build -node dist/cli/index.js decode result.native --format native +# Run a query and see it decoded — one step, no intermediate file: +chfx query --query "SELECT number AS n, [number] AS arr FROM numbers(3)" -# pipe bytes in from anywhere -clickhouse-client -q "SELECT 1 FORMAT Native" | node dist/cli/index.js decode -f native - +# Decode a dump you already have (or pipe one in): +chfx decode capture.chproto +clickhouse-client -q "SELECT 1 FORMAT Native" | chfx decode -f native - ``` +> Prefer not to `npm link`? Use `npm run cli -- ` (runs from source via +> tsx, no build) or `node dist/cli/index.js ` after `cli:build`. + Output is a single JSON document on **stdout**; diagnostics and a JSON error envelope go to **stderr**. Exit codes: `0` success, `2` usage error, `1` I/O or decode error. @@ -71,10 +74,34 @@ decode error. | Command | Description | |---------|-------------| +| `chfx query --query ""` | Run a query **and decode it** in one step (no file). `--protocol tcp` (default) captures the native packet stream via `clickhouse-client`; `--protocol http` POSTs to ClickHouse HTTP and decodes the `--format` body. `--save ` keeps the `.chproto` dump (tcp). | +| `chfx capture --query ""` | Capture a query to a `.chproto` dump only (native protocol). Writes `--out `, or streams raw bytes to stdout (so `chfx capture … \| chfx decode` works). `npm run capture` is an alias. | | `chfx decode [file]` | Decode a `.chproto`, Native, or RowBinary dump to JSON. Reads stdin when no file (or `-`) is given. | | `chfx --help` / `chfx --help` | Human-readable help. | | `chfx --version` | Print the version. | +### `query` transport options + +| Option | Description | +|--------|-------------| +| `--protocol tcp\|http` | Transport. `tcp` (default) = native capture via `clickhouse-client`. `http` = HTTP request. | +| `--format native\|RowBinaryWithNamesAndTypes` | **http only** — the body format to request and decode (default `native`). | +| `--protocol-version ` | `client_protocol_version` for an http Native query (default `0`). | +| `--save ` | **tcp only** — also write the raw `.chproto` capture. | + +### Connection options (`query` / `capture`) + +| Option | Description | +|--------|-------------| +| `--query ` | SQL to run (required). | +| `--host` / `--port` | Server host / port. Env: `CH_NATIVE_HOST`, `CH_NATIVE_PORT` (tcp) / `CH_HTTP_PORT` (http). Default `127.0.0.1`, port `9000` (tcp) / `8123` (http). | +| `--user` / `--password` | Credentials. Env: `CH_USER` / `CH_PASSWORD`. | +| `--database ` | Default database. Env: `CH_DATABASE`. | +| `--setting k=v` | Per-query setting; repeatable. | +| `--no-experimental-settings` | Don't send the Variant/Dynamic/JSON/QBit enabling settings (sent by default). | +| `--client ` | Path to `clickhouse-client` (tcp only). Env: `CLICKHOUSE_CLIENT`. | +| `--out ` (`capture`) | Where to write the `.chproto` dump. | + ### `decode` options | Option | Description | @@ -88,8 +115,8 @@ decode error. ```jsonc { - "chfx": { "tool": "chfx", "version": "...", "schemaVersion": 1, "command": "decode" }, - "source": { "kind": "file", "path": "...", "byteLength": 2417 }, + "chfx": { "tool": "chfx", "version": "...", "schemaVersion": 1, "command": "decode" }, // or "query" + "source": { "kind": "file", "path": "...", "byteLength": 2417 }, // kind "stdin" | "query" too "format": "NativeProtocol", // | Native | RowBinaryWithNamesAndTypes "formatDetected": true, // false when forced via --format "protocolVersion": 54482, // negotiated (chproto) / requested (native) / null (rowbinary) diff --git a/docs/cli-spec.md b/docs/cli-spec.md index 30c3dba..c103b32 100644 --- a/docs/cli-spec.md +++ b/docs/cli-spec.md @@ -38,18 +38,31 @@ Import a binary dump from a file **or stdin** and emit structured JSON. - Accepts binary on **stdin** (e.g. piped from clickhouse-client) as well as a file path argument. -#### `chfx query` -Run SQL against a server and decode the result in one step. -- Transport: **both, `--transport http|native`.** - - `http`: POST to ClickHouse HTTP, request the chosen format, decode the body. - - `native`: drive clickhouse-client through the capture proxy and decode the - full `.chproto` packet stream. -- Default `--format native` (richest). **Experimental type settings** - (Variant/Dynamic/JSON enablement) are **sent by default**, with - `--no-experimental-settings` to disable for read-only/strict servers that - reject them. -- Remote connection flags: `--host/--port/--user/--password`, env-var fallbacks, - and HTTPS/TLS where applicable. +#### `chfx query` (implemented) +Run a query **and decode it in one step** — no intermediate file — over either +transport, emitting the same envelope as `decode`: +- **`--protocol tcp`** (default): drives `clickhouse-client` through the + capturing proxy (`scripts/native-proxy.mjs`) and decodes the native packet + stream. `--save ` also writes the raw `.chproto` dump. +- **`--protocol http`**: POSTs to ClickHouse HTTP requesting `--format` + (`native` | `RowBinaryWithNamesAndTypes`, default native) and decodes the + body. `--protocol-version ` sets the Native `client_protocol_version`. + Port defaults to 8123 (env `CH_HTTP_PORT`); user/password go via + `X-ClickHouse-User`/`-Key` headers. +- SQL via the **`--query` flag**. **Experimental type settings** sent by default + (`--no-experimental-settings` to disable); `--setting k=v` repeatable. +- Connection flags `--host/--port/--user/--password/--database/--client` with + env fallbacks (`CH_NATIVE_HOST`, `CH_NATIVE_PORT`/`CH_HTTP_PORT`, `CH_USER`, + `CH_PASSWORD`, `CH_DATABASE`, `CLICKHOUSE_CLIENT`). +- **Deferred:** TLS. (The shelved own-TCP-client would remove the + `clickhouse-client` dependency for tcp and could revive item 3.) + +#### `chfx capture` (implemented) +Capture a query to a `.chproto` dump **without decoding**. `--out ` (`-o`) +writes the dump; omitted, it streams the raw dump bytes to stdout so +`chfx capture … | chfx decode` works. Shares all `query` connection flags. +**`npm run capture` is a thin alias** to `chfx capture` (the standalone +`scripts/capture-native.mjs` was folded in and removed). #### `chfx proxy` (item 5 — standalone capture proxy) A listener that forwards to a target server and captures the native TCP stream. diff --git a/package.json b/package.json index cdfc4d4..58a83f9 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "capture": "node scripts/capture-native.mjs", + "capture": "tsx src/cli/index.ts capture", "cli": "tsx src/cli/index.ts", "cli:build": "node scripts/build-cli.mjs", "electron:dev": "ELECTRON=true vite dev", diff --git a/scripts/capture-native.mjs b/scripts/capture-native.mjs deleted file mode 100644 index 1c6d8c9..0000000 --- a/scripts/capture-native.mjs +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -// @ts-check -/** - * CLI: capture a single native-protocol query exchange to a .chproto dump. - * - * node scripts/capture-native.mjs --query "SELECT 1" --out capture.chproto - * - * Options: - * --query (required) SQL to run - * --out output dump path (default: capture.chproto) - * --host server host (default 127.0.0.1) - * --port

server native port (default 9000) - * --user / --password / --database - * --client path to clickhouse-client - * --setting k=v per-query setting (repeatable) - */ - -import fs from 'node:fs'; -import { captureQuery, encodeDump } from './native-proxy.mjs'; - -function parseArgs(argv) { - const out = { settings: {}, clientArgs: [] }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - const next = () => argv[++i]; - switch (a) { - case '--query': out.query = next(); break; - case '--out': out.out = next(); break; - case '--host': out.host = next(); break; - case '--port': out.port = Number(next()); break; - case '--user': out.user = next(); break; - case '--password': out.password = next(); break; - case '--database': out.database = next(); break; - case '--client': out.clientPath = next(); break; - case '--setting': { - const [k, ...rest] = next().split('='); - out.settings[k] = rest.join('='); - break; - } - default: out.clientArgs.push(a); - } - } - return out; -} - -async function main() { - const opts = parseArgs(process.argv.slice(2)); - if (!opts.query) { - console.error('error: --query is required'); - process.exit(2); - } - const outPath = opts.out ?? 'capture.chproto'; - const capture = await captureQuery(opts); - fs.writeFileSync(outPath, encodeDump(capture)); - const total = capture.c2s.length + capture.s2c.length; - console.error( - `captured ${capture.segments.length} segments → ${outPath} ` + - `(C2S ${capture.c2s.length}B, S2C ${capture.s2c.length}B, total ${total}B)`, - ); - if (capture.meta.stderr) console.error(`client stderr: ${capture.meta.stderr}`); -} - -main().catch((err) => { - console.error(err.message || err); - process.exit(1); -}); diff --git a/scripts/native-proxy.d.mts b/scripts/native-proxy.d.mts new file mode 100644 index 0000000..7270ead --- /dev/null +++ b/scripts/native-proxy.d.mts @@ -0,0 +1,42 @@ +// Type declarations for the JS proxy harness so the TypeScript CLI can import +// it. Runtime is scripts/native-proxy.mjs (Node built-ins only). +import type { Buffer } from 'node:buffer'; + +export const MAGIC: string; +export const DIR_C2S: 0; +export const DIR_S2C: 1; + +export interface Segment { + dir: 0 | 1; + data: Buffer; +} + +export interface Capture { + c2s: Buffer; + s2c: Buffer; + segments: Segment[]; + meta: Record; +} + +export interface CaptureQueryOptions { + query: string; + host?: string; + port?: number; + user?: string; + password?: string; + database?: string; + clientPath?: string; + clientArgs?: string[]; + settings?: Record; +} + +export function startProxy(opts: { + targetHost: string; + targetPort: number; + listenHost?: string; +}): Promise<{ port: number; done: Promise; close: () => void }>; + +export function splitStreams(segments: Segment[]): { c2s: Buffer; s2c: Buffer }; +export function captureQuery(opts: CaptureQueryOptions): Promise; +export function encodeDump(capture: Capture): Buffer; +export function decodeDump(buf: Buffer): Capture; diff --git a/src/cli/args.ts b/src/cli/args.ts index 1316db1..176a8d9 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -2,12 +2,14 @@ import { CliError } from './output'; export interface ParsedArgs { positionals: string[]; - options: Record; + options: Record; } export interface ArgSpec { - /** Option names (canonical) that consume the following token as a value. */ + /** Option names (canonical) that consume the following token as a single value. */ valueFlags?: string[]; + /** Option names that consume a value and accumulate repeats into an array. */ + multiFlags?: string[]; /** Short/alternate name → canonical name. */ aliases?: Record; } @@ -21,9 +23,19 @@ export interface ArgSpec { */ export function parseArgs(argv: string[], spec: ArgSpec = {}): ParsedArgs { const valueFlags = new Set(spec.valueFlags ?? []); + const multiFlags = new Set(spec.multiFlags ?? []); const aliases = spec.aliases ?? {}; const positionals: string[] = []; - const options: Record = {}; + const options: Record = {}; + + const addValue = (name: string, value: string) => { + if (multiFlags.has(name)) { + const existing = options[name]; + options[name] = Array.isArray(existing) ? [...existing, value] : [value]; + } else { + options[name] = value; + } + }; let i = 0; let positionalOnly = false; @@ -52,12 +64,12 @@ export function parseArgs(argv: string[], spec: ArgSpec = {}): ParsedArgs { name = aliases[name] ?? name; if (inlineValue !== undefined) { - options[name] = inlineValue; - } else if (valueFlags.has(name)) { + addValue(name, inlineValue); + } else if (valueFlags.has(name) || multiFlags.has(name)) { if (i >= argv.length) { throw new CliError('usage', `option --${name} requires a value`); } - options[name] = argv[i++]; + addValue(name, argv[i++]); } else { options[name] = true; } @@ -66,16 +78,30 @@ export function parseArgs(argv: string[], spec: ArgSpec = {}): ParsedArgs { return { positionals, options }; } -/** Read an option as a string, or undefined if absent. Errors if it's a bare boolean flag. */ +/** Read an option as a string, or undefined if absent. Errors if it's a bare boolean flag or repeated. */ export function stringOption(args: ParsedArgs, name: string): string | undefined { const v = args.options[name]; if (v === undefined) return undefined; if (typeof v === 'boolean') { throw new CliError('usage', `option --${name} requires a value`); } + if (Array.isArray(v)) { + throw new CliError('usage', `option --${name} may only be given once`); + } return v; } export function boolOption(args: ParsedArgs, name: string): boolean { return args.options[name] === true || args.options[name] === 'true'; } + +/** Read a repeatable option as an array (empty if absent). */ +export function arrayOption(args: ParsedArgs, name: string): string[] { + const v = args.options[name]; + if (v === undefined) return []; + if (Array.isArray(v)) return v; + if (typeof v === 'boolean') { + throw new CliError('usage', `option --${name} requires a value`); + } + return [v]; +} diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 4b9b7fd..57b8019 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -1,12 +1,18 @@ import { describe, it, expect } from 'vitest'; -import { readFileSync, readdirSync } from 'node:fs'; +import { readFileSync, readdirSync, rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { decodeBuffer, decodeCommand } from './commands/decode'; -import { parseArgs, stringOption, boolOption } from './args'; +import { queryCommand } from './commands/query'; +import { captureCommand } from './commands/capture'; +import { resolveCaptureOptions } from './connection'; +import { parseArgs, stringOption, boolOption, arrayOption } from './args'; import { stringify, CliError } from './output'; import { ClickHouseFormat } from '../core/types/formats'; +import { parseChprotoDump } from '../core/decoder/protocol-dump'; const FIXTURE_DIR = fileURLToPath(new URL('../core/decoder/fixtures/protocol/', import.meta.url)); const CLI_ENTRY = fileURLToPath(new URL('./index.ts', import.meta.url)); @@ -187,6 +193,137 @@ describe('per-node inline bytes', () => { }); }); +describe('parseArgs — repeatable flags', () => { + it('accumulates multiFlags into an array', () => { + const args = parseArgs(['--setting', 'a=1', '--setting', 'b=2'], { multiFlags: ['setting'] }); + expect(arrayOption(args, 'setting')).toEqual(['a=1', 'b=2']); + }); +}); + +describe('resolveCaptureOptions', () => { + it('requires --query', () => { + expect(() => resolveCaptureOptions(parseArgs([]))).toThrow(CliError); + }); + + it('takes flags over env and parses settings, dropping experimental when asked', () => { + const args = parseArgs( + ['--query', 'SELECT 1', '--host', 'h1', '--port', '9001', '--setting', 'max_threads=2', '--no-experimental-settings'], + { valueFlags: ['query', 'host', 'port'], multiFlags: ['setting'] }, + ); + const opts = resolveCaptureOptions(args); + expect(opts).toMatchObject({ query: 'SELECT 1', host: 'h1', port: 9001 }); + expect(opts.settings).toEqual({ max_threads: '2' }); + }); + + it('sends experimental settings by default', () => { + const opts = resolveCaptureOptions(parseArgs(['--query', 'SELECT 1'], { valueFlags: ['query'] })); + expect(opts.settings).toHaveProperty('allow_experimental_json_type', '1'); + }); + + it('rejects a non-numeric port', () => { + expect(() => resolveCaptureOptions(parseArgs(['--query', 'x', '--port', 'abc'], { valueFlags: ['query', 'port'] }))).toThrow( + CliError, + ); + }); +}); + +describe('query / capture (with injected capture)', () => { + // Build a fake capture from a real fixture so no server/clickhouse-client is needed. + function fakeCapture() { + const { c2s, s2c, meta } = parseChprotoDump(readFixture(fixtures[0])); + const cb = Buffer.from(c2s); + const sb = Buffer.from(s2c); + return { + c2s: cb, + s2c: sb, + segments: [ + { dir: 0 as const, data: cb }, + { dir: 1 as const, data: sb }, + ], + meta: meta ?? {}, + }; + } + const deps = { captureQuery: async () => fakeCapture() }; + + it('query captures + decodes in one step', async () => { + const out = await queryCommand(['--query', 'SELECT 1'], deps); + expect(out.stdout).toBe('json'); + const env = out.data as { chfx: { command: string }; source: { kind: string; query: string }; format: string; bytesHex: string }; + expect(env.chfx.command).toBe('query'); + expect(env.source).toMatchObject({ kind: 'query', query: 'SELECT 1' }); + expect(env.format).toBe('NativeProtocol'); + expect(env.bytesHex.length).toBeGreaterThan(0); + }); + + it('query --save writes a round-trippable .chproto dump', async () => { + const path = join(tmpdir(), 'chfx-query-save.chproto'); + try { + await queryCommand(['--query', 'SELECT 1', '--save', path], deps); + const reparsed = parseChprotoDump(new Uint8Array(readFileSync(path))); + expect(reparsed.c2s.length).toBe(fakeCapture().c2s.length); + } finally { + rmSync(path, { force: true }); + } + }); + + it('capture without --out streams a raw .chproto dump to stdout', async () => { + const out = await captureCommand(['--query', 'SELECT 1'], deps); + expect(out.stdout).toBe('raw'); + if (out.stdout !== 'raw') throw new Error('expected raw'); + const reparsed = parseChprotoDump(out.bytes); + expect(reparsed.c2s.length).toBe(fakeCapture().c2s.length); + }); + + it('capture --out writes a file and returns a JSON summary', async () => { + const path = join(tmpdir(), 'chfx-capture-out.chproto'); + try { + const out = await captureCommand(['--query', 'SELECT 1', '--out', path], deps); + expect(out.stdout).toBe('json'); + const data = (out as { data: { saved: string; bytes: number; segments: number } }).data; + expect(data.saved).toBe(path); + expect(data.bytes).toBeGreaterThan(0); + expect(data.segments).toBe(2); + expect(readFileSync(path).length).toBe(data.bytes); + } finally { + rmSync(path, { force: true }); + } + }); +}); + +describe('query --protocol http (injected fetch)', () => { + const fetchReturning = (body: Uint8Array | string, status = 200): typeof fetch => + (async () => new Response(body, { status })) as typeof fetch; + + it('decodes a Native body and records the http transport', async () => { + const out = await queryCommand(['--query', 'SELECT 1', '--protocol', 'http'], { fetch: fetchReturning(NATIVE_BODY) }); + const env = out.data as { format: string; source: { protocol: string; httpFormat: string } }; + expect(env.format).toBe('Native'); + expect(env.source.protocol).toBe('http'); + expect(env.source.httpFormat).toBe('Native'); + }); + + it('decodes a RowBinaryWithNamesAndTypes body', async () => { + const out = await queryCommand( + ['--query', 'SELECT 1', '--protocol', 'http', '--format', 'RowBinaryWithNamesAndTypes'], + { fetch: fetchReturning(ROWBINARY_BODY) }, + ); + expect((out.data as { format: string }).format).toBe('RowBinaryWithNamesAndTypes'); + }); + + it('surfaces a non-2xx HTTP response as an error', async () => { + await expect( + queryCommand(['--query', 'x', '--protocol', 'http'], { fetch: fetchReturning('Code: 60', 404) }), + ).rejects.toThrow(CliError); + }); + + it('rejects --format with tcp and --save with http', async () => { + await expect(queryCommand(['--query', 'x', '--format', 'native'], { captureQuery: async () => { throw new Error('unused'); } })) + .rejects.toThrow(/--format only applies/); + await expect(queryCommand(['--query', 'x', '--protocol', 'http', '--save', '/tmp/x'], { fetch: fetchReturning(NATIVE_BODY) })) + .rejects.toThrow(/--save only applies/); + }); +}); + describe('end-to-end via tsx (entry, stdin, exit codes)', () => { const run = (args: string[], input?: Buffer) => execFileSync('npx', ['tsx', CLI_ENTRY, ...args], { input, stdio: ['pipe', 'pipe', 'pipe'] }).toString(); diff --git a/src/cli/commands/capture.ts b/src/cli/commands/capture.ts new file mode 100644 index 0000000..72cfb15 --- /dev/null +++ b/src/cli/commands/capture.ts @@ -0,0 +1,61 @@ +import { writeFile } from 'node:fs/promises'; + +import { + captureQuery as defaultCaptureQuery, + encodeDump, + type Capture, + type CaptureQueryOptions, +} from '../../../scripts/native-proxy.mjs'; + +import { parseArgs, stringOption, boolOption } from '../args'; +import { CliError, type CommandOutput } from '../output'; +import { CHFX_VERSION, CLI_SCHEMA_VERSION } from '../version'; +import { resolveCaptureOptions, CONNECTION_VALUE_FLAGS, CONNECTION_MULTI_FLAGS } from '../connection'; + +export interface CaptureDeps { + captureQuery: (opts: CaptureQueryOptions) => Promise; +} + +/** + * Capture a query over the native protocol to a .chproto dump, without decoding. + * With `--out ` it writes the dump and prints a JSON summary; otherwise it + * streams the raw dump bytes to stdout (so `chfx capture … | chfx decode` works). + */ +export async function captureCommand( + rest: string[], + deps: CaptureDeps = { captureQuery: defaultCaptureQuery }, +): Promise { + const args = parseArgs(rest, { + valueFlags: [...CONNECTION_VALUE_FLAGS, 'out'], + multiFlags: CONNECTION_MULTI_FLAGS, + aliases: { o: 'out' }, + }); + const compact = boolOption(args, 'compact'); + const out = stringOption(args, 'out'); + const captureOpts = resolveCaptureOptions(args); + + let capture: Capture; + try { + capture = await deps.captureQuery(captureOpts); + } catch (err) { + throw new CliError('io', `capture failed: ${(err as Error).message}`); + } + + const dump = encodeDump(capture); + + if (!out || out === '-') { + return { stdout: 'raw', bytes: new Uint8Array(dump) }; + } + + await writeFile(out, dump); + const data = { + chfx: { tool: 'chfx', version: CHFX_VERSION, schemaVersion: CLI_SCHEMA_VERSION, command: 'capture' }, + query: captureOpts.query, + saved: out, + bytes: dump.length, + c2sBytes: capture.c2s.length, + s2cBytes: capture.s2c.length, + segments: capture.segments.length, + }; + return { stdout: 'json', data, compact }; +} diff --git a/src/cli/commands/decode.ts b/src/cli/commands/decode.ts index db194bd..30ac866 100644 --- a/src/cli/commands/decode.ts +++ b/src/cli/commands/decode.ts @@ -8,7 +8,7 @@ import { createDecoder, ProtocolDecoder } from '../../core/decoder'; import { parseChprotoDump } from '../../core/decoder/protocol-dump'; import { DEFAULT_NATIVE_PROTOCOL_VERSION } from '../../core/types/native-protocol'; -import { CliError } from '../output'; +import { CliError, type JsonOutput } from '../output'; import { CHFX_VERSION, CLI_SCHEMA_VERSION } from '../version'; import { parseArgs, stringOption, boolOption } from '../args'; @@ -17,7 +17,7 @@ export type FormatName = (typeof FORMAT_NAMES)[number]; const CHPROTO_MAGIC = 'CHPROTO1'; -interface DecodeCore { +export interface DecodeCore { format: ClickHouseFormat; /** Version negotiated (chproto) or requested (native); null for RowBinary. */ protocolVersion: number | null; @@ -40,8 +40,16 @@ function isChproto(bytes: Uint8Array): boolean { return true; } -function decodeChproto(bytes: Uint8Array): DecodeCore { - const { c2s, s2c, meta } = parseChprotoDump(bytes); +/** + * Decode the two per-direction streams of a native-protocol capture. Shared by + * `decode` (from a .chproto file) and `query`/`capture` (from a live exchange). + * The byteRanges index into the combined c2s+s2c buffer returned as outputBytes. + */ +export function decodeCaptureStreams( + c2s: Uint8Array, + s2c: Uint8Array, + meta?: Record, +): DecodeCore { const combined = new Uint8Array(c2s.length + s2c.length); combined.set(c2s, 0); combined.set(s2c, c2s.length); @@ -60,6 +68,11 @@ function decodeChproto(bytes: Uint8Array): DecodeCore { }; } +function decodeChproto(bytes: Uint8Array): DecodeCore { + const { c2s, s2c, meta } = parseChprotoDump(bytes); + return decodeCaptureStreams(c2s, s2c, meta); +} + function decodeNative(bytes: Uint8Array, protocolVersion: number): DecodeCore { const parsed = createDecoder(bytes, ClickHouseFormat.Native, { nativeProtocolVersion: protocolVersion }).decode(); return { format: ClickHouseFormat.Native, protocolVersion, outputBytes: bytes, parsed }; @@ -146,6 +159,35 @@ function attachNodeBytes(value: unknown, buffer: Uint8Array): unknown { return value; } +/** + * Build the JSON envelope shared by `decode` and `query`: tool metadata, the + * format/version, the whole buffer as `bytesHex`, and the ParsedData tree (with + * per-node inline bytes unless disabled). + */ +export function buildDecodeEnvelope( + result: DecodeResult, + source: Record, + opts: { command: string; includeNodeBytes: boolean }, +): Record { + return { + chfx: { tool: 'chfx', version: CHFX_VERSION, schemaVersion: CLI_SCHEMA_VERSION, command: opts.command }, + source, + format: result.format, + formatDetected: result.formatDetected, + protocolVersion: result.protocolVersion, + nodeBytes: opts.includeNodeBytes, + ...(result.protocol ? { protocol: result.protocol } : {}), + conventions: { + byteRange: + 'Each node has byteRange {start, end} into bytesHex (2 hex chars per byte; start inclusive, end exclusive).', + bytes: + 'When nodeBytes is true, each node also carries its own raw bytes inline as "bytes" (hex); pass --no-node-bytes to omit and slice bytesHex by range instead.', + }, + bytesHex: Buffer.from(result.outputBytes).toString('hex'), + data: opts.includeNodeBytes ? attachNodeBytes(result.parsed, result.outputBytes) : result.parsed, + }; +} + async function readInput(path: string | undefined): Promise<{ bytes: Uint8Array; source: Record }> { if (path && path !== '-') { try { @@ -173,7 +215,7 @@ function parseProtocolVersion(raw: string | undefined): number | undefined { return n; } -export async function decodeCommand(rest: string[]): Promise<{ data: unknown; compact: boolean }> { +export async function decodeCommand(rest: string[]): Promise { const args = parseArgs(rest, { valueFlags: ['format', 'protocol-version'], aliases: { f: 'format' }, @@ -193,24 +235,6 @@ export async function decodeCommand(rest: string[]): Promise<{ data: unknown; co } const result = decodeBuffer(bytes, { format, protocolVersion }); - - const data = { - chfx: { tool: 'chfx', version: CHFX_VERSION, schemaVersion: CLI_SCHEMA_VERSION, command: 'decode' }, - source, - format: result.format, - formatDetected: result.formatDetected, - protocolVersion: result.protocolVersion, - nodeBytes: includeNodeBytes, - ...(result.protocol ? { protocol: result.protocol } : {}), - conventions: { - byteRange: - 'Each node has byteRange {start, end} into bytesHex (2 hex chars per byte; start inclusive, end exclusive).', - bytes: - 'When nodeBytes is true, each node also carries its own raw bytes inline as "bytes" (hex); pass --no-node-bytes to omit and slice bytesHex by range instead.', - }, - bytesHex: Buffer.from(result.outputBytes).toString('hex'), - data: includeNodeBytes ? attachNodeBytes(result.parsed, result.outputBytes) : result.parsed, - }; - - return { data, compact }; + const data = buildDecodeEnvelope(result, source, { command: 'decode', includeNodeBytes }); + return { stdout: 'json', data, compact }; } diff --git a/src/cli/commands/query.ts b/src/cli/commands/query.ts new file mode 100644 index 0000000..865ac54 --- /dev/null +++ b/src/cli/commands/query.ts @@ -0,0 +1,157 @@ +import { writeFile } from 'node:fs/promises'; + +import { + captureQuery as defaultCaptureQuery, + encodeDump, + type Capture, + type CaptureQueryOptions, +} from '../../../scripts/native-proxy.mjs'; + +import { ClickHouseFormat } from '../../core/types/formats'; +import { appendClickHouseRequestParams } from '../../core/clickhouse/request-params'; +import { parseArgs, stringOption, boolOption } from '../args'; +import { CliError, type JsonOutput } from '../output'; +import { resolveCaptureOptions, resolveHttpConnection, CONNECTION_VALUE_FLAGS, CONNECTION_MULTI_FLAGS } from '../connection'; +import { decodeBuffer, decodeCaptureStreams, buildDecodeEnvelope, type DecodeResult, type FormatName } from './decode'; + +export interface QueryDeps { + captureQuery: (opts: CaptureQueryOptions) => Promise; + fetch: typeof fetch; +} + +const DEFAULT_DEPS: QueryDeps = { captureQuery: defaultCaptureQuery, fetch: globalThis.fetch }; + +/** HTTP `--format` accepts the short or full name; maps to the wire format + decoder. */ +function resolveHttpFormat(raw: string | undefined): { wire: ClickHouseFormat; cli: FormatName } { + switch ((raw ?? 'native').toLowerCase()) { + case 'native': + return { wire: ClickHouseFormat.Native, cli: 'native' }; + case 'rowbinary': + case 'rowbinarywithnamesandtypes': + return { wire: ClickHouseFormat.RowBinaryWithNamesAndTypes, cli: 'rowbinary' }; + default: + throw new CliError('usage', `unknown --format '${raw}'; expected native or RowBinaryWithNamesAndTypes`); + } +} + +function parseProtocolVersion(raw: string | undefined): number { + if (raw === undefined) return 0; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + throw new CliError('usage', `--protocol-version must be a non-negative integer, got: ${raw}`); + } + return n; +} + +/** + * Run a query and decode the result in one step. + * - `--protocol tcp` (default): drive clickhouse-client through the capturing + * proxy and decode the full native packet stream. `--save ` keeps the dump. + * - `--protocol http`: POST to ClickHouse HTTP, requesting `--format` + * (native | RowBinaryWithNamesAndTypes), and decode the response body. + */ +export async function queryCommand(rest: string[], deps: Partial = {}): Promise { + const merged: QueryDeps = { ...DEFAULT_DEPS, ...deps }; + const args = parseArgs(rest, { + valueFlags: [...CONNECTION_VALUE_FLAGS, 'save', 'protocol', 'format', 'protocol-version'], + multiFlags: CONNECTION_MULTI_FLAGS, + }); + const compact = boolOption(args, 'compact'); + const includeNodeBytes = !boolOption(args, 'no-node-bytes'); + + const protocol = stringOption(args, 'protocol') ?? 'tcp'; + if (protocol !== 'tcp' && protocol !== 'http') { + throw new CliError('usage', `unknown --protocol '${protocol}'; expected tcp or http`); + } + + const data = + protocol === 'http' + ? await runHttp(args, merged, includeNodeBytes) + : await runTcp(args, merged, includeNodeBytes); + + return { stdout: 'json', data, compact }; +} + +async function runTcp( + args: ReturnType, + deps: QueryDeps, + includeNodeBytes: boolean, +): Promise> { + if (stringOption(args, 'format') !== undefined) { + throw new CliError('usage', '--format only applies to --protocol http'); + } + const save = stringOption(args, 'save'); + const captureOpts = resolveCaptureOptions(args); + + let capture: Capture; + try { + capture = await deps.captureQuery(captureOpts); + } catch (err) { + throw new CliError('io', `capture failed: ${(err as Error).message}`); + } + if (save) { + await writeFile(save, encodeDump(capture)); + } + + const result: DecodeResult = { ...decodeCaptureStreams(capture.c2s, capture.s2c, capture.meta), formatDetected: true }; + const source: Record = { + kind: 'query', + protocol: 'tcp', + query: captureOpts.query, + host: captureOpts.host ?? '127.0.0.1', + port: captureOpts.port ?? 9000, + ...(save ? { saved: save } : {}), + }; + return buildDecodeEnvelope(result, source, { command: 'query', includeNodeBytes }); +} + +async function runHttp( + args: ReturnType, + deps: QueryDeps, + includeNodeBytes: boolean, +): Promise> { + if (stringOption(args, 'save') !== undefined) { + throw new CliError('usage', '--save only applies to --protocol tcp (HTTP returns a body, not a capture)'); + } + const format = resolveHttpFormat(stringOption(args, 'format')); + const protocolVersion = parseProtocolVersion(stringOption(args, 'protocol-version')); + const conn = resolveHttpConnection(args); + + const params = new URLSearchParams(); + appendClickHouseRequestParams(params, format.wire, protocolVersion); + for (const [key, value] of Object.entries(conn.settings)) params.set(key, value); + if (conn.database) params.set('database', conn.database); + + const headers: Record = { 'Content-Type': 'text/plain' }; + if (conn.user) headers['X-ClickHouse-User'] = conn.user; + if (conn.password) headers['X-ClickHouse-Key'] = conn.password; + + let res: Response; + try { + res = await deps.fetch(`http://${conn.host}:${conn.port}/?${params.toString()}`, { + method: 'POST', + body: conn.query, + headers, + }); + } catch (err) { + throw new CliError('io', `HTTP request failed: ${(err as Error).message}`); + } + if (!res.ok) { + throw new CliError('io', `ClickHouse HTTP ${res.status}: ${(await res.text()).trim()}`); + } + const body = new Uint8Array(await res.arrayBuffer()); + if (body.length === 0) { + throw new CliError('decode', 'server returned an empty body'); + } + + const result = decodeBuffer(body, { format: format.cli, protocolVersion }); + const source: Record = { + kind: 'query', + protocol: 'http', + query: conn.query, + host: conn.host, + port: conn.port, + httpFormat: format.wire, + }; + return buildDecodeEnvelope(result, source, { command: 'query', includeNodeBytes }); +} diff --git a/src/cli/connection.ts b/src/cli/connection.ts new file mode 100644 index 0000000..3db6261 --- /dev/null +++ b/src/cli/connection.ts @@ -0,0 +1,102 @@ +import process from 'node:process'; + +import { stringOption, arrayOption, boolOption, type ParsedArgs } from './args'; +import { CliError } from './output'; +import type { CaptureQueryOptions } from '../../scripts/native-proxy.mjs'; + +/** + * Experimental type settings sent per-query so Variant/Dynamic/JSON/QBit decode. + * Mirrors the web capture server; disable with --no-experimental-settings for + * read-only/strict servers that reject them. + */ +const EXPERIMENTAL_SETTINGS: Record = { + allow_experimental_variant_type: '1', + allow_experimental_dynamic_type: '1', + allow_experimental_json_type: '1', + allow_suspicious_variant_types: '1', + allow_experimental_qbit_type: '1', + allow_suspicious_low_cardinality_types: '1', +}; + +/** Flags shared by `query` and `capture` for arg-parser specs. */ +export const CONNECTION_VALUE_FLAGS = ['query', 'host', 'port', 'user', 'password', 'database', 'client']; +export const CONNECTION_MULTI_FLAGS = ['setting']; + +function parseSettings(pairs: string[]): Record { + const out: Record = {}; + for (const pair of pairs) { + const eq = pair.indexOf('='); + if (eq === -1) throw new CliError('usage', `--setting must be key=value, got: ${pair}`); + out[pair.slice(0, eq)] = pair.slice(eq + 1); + } + return out; +} + +/** + * Resolve connection + query options from CLI args, falling back to the same + * environment variables the capture server uses (CH_NATIVE_HOST/PORT, CH_USER, + * CH_PASSWORD, CH_DATABASE, CLICKHOUSE_CLIENT). Undefined fields let the proxy + * apply its own defaults (127.0.0.1:9000, user "default"). Experimental type + * settings are merged first so explicit --setting can override them. + */ +export interface QueryBase { + query: string; + host?: string; + user?: string; + password?: string; + database?: string; + settings: Record; +} + +/** + * Transport-independent query + connection bits, with env fallbacks + * (CH_NATIVE_HOST, CH_USER, CH_PASSWORD, CH_DATABASE). Experimental type + * settings are merged first so explicit --setting can override them. + */ +export function resolveQueryBase(args: ParsedArgs): QueryBase { + const query = stringOption(args, 'query'); + if (!query) throw new CliError('usage', '--query is required'); + + const experimental = !boolOption(args, 'no-experimental-settings'); + const settings = { ...(experimental ? EXPERIMENTAL_SETTINGS : {}), ...parseSettings(arrayOption(args, 'setting')) }; + + return { + query, + host: stringOption(args, 'host') ?? process.env.CH_NATIVE_HOST, + user: stringOption(args, 'user') ?? process.env.CH_USER, + password: stringOption(args, 'password') ?? process.env.CH_PASSWORD, + database: stringOption(args, 'database') ?? process.env.CH_DATABASE, + settings, + }; +} + +function parsePort(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const port = Number(raw); + if (!Number.isInteger(port) || port <= 0) { + throw new CliError('usage', `--port must be a positive integer, got: ${raw}`); + } + return port; +} + +/** + * Native-protocol (TCP) capture options. Undefined host/port let the proxy apply + * its own defaults (127.0.0.1:9000). Env: CH_NATIVE_PORT, CLICKHOUSE_CLIENT. + */ +export function resolveCaptureOptions(args: ParsedArgs): CaptureQueryOptions { + return { + ...resolveQueryBase(args), + port: parsePort(stringOption(args, 'port') ?? process.env.CH_NATIVE_PORT), + clientPath: stringOption(args, 'client') ?? process.env.CLICKHOUSE_CLIENT, + }; +} + +/** HTTP connection: host/port default 127.0.0.1:8123 (env CH_NATIVE_HOST, CH_HTTP_PORT). */ +export function resolveHttpConnection(args: ParsedArgs): QueryBase & { host: string; port: number } { + const base = resolveQueryBase(args); + return { + ...base, + host: base.host ?? '127.0.0.1', + port: parsePort(stringOption(args, 'port') ?? process.env.CH_HTTP_PORT) ?? 8123, + }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index aee7c63..2f52338 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,9 +1,11 @@ import process from 'node:process'; -import { CliError, emitError, writeStdout, stringify } from './output'; +import { CliError, emitError, writeStdout, stringify, type CommandOutput } from './output'; import { CHFX_VERSION } from './version'; import { COMMANDS, findCommand, type CommandDoc } from './registry'; import { decodeCommand } from './commands/decode'; +import { queryCommand } from './commands/query'; +import { captureCommand } from './commands/capture'; function generalHelp(): string { const lines = [ @@ -57,16 +59,26 @@ async function run(argv: string[]): Promise { return 0; } - let out: { data: unknown; compact: boolean }; + let out: CommandOutput; switch (command) { case 'decode': out = await decodeCommand(rest); break; + case 'query': + out = await queryCommand(rest); + break; + case 'capture': + out = await captureCommand(rest); + break; default: throw new CliError('usage', `unknown command: ${command} (try: chfx --help)`); } - writeStdout(stringify(out.data, out.compact)); + if (out.stdout === 'json') { + writeStdout(stringify(out.data, out.compact)); + } else { + process.stdout.write(out.bytes); + } return 0; } diff --git a/src/cli/output.ts b/src/cli/output.ts index 14df31f..e1b97ef 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -25,6 +25,18 @@ export class CliError extends Error { } } +/** A command either emits a JSON document or raw bytes on stdout. */ +export interface JsonOutput { + stdout: 'json'; + data: unknown; + compact: boolean; +} +export interface RawOutput { + stdout: 'raw'; + bytes: Uint8Array; +} +export type CommandOutput = JsonOutput | RawOutput; + /** * JSON.stringify replacer that makes decoded values safe to serialize: * bigint → decimal string, byte arrays → hex, Map/Set → plain structures. diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 7fa98b0..f6e87b9 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -40,6 +40,41 @@ export const COMMANDS: CommandDoc[] = [ { flag: '--help, -h', description: 'Show help for this command.' }, ], }, + { + name: 'query', + summary: 'Run a query and decode the result in one step (native TCP capture or HTTP).', + usage: 'chfx query --query "" [--protocol tcp|http] [--format ...] [connection options]', + details: + 'tcp (default): drive clickhouse-client through a capturing proxy and decode the full packet ' + + 'stream (--save keeps the .chproto). http: POST to ClickHouse HTTP, request --format, decode the body.', + options: [ + { flag: '--query', value: 'sql', description: 'SQL to run (required).' }, + { flag: '--protocol', value: 'tcp|http', description: 'Transport. tcp = native capture (default); http = HTTP request.' }, + { flag: '--format', value: 'native|RowBinaryWithNamesAndTypes', description: 'HTTP body format to request + decode (http only; default native).' }, + { flag: '--protocol-version', value: 'N', description: 'client_protocol_version for an http Native query (default 0).' }, + { flag: '--save', value: 'file', description: 'Write the raw .chproto capture here (tcp only).' }, + { flag: '--host / --port', value: 'h / p', description: 'Server host / port (env CH_NATIVE_HOST, CH_NATIVE_PORT/CH_HTTP_PORT; default 9000 tcp, 8123 http).' }, + { flag: '--user / --password', description: 'Credentials (env CH_USER / CH_PASSWORD).' }, + { flag: '--database', value: 'db', description: 'Default database (env CH_DATABASE).' }, + { flag: '--setting', value: 'k=v', description: 'Per-query setting; repeatable.' }, + { flag: '--no-experimental-settings', description: 'Do not send Variant/Dynamic/JSON/QBit enabling settings.' }, + { flag: '--client', value: 'path', description: 'Path to clickhouse-client, tcp only (env CLICKHOUSE_CLIENT).' }, + { flag: '--no-node-bytes / --compact', description: 'Same output controls as decode.' }, + { flag: '--help, -h', description: 'Show help for this command.' }, + ], + }, + { + name: 'capture', + summary: 'Capture a query over the native protocol to a .chproto dump (no decode).', + usage: 'chfx capture --query "" [--out ] [connection options]', + details: 'Writes the dump to --out, or streams raw dump bytes to stdout when --out is omitted.', + options: [ + { flag: '--query', value: 'sql', description: 'SQL to run (required).' }, + { flag: '--out, -o', value: 'file', description: 'Write the .chproto dump here; omit to stream raw bytes to stdout.' }, + { flag: '(connection)', description: 'Same --host/--port/--user/--password/--database/--setting/--client as query.' }, + { flag: '--help, -h', description: 'Show help for this command.' }, + ], + }, ]; export function findCommand(name: string): CommandDoc | undefined { diff --git a/todo.md b/todo.md index 848e1e2..9a37fd4 100644 --- a/todo.md +++ b/todo.md @@ -3,11 +3,11 @@ Spec for items 1, 2, 4, 5: [docs/cli-spec.md](docs/cli-spec.md) 1. **CLI (`chfx`) usable by agent** — _implemented (branch `cli-foundation-decode`, - with item 4)._ npm bin via Node/tsx, publish-ready (esbuild bundle). Deterministic - JSON on stdout, JSON error envelope on stderr, non-interactive, self-describing. - Commands: `decode` + `--help`/`--version`. (A standalone `schema` command was - dropped — `--help` + self-describing output suffice for now; revisit when - `query`/`proxy` land.) + with item 4)._ npm bin via Node/tsx, publish-ready (esbuild bundle); `npm link` + for a PATH `chfx`. Deterministic JSON on stdout, JSON error envelope on stderr, + non-interactive, self-describing. Commands: `decode`, `query`, `capture` + + `--help`/`--version`. (A standalone `schema` command was dropped — `--help` + + self-describing output suffice for now.) 2. **Configurable server version in the image** — _done (#42)._ `ARG CH_VERSION` → `FROM clickhouse/clickhouse-server:${CH_VERSION}`, surfaced via docker-compose. 3. ~~Configurable protocol version in TCP + native web interface~~ — **dropped** @@ -16,12 +16,20 @@ Spec for items 1, 2, 4, 5: [docs/cli-spec.md](docs/cli-spec.md) `chfx decode`: autodetect `.chproto`/Native/RowBinary with `--format` override, stdin supported. Emits the web `ParsedData`/`AstNode` JSON; top-level `bytesHex` (whole buffer once) plus per-node inline `bytes` by default (`--no-node-bytes` - to omit). `query` transport deferred to a later branch. + to omit). + - **UX one-shot (implemented):** `chfx query --query ""` runs **and** decodes + in one step — no intermediate file. `--protocol tcp` (default) captures via + clickhouse-client; `--protocol http` POSTs and decodes the `--format` + (native | RowBinaryWithNamesAndTypes) body. `--save` keeps the dump (tcp). + `chfx capture` writes a dump (file or raw stdout); `npm run capture` aliases it. 5. **Use with external clients, in the CLI** — `chfx proxy`: standalone capture proxy any native client connects through. Single-shot by default; persistent - and live-decode via flags. Plaintext/uncompressed only. + and live-decode via flags. Plaintext/uncompressed only. _(Not yet built — the + `query`/`capture` above are us being the client; the proxy captures other + clients.)_ Cross-cutting: thorough CLI tests/fixtures; README quick-start + full options -reference; remote auth/TLS flags for `query`/`proxy`. +reference; remote auth/TLS flags. _(Native own-TCP-client to drop the +clickhouse-client dependency: considered, shelved for now.)_ 6. Deploy similar to play.clickhouse.com? From 510df3aa4ce14033acc2e5054c1585343c33f228 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 12 Jun 2026 11:36:52 +0200 Subject: [PATCH 3/7] fix(cli): address review findings (OOM on tiny input, EPIPE, stale schema help) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Blocker: a 0-column RowBinary header made decodeRows loop forever (offset never advances while remaining > 0), exhausting memory — trivially reachable since `decode` autodetect trials RowBinary first. `printf '\x00\x02' | chfx decode -` OOM'd the process. Guard the row loop to break on a non-advancing iteration (rowbinary-decoder.ts). Now terminates with a clean usage error. - High: piping decode output into a consumer that closes early (`… | head`) threw an unhandled EPIPE and dumped a Node stack trace to stderr, violating the clean-exit contract. Handle EPIPE on stdout/stderr and exit 0. - Stale `schema` references: general --help advertised a `chfx schema` command that was dropped; removed it and the registry comment. - Classify --save / --out write failures as io errors (not decode); wrap both writeFile calls. - README: build before `npm link` (link points at the not-yet-built binary). - Tidy an orphaned doc comment in connection.ts. Tests: regression for degenerate/tiny inputs terminating (no OOM). 45 CLI tests + 86 core unit tests pass; lint + tsc clean; both blockers verified fixed end-to-end (2-byte input → exit 2 usage error; `| head` → no stack trace). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++-- src/cli/cli.test.ts | 15 +++++++++++++++ src/cli/commands/capture.ts | 6 +++++- src/cli/commands/query.ts | 6 +++++- src/cli/connection.ts | 7 ------- src/cli/index.ts | 11 ++++++++++- src/cli/registry.ts | 2 +- src/core/decoder/rowbinary-decoder.ts | 4 ++++ 8 files changed, 42 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 76d5308..8412583 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ can be scripted or driven by an agent. ```bash npm install -npm link # makes `chfx` available on your PATH (uses dist/cli/index.js) -npm run cli:build # build the binary `chfx` runs +npm run cli:build # build the binary → dist/cli/index.js +npm link # makes `chfx` available on your PATH (points at the built binary) # Run a query and see it decoded — one step, no intermediate file: chfx query --query "SELECT number AS n, [number] AS arr FROM numbers(3)" diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 57b8019..a4a6f7f 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -104,6 +104,21 @@ describe('decodeBuffer — raw bodies', () => { expect(() => decodeBuffer(garbage)).toThrow(CliError); }); + it('terminates (no infinite loop / OOM) on tiny degenerate inputs', () => { + // A 0-column RowBinary header (0x00) used to loop forever and exhaust memory. + // These must each return or throw quickly — the vitest timeout guards regressions. + for (const bytes of [[0x00, 0x02], [0x00], [0x01], [0x00, 0x00, 0x00]]) { + const input = Uint8Array.from(bytes); + try { + decodeBuffer(input); + } catch (err) { + expect(err).toBeInstanceOf(CliError); + } + // Forcing rowbinary on a 0-column header must terminate too. + expect(() => decodeBuffer(Uint8Array.from([0x00, 0x00]), { format: 'rowbinary' })).not.toThrow(); + } + }); + it('propagates a decode failure when forced to the wrong format', () => { const chproto = readFixture(fixtures[0]); expect(() => decodeBuffer(chproto, { format: 'rowbinary' })).toThrow(); diff --git a/src/cli/commands/capture.ts b/src/cli/commands/capture.ts index 72cfb15..79d91bf 100644 --- a/src/cli/commands/capture.ts +++ b/src/cli/commands/capture.ts @@ -47,7 +47,11 @@ export async function captureCommand( return { stdout: 'raw', bytes: new Uint8Array(dump) }; } - await writeFile(out, dump); + try { + await writeFile(out, dump); + } catch (err) { + throw new CliError('io', `cannot write --out file: ${out}`, { cause: (err as Error).message }); + } const data = { chfx: { tool: 'chfx', version: CHFX_VERSION, schemaVersion: CLI_SCHEMA_VERSION, command: 'capture' }, query: captureOpts.query, diff --git a/src/cli/commands/query.ts b/src/cli/commands/query.ts index 865ac54..6814817 100644 --- a/src/cli/commands/query.ts +++ b/src/cli/commands/query.ts @@ -90,7 +90,11 @@ async function runTcp( throw new CliError('io', `capture failed: ${(err as Error).message}`); } if (save) { - await writeFile(save, encodeDump(capture)); + try { + await writeFile(save, encodeDump(capture)); + } catch (err) { + throw new CliError('io', `cannot write --save file: ${save}`, { cause: (err as Error).message }); + } } const result: DecodeResult = { ...decodeCaptureStreams(capture.c2s, capture.s2c, capture.meta), formatDetected: true }; diff --git a/src/cli/connection.ts b/src/cli/connection.ts index 3db6261..dd53423 100644 --- a/src/cli/connection.ts +++ b/src/cli/connection.ts @@ -32,13 +32,6 @@ function parseSettings(pairs: string[]): Record { return out; } -/** - * Resolve connection + query options from CLI args, falling back to the same - * environment variables the capture server uses (CH_NATIVE_HOST/PORT, CH_USER, - * CH_PASSWORD, CH_DATABASE, CLICKHOUSE_CLIENT). Undefined fields let the proxy - * apply its own defaults (127.0.0.1:9000, user "default"). Experimental type - * settings are merged first so explicit --setting can override them. - */ export interface QueryBase { query: string; host?: string; diff --git a/src/cli/index.ts b/src/cli/index.ts index 2f52338..27381e6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,7 +23,7 @@ function generalHelp(): string { ' --version, -V Print version', '', 'Output: a single JSON document on stdout; diagnostics and a JSON error', - 'envelope on stderr. Run "chfx schema" for machine-readable documentation.', + 'envelope on stderr. Exit codes: 0 ok, 2 usage, 1 i/o or decode.', ]; return lines.join('\n'); } @@ -82,6 +82,15 @@ async function run(argv: string[]): Promise { return 0; } +// Exit cleanly when a downstream consumer closes the pipe early (e.g. `… | head`) +// instead of crashing with an unhandled EPIPE stack trace. +for (const stream of [process.stdout, process.stderr]) { + stream.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') process.exit(0); + throw err; + }); +} + run(process.argv.slice(2)) .then((code) => { process.exitCode = code; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f6e87b9..3232301 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -12,7 +12,7 @@ export interface CommandDoc { options: OptionDoc[]; } -/** Single source of truth for `chfx schema` and the human `--help` text. */ +/** Single source of truth for the human `--help` text. */ export const COMMANDS: CommandDoc[] = [ { name: 'decode', diff --git a/src/core/decoder/rowbinary-decoder.ts b/src/core/decoder/rowbinary-decoder.ts index c9a6cd2..73ac427 100644 --- a/src/core/decoder/rowbinary-decoder.ts +++ b/src/core/decoder/rowbinary-decoder.ts @@ -98,6 +98,10 @@ export class RowBinaryDecoder extends FormatDecoder { values.push(node); } + // Guard against a non-advancing iteration (e.g. a 0-column header), which + // would otherwise loop forever on the remaining bytes and exhaust memory. + if (this.reader.offset === rowStart) break; + rows.push({ index: rowIndex++, byteRange: { start: rowStart, end: this.reader.offset }, From 60e739d545d6c53a0cf7e29f72e98fd9579e281b Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 12 Jun 2026 11:46:35 +0200 Subject: [PATCH 4/7] test(cli): cover option combos, failure modes, env fallbacks, http request shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the gaps from the coverage review (45 → 67 tests): - decode edge/error: empty input, missing file, invalid --protocol-version, ambiguous raw-body autodetect. - query http request construction (spied fetch): default_format, --setting and --database query params, X-ClickHouse-User/-Key headers, body, port 8123, and --protocol-version → client_protocol_version; RowBinaryWithNamesAndTypes format. - query/capture failure modes: tcp capture throw → io, --save write failure → io, empty http body → decode, http transport throw → io, unknown --protocol, unknown http --format, capture-command throw → io. - capture: -o alias, --out - raw stdout. - connection: CH_NATIVE_HOST/PORT + CH_HTTP_PORT env fallbacks and flag precedence, resolveHttpConnection defaults, --setting overriding an experimental default (added a withEnv save/restore helper and a shared fakeCaptureOf). - e2e via tsx: --version, unknown-command exit 2, and a clean-exit-on-EPIPE check (decode | head closes early → no stack trace). eslint + tsc clean; 67 CLI tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/cli.test.ts | 248 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 230 insertions(+), 18 deletions(-) diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index a4a6f7f..ee521ca 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -1,19 +1,37 @@ import { describe, it, expect } from 'vitest'; -import { readFileSync, readdirSync, rmSync } from 'node:fs'; +import { readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { decodeBuffer, decodeCommand } from './commands/decode'; import { queryCommand } from './commands/query'; import { captureCommand } from './commands/capture'; -import { resolveCaptureOptions } from './connection'; +import { resolveCaptureOptions, resolveHttpConnection } from './connection'; import { parseArgs, stringOption, boolOption, arrayOption } from './args'; import { stringify, CliError } from './output'; import { ClickHouseFormat } from '../core/types/formats'; import { parseChprotoDump } from '../core/decoder/protocol-dump'; +/** Run `fn` with env vars temporarily set, restoring prior values after. */ +function withEnv(vars: Record, fn: () => void): void { + const prior: Record = {}; + for (const key of Object.keys(vars)) { + prior[key] = process.env[key]; + if (vars[key] === undefined) delete process.env[key]; + else process.env[key] = vars[key]; + } + try { + fn(); + } finally { + for (const key of Object.keys(prior)) { + if (prior[key] === undefined) delete process.env[key]; + else process.env[key] = prior[key]; + } + } +} + const FIXTURE_DIR = fileURLToPath(new URL('../core/decoder/fixtures/protocol/', import.meta.url)); const CLI_ENTRY = fileURLToPath(new URL('./index.ts', import.meta.url)); @@ -26,6 +44,22 @@ const enc = (s: string) => [...s].map((c) => c.charCodeAt(0)); const ROWBINARY_BODY = new Uint8Array([0x01, 0x01, ...enc('x'), 0x05, ...enc('UInt8'), 0x01]); const NATIVE_BODY = new Uint8Array([0x01, 0x01, 0x01, ...enc('x'), 0x05, ...enc('UInt8'), 0x01]); +/** Build a fake native capture from a real fixture (for an injected captureQuery). */ +function fakeCaptureOf(name: string) { + const { c2s, s2c, meta } = parseChprotoDump(readFixture(name)); + const cb = Buffer.from(c2s); + const sb = Buffer.from(s2c); + return { + c2s: cb, + s2c: sb, + segments: [ + { dir: 0 as const, data: cb }, + { dir: 1 as const, data: sb }, + ], + meta: meta ?? {}, + }; +} + describe('parseArgs', () => { it('parses positionals, long/short flags, =values, and value flags', () => { const args = parseArgs(['file.bin', '--format', 'native', '-f', 'rowbinary', '--compact', '--protocol-version=54483'], { @@ -243,21 +277,8 @@ describe('resolveCaptureOptions', () => { }); describe('query / capture (with injected capture)', () => { - // Build a fake capture from a real fixture so no server/clickhouse-client is needed. - function fakeCapture() { - const { c2s, s2c, meta } = parseChprotoDump(readFixture(fixtures[0])); - const cb = Buffer.from(c2s); - const sb = Buffer.from(s2c); - return { - c2s: cb, - s2c: sb, - segments: [ - { dir: 0 as const, data: cb }, - { dir: 1 as const, data: sb }, - ], - meta: meta ?? {}, - }; - } + // Use a fake capture from a real fixture so no server/clickhouse-client is needed. + const fakeCapture = () => fakeCaptureOf(fixtures[0]); const deps = { captureQuery: async () => fakeCapture() }; it('query captures + decodes in one step', async () => { @@ -339,6 +360,164 @@ describe('query --protocol http (injected fetch)', () => { }); }); +describe('decode — edge & error paths', () => { + it('rejects empty input as a usage error', async () => { + const path = join(tmpdir(), 'chfx-empty.bin'); + writeFileSync(path, Buffer.alloc(0)); + try { + await expect(decodeCommand([path])).rejects.toThrow(/empty/); + } finally { + rmSync(path, { force: true }); + } + }); + + it('reports a missing file as an io error', async () => { + await expect(decodeCommand([join(tmpdir(), 'chfx-does-not-exist.bin')])).rejects.toThrow(CliError); + }); + + it('rejects an invalid --protocol-version before reading input', async () => { + await expect(decodeCommand(['whatever', '--protocol-version', '-1'])).rejects.toThrow(/non-negative integer/); + await expect(decodeCommand(['whatever', '--protocol-version', 'abc'])).rejects.toThrow(/non-negative integer/); + }); + + it('reports an ambiguous raw body (both decoders accept) as a usage error', () => { + // A 0x00 lead byte parses as a 0-column RowBinary header AND as a Native block. + expect(() => decodeBuffer(Uint8Array.from([0x00, 0x02]))).toThrow(/ambiguous/); + }); +}); + +describe('query — http request construction (spied fetch)', () => { + it('builds URL params + auth headers + body correctly', async () => { + let url = ''; + let init: RequestInit | undefined; + const fetchSpy = (async (u: string | URL | Request, i?: RequestInit) => { + url = String(u); + init = i; + return new Response(NATIVE_BODY, { status: 200 }); + }) as typeof fetch; + + await queryCommand( + ['--query', 'SELECT 1', '--protocol', 'http', '--user', 'bob', '--password', 'pw', '--database', 'db1', '--setting', 'max_threads=2', '--no-experimental-settings'], + { fetch: fetchSpy }, + ); + + const parsed = new URL(url); + expect(parsed.port).toBe('8123'); + expect(parsed.searchParams.get('default_format')).toBe('Native'); + expect(parsed.searchParams.get('max_threads')).toBe('2'); + expect(parsed.searchParams.get('database')).toBe('db1'); + const headers = init!.headers as Record; + expect(headers['X-ClickHouse-User']).toBe('bob'); + expect(headers['X-ClickHouse-Key']).toBe('pw'); + expect(init!.body).toBe('SELECT 1'); + }); + + it('requests RowBinaryWithNamesAndTypes when --format selects it', async () => { + let url = ''; + const fetchSpy = (async (u: string | URL | Request) => { + url = String(u); + return new Response(ROWBINARY_BODY, { status: 200 }); + }) as typeof fetch; + await queryCommand(['--query', 'SELECT 1', '--protocol', 'http', '--format', 'RowBinaryWithNamesAndTypes'], { fetch: fetchSpy }); + expect(new URL(url).searchParams.get('default_format')).toBe('RowBinaryWithNamesAndTypes'); + }); + + it('forwards --protocol-version as client_protocol_version', async () => { + let url = ''; + const fetchSpy = (async (u: string | URL | Request) => { + url = String(u); + return new Response(NATIVE_BODY, { status: 200 }); + }) as typeof fetch; + // decode of the v0 body at this revision may fail; we only assert the request param. + await queryCommand(['--query', 'x', '--protocol', 'http', '--protocol-version', '54465'], { fetch: fetchSpy }).catch(() => {}); + expect(new URL(url).searchParams.get('client_protocol_version')).toBe('54465'); + }); +}); + +describe('query / capture — failure modes', () => { + const throwingCapture = { captureQuery: async () => { throw new Error('boom'); } }; + const fetchThrows = (async () => { throw new Error('ECONNREFUSED'); }) as typeof fetch; + const fetchEmpty = (async () => new Response(new Uint8Array(0), { status: 200 })) as typeof fetch; + + it('wraps a tcp capture failure as an io error', async () => { + await expect(queryCommand(['--query', 'x'], throwingCapture)).rejects.toThrow(/capture failed/); + }); + + it('wraps a --save write failure as an io error', async () => { + const badPath = join(tmpdir(), 'chfx-no-such-dir', 'x.chproto'); + await expect(queryCommand(['--query', 'x', '--save', badPath], { captureQuery: async () => fakeCaptureOf(fixtures[0]) })) + .rejects.toThrow(/cannot write --save/); + }); + + it('reports an empty http body as a decode error', async () => { + await expect(queryCommand(['--query', 'x', '--protocol', 'http'], { fetch: fetchEmpty })).rejects.toThrow(/empty body/); + }); + + it('wraps an http transport failure as an io error', async () => { + await expect(queryCommand(['--query', 'x', '--protocol', 'http'], { fetch: fetchThrows })).rejects.toThrow(/HTTP request failed/); + }); + + it('rejects an unknown --protocol', async () => { + await expect(queryCommand(['--query', 'x', '--protocol', 'ftp'])).rejects.toThrow(/unknown --protocol/); + }); + + it('rejects an unknown http --format', async () => { + await expect(queryCommand(['--query', 'x', '--protocol', 'http', '--format', 'json'], { fetch: fetchEmpty })) + .rejects.toThrow(/unknown --format/); + }); + + it('wraps a capture-command failure as an io error', async () => { + await expect(captureCommand(['--query', 'x'], throwingCapture)).rejects.toThrow(/capture failed/); + }); +}); + +describe('capture — aliases & raw stdout variants', () => { + it('-o is an alias for --out', async () => { + const path = join(tmpdir(), 'chfx-capture-alias.chproto'); + try { + const out = await captureCommand(['--query', 'x', '-o', path], { captureQuery: async () => fakeCaptureOf(fixtures[0]) }); + expect(out.stdout).toBe('json'); + expect(readFileSync(path).length).toBeGreaterThan(0); + } finally { + rmSync(path, { force: true }); + } + }); + + it('--out - streams raw bytes to stdout', async () => { + const out = await captureCommand(['--query', 'x', '--out', '-'], { captureQuery: async () => fakeCaptureOf(fixtures[0]) }); + expect(out.stdout).toBe('raw'); + }); +}); + +describe('connection — env fallbacks & precedence', () => { + it('uses CH_NATIVE_HOST/PORT when flags are absent, flags win when present', () => { + withEnv({ CH_NATIVE_HOST: 'envhost', CH_NATIVE_PORT: '9999' }, () => { + const fromEnv = resolveCaptureOptions(parseArgs(['--query', 'x'], { valueFlags: ['query'] })); + expect(fromEnv).toMatchObject({ host: 'envhost', port: 9999 }); + + const flagWins = resolveCaptureOptions( + parseArgs(['--query', 'x', '--host', 'flaghost', '--port', '8888'], { valueFlags: ['query', 'host', 'port'] }), + ); + expect(flagWins).toMatchObject({ host: 'flaghost', port: 8888 }); + }); + }); + + it('resolveHttpConnection defaults to 127.0.0.1:8123 and honors CH_HTTP_PORT', () => { + const def = resolveHttpConnection(parseArgs(['--query', 'x'], { valueFlags: ['query'] })); + expect(def).toMatchObject({ host: '127.0.0.1', port: 8123 }); + withEnv({ CH_HTTP_PORT: '18123' }, () => { + expect(resolveHttpConnection(parseArgs(['--query', 'x'], { valueFlags: ['query'] })).port).toBe(18123); + }); + }); + + it('an explicit --setting overrides a default experimental setting', () => { + const opts = resolveCaptureOptions( + parseArgs(['--query', 'x', '--setting', 'allow_experimental_json_type=0'], { valueFlags: ['query'], multiFlags: ['setting'] }), + ); + expect(opts.settings!.allow_experimental_json_type).toBe('0'); + }); +}); + describe('end-to-end via tsx (entry, stdin, exit codes)', () => { const run = (args: string[], input?: Buffer) => execFileSync('npx', ['tsx', CLI_ENTRY, ...args], { input, stdio: ['pipe', 'pipe', 'pipe'] }).toString(); @@ -366,4 +545,37 @@ describe('end-to-end via tsx (entry, stdin, exit codes)', () => { expect(code).toBe(2); expect(JSON.parse(stderr).error.kind).toBe('usage'); }, 30000); + + it('prints the version and exits 0', () => { + expect(run(['--version']).trim()).toMatch(/^\d+\.\d+\.\d+/); + }, 30000); + + it('rejects an unknown command with exit 2', () => { + let code = 0; + let stderr = ''; + try { + execFileSync('npx', ['tsx', CLI_ENTRY, 'frobnicate'], { stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err) { + const e = err as { status: number; stderr: Buffer }; + code = e.status; + stderr = e.stderr.toString(); + } + expect(code).toBe(2); + expect(JSON.parse(stderr).error.message).toMatch(/unknown command/); + }, 30000); + + it('exits cleanly when stdout is closed early (no EPIPE stack trace)', () => { + const errFile = join(tmpdir(), 'chfx-epipe.err'); + try { + // Large pretty output piped into a reader that closes after a few bytes. + execSync(`npx tsx "${CLI_ENTRY}" decode "${fixturePath(fixtures[0])}" 2>"${errFile}" | head -c 5 >/dev/null`, { + shell: '/bin/bash', + stdio: ['ignore', 'ignore', 'ignore'], + }); + const err = readFileSync(errFile, 'utf8'); + expect(err).not.toMatch(/EPIPE|Error:|\bat /); + } finally { + rmSync(errFile, { force: true }); + } + }, 30000); }); From 2e6c8c17b9bad01818505d51f76c4fdad7d4b2ff Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 12 Jun 2026 11:51:07 +0200 Subject: [PATCH 5/7] fix(capture): don't hang when clickhouse-client exits before connecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuzz testing found that a TCP `chfx query`/`capture` hangs forever on any flag the client rejects at startup (e.g. --setting totally_fake_setting_xyz=1 or a bad setting value): clickhouse-client exits before opening the proxied TCP connection, so the proxy's `done` promise (which only resolves once both ends of a connection close) never settles and `await done` blocks indefinitely. Fix in scripts/native-proxy.mjs (shared by the web/Electron/CLI capture paths): - Settle `done` exactly once via finishOk/finishErr, and make the proxy's close() resolve `done` with whatever was captured so far. - In captureQuery, on a non-zero client exit, race `done` against a 100ms grace window then force-close — so a pre-connect failure yields a clean io error ("clickhouse-client exited 40: …") instead of a hang. The happy path and the connected-but-failed path (server Exception captured) are unchanged. Regression test uses `false` as the client (exits before connecting) so it needs no server. 68 CLI tests pass; lint + tsc clean; verified against the live server that the original repros now return a clean error and normal queries still work. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/native-proxy.mjs | 36 ++++++++++++++++++++++++++++-------- src/cli/cli.test.ts | 10 ++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/scripts/native-proxy.mjs b/scripts/native-proxy.mjs index 913b307..cd5b6db 100644 --- a/scripts/native-proxy.mjs +++ b/scripts/native-proxy.mjs @@ -64,6 +64,22 @@ export function startProxy({ targetHost, targetPort, listenHost = '127.0.0.1' }) }); let handled = false; + // Settle `done` exactly once. Crucially, `close()` resolves it too, so a + // caller can always unblock `await done` even if no client ever connected + // (e.g. the client rejected a bad flag and exited before connecting). + let settled = false; + const finishOk = () => { + if (settled) return; + settled = true; + try { server.close(); } catch { /* ignore */ } + resolveDone(segments); + }; + const finishErr = (/** @type {Error} */ err) => { + if (settled) return; + settled = true; + try { server.close(); } catch { /* ignore */ } + rejectDone(err); + }; const server = net.createServer((client) => { if (handled) { @@ -77,10 +93,7 @@ export function startProxy({ targetHost, targetPort, listenHost = '127.0.0.1' }) let openEnds = 2; const closeOne = () => { openEnds -= 1; - if (openEnds === 0) { - server.close(); - resolveDone(segments); - } + if (openEnds === 0) finishOk(); }; client.on('data', (chunk) => { @@ -100,8 +113,7 @@ export function startProxy({ targetHost, targetPort, listenHost = '127.0.0.1' }) const fail = (/** @type {Error} */ err) => { client.destroy(); upstream.destroy(); - try { server.close(); } catch { /* ignore */ } - rejectDone(err); + finishErr(err); }; client.on('error', fail); upstream.on('error', fail); @@ -114,7 +126,8 @@ export function startProxy({ targetHost, targetPort, listenHost = '127.0.0.1' }) reject(new Error('proxy failed to bind an ephemeral port')); return; } - resolve({ port: addr.port, done, close: () => server.close() }); + // `close()` resolves `done` with whatever was captured so far. + resolve({ port: addr.port, done, close: finishOk }); }); }); } @@ -178,10 +191,17 @@ export async function captureQuery({ let segments; try { const code = await exit; + if (code !== 0) { + // The client exited non-zero. If it failed before completing a proxied + // connection (e.g. it rejected a bad --setting flag at startup), `done` + // never resolves on its own. Give any in-flight close events a brief + // window, then force the proxy closed so we don't hang forever. + await Promise.race([done, new Promise((r) => setTimeout(r, 100))]); + close(); + } segments = await done; if (code !== 0 && segments.every((s) => s.dir === DIR_C2S)) { // Client failed before the server answered anything useful. - close(); throw new Error(`clickhouse-client exited ${code}: ${stderr.trim()}`); } } catch (err) { diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index ee521ca..5d1aee0 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -13,6 +13,7 @@ import { parseArgs, stringOption, boolOption, arrayOption } from './args'; import { stringify, CliError } from './output'; import { ClickHouseFormat } from '../core/types/formats'; import { parseChprotoDump } from '../core/decoder/protocol-dump'; +import { captureQuery } from '../../scripts/native-proxy.mjs'; /** Run `fn` with env vars temporarily set, restoring prior values after. */ function withEnv(vars: Record, fn: () => void): void { @@ -518,6 +519,15 @@ describe('connection — env fallbacks & precedence', () => { }); }); +describe('native proxy — no hang when the client exits before connecting', () => { + it('rejects instead of hanging when clickhouse-client exits pre-connect', async () => { + // `false` exits non-zero immediately without ever connecting to the proxy. + // Before the fix this hung forever (the proxy `done` never resolved); the + // 10s timeout fails the test if that regresses. + await expect(captureQuery({ query: 'SELECT 1', clientPath: 'false' })).rejects.toThrow(/exited/); + }, 10000); +}); + describe('end-to-end via tsx (entry, stdin, exit codes)', () => { const run = (args: string[], input?: Buffer) => execFileSync('npx', ['tsx', CLI_ENTRY, ...args], { input, stdio: ['pipe', 'pipe', 'pipe'] }).toString(); From 537fe39d02187845b32195589dba0bab6e39264a Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 12 Jun 2026 12:27:11 +0200 Subject: [PATCH 6/7] fix(cli): reject unknown options and unexpected positionals (review) Address Copilot review on PR #43: the permissive parser meant commands silently ignored unknown flags (a typo like `--protcol http` ran the default tcp path) and extra positionals. Add rejectUnknownArgs(allowed, maxPositionals) and call it in decode/query/capture so unrecognized options or surplus arguments fail fast as `usage` errors (exit 2), matching the documented contract. Also correct the stringOption doc comment (it errors on a repeated *multi* flag, not any repeat). Tests: unknown-flag + extra-positional rejection for decode/query/capture. 73 CLI tests pass; lint + tsc clean; verified `--protcol` now errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/args.ts | 25 ++++++++++++++++++++++++- src/cli/cli.test.ts | 20 ++++++++++++++++++++ src/cli/commands/capture.ts | 5 +++-- src/cli/commands/decode.ts | 3 ++- src/cli/commands/query.ts | 11 +++++++++-- src/cli/connection.ts | 2 ++ 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/cli/args.ts b/src/cli/args.ts index 176a8d9..bc5c7b3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -78,7 +78,11 @@ export function parseArgs(argv: string[], spec: ArgSpec = {}): ParsedArgs { return { positionals, options }; } -/** Read an option as a string, or undefined if absent. Errors if it's a bare boolean flag or repeated. */ +/** + * Read an option as a single string, or undefined if absent. Errors if it was + * given without a value (a bare boolean flag) or accumulated as an array + * (declared in `multiFlags` and given more than once). + */ export function stringOption(args: ParsedArgs, name: string): string | undefined { const v = args.options[name]; if (v === undefined) return undefined; @@ -95,6 +99,25 @@ export function boolOption(args: ParsedArgs, name: string): boolean { return args.options[name] === true || args.options[name] === 'true'; } +/** + * Reject anything the command doesn't recognize: an option whose (canonical) + * name isn't in `allowed`, or positionals beyond `maxPositionals`. Keeps the + * permissive parser generic while letting each command fail fast on typos like + * `--protcol` instead of silently ignoring them. `allowed` lists canonical + * names (aliases are already resolved by parseArgs). + */ +export function rejectUnknownArgs(args: ParsedArgs, allowed: string[], maxPositionals = 0): void { + const known = new Set(allowed); + for (const name of Object.keys(args.options)) { + if (!known.has(name)) { + throw new CliError('usage', `unknown option: --${name}`); + } + } + if (args.positionals.length > maxPositionals) { + throw new CliError('usage', `unexpected argument: ${args.positionals[maxPositionals]}`); + } +} + /** Read a repeatable option as an array (empty if absent). */ export function arrayOption(args: ParsedArgs, name: string): string[] { const v = args.options[name]; diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 5d1aee0..b1c1dfc 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -385,6 +385,14 @@ describe('decode — edge & error paths', () => { // A 0x00 lead byte parses as a 0-column RowBinary header AND as a Native block. expect(() => decodeBuffer(Uint8Array.from([0x00, 0x02]))).toThrow(/ambiguous/); }); + + it('rejects an unknown flag instead of silently ignoring it', async () => { + await expect(decodeCommand(['file.bin', '--frmat', 'native'])).rejects.toThrow(/unknown option: --frmat/); + }); + + it('rejects an extra positional argument', async () => { + await expect(decodeCommand(['a.bin', 'b.bin'])).rejects.toThrow(/unexpected argument: b\.bin/); + }); }); describe('query — http request construction (spied fetch)', () => { @@ -467,9 +475,21 @@ describe('query / capture — failure modes', () => { .rejects.toThrow(/unknown --format/); }); + it('rejects a typo\'d/unknown flag (e.g. --protcol) instead of ignoring it', async () => { + await expect(queryCommand(['--query', 'x', '--protcol', 'http'])).rejects.toThrow(/unknown option: --protcol/); + }); + + it('rejects an unexpected positional argument', async () => { + await expect(queryCommand(['--query', 'x', 'extra'])).rejects.toThrow(/unexpected argument: extra/); + }); + it('wraps a capture-command failure as an io error', async () => { await expect(captureCommand(['--query', 'x'], throwingCapture)).rejects.toThrow(/capture failed/); }); + + it('capture rejects an unknown flag', async () => { + await expect(captureCommand(['--query', 'x', '--nope'])).rejects.toThrow(/unknown option: --nope/); + }); }); describe('capture — aliases & raw stdout variants', () => { diff --git a/src/cli/commands/capture.ts b/src/cli/commands/capture.ts index 79d91bf..1d567de 100644 --- a/src/cli/commands/capture.ts +++ b/src/cli/commands/capture.ts @@ -7,10 +7,10 @@ import { type CaptureQueryOptions, } from '../../../scripts/native-proxy.mjs'; -import { parseArgs, stringOption, boolOption } from '../args'; +import { parseArgs, stringOption, boolOption, rejectUnknownArgs } from '../args'; import { CliError, type CommandOutput } from '../output'; import { CHFX_VERSION, CLI_SCHEMA_VERSION } from '../version'; -import { resolveCaptureOptions, CONNECTION_VALUE_FLAGS, CONNECTION_MULTI_FLAGS } from '../connection'; +import { resolveCaptureOptions, CONNECTION_VALUE_FLAGS, CONNECTION_MULTI_FLAGS, CONNECTION_ALLOWED } from '../connection'; export interface CaptureDeps { captureQuery: (opts: CaptureQueryOptions) => Promise; @@ -30,6 +30,7 @@ export async function captureCommand( multiFlags: CONNECTION_MULTI_FLAGS, aliases: { o: 'out' }, }); + rejectUnknownArgs(args, [...CONNECTION_ALLOWED, 'out', 'compact']); const compact = boolOption(args, 'compact'); const out = stringOption(args, 'out'); const captureOpts = resolveCaptureOptions(args); diff --git a/src/cli/commands/decode.ts b/src/cli/commands/decode.ts index 30ac866..d049b83 100644 --- a/src/cli/commands/decode.ts +++ b/src/cli/commands/decode.ts @@ -10,7 +10,7 @@ import { DEFAULT_NATIVE_PROTOCOL_VERSION } from '../../core/types/native-protoco import { CliError, type JsonOutput } from '../output'; import { CHFX_VERSION, CLI_SCHEMA_VERSION } from '../version'; -import { parseArgs, stringOption, boolOption } from '../args'; +import { parseArgs, stringOption, boolOption, rejectUnknownArgs } from '../args'; export const FORMAT_NAMES = ['chproto', 'native', 'rowbinary'] as const; export type FormatName = (typeof FORMAT_NAMES)[number]; @@ -220,6 +220,7 @@ export async function decodeCommand(rest: string[]): Promise { valueFlags: ['format', 'protocol-version'], aliases: { f: 'format' }, }); + rejectUnknownArgs(args, ['format', 'protocol-version', 'compact', 'no-node-bytes'], 1); const format = stringOption(args, 'format') as FormatName | undefined; if (format && !FORMAT_NAMES.includes(format)) { diff --git a/src/cli/commands/query.ts b/src/cli/commands/query.ts index 6814817..e3fca33 100644 --- a/src/cli/commands/query.ts +++ b/src/cli/commands/query.ts @@ -9,9 +9,15 @@ import { import { ClickHouseFormat } from '../../core/types/formats'; import { appendClickHouseRequestParams } from '../../core/clickhouse/request-params'; -import { parseArgs, stringOption, boolOption } from '../args'; +import { parseArgs, stringOption, boolOption, rejectUnknownArgs } from '../args'; import { CliError, type JsonOutput } from '../output'; -import { resolveCaptureOptions, resolveHttpConnection, CONNECTION_VALUE_FLAGS, CONNECTION_MULTI_FLAGS } from '../connection'; +import { + resolveCaptureOptions, + resolveHttpConnection, + CONNECTION_VALUE_FLAGS, + CONNECTION_MULTI_FLAGS, + CONNECTION_ALLOWED, +} from '../connection'; import { decodeBuffer, decodeCaptureStreams, buildDecodeEnvelope, type DecodeResult, type FormatName } from './decode'; export interface QueryDeps { @@ -56,6 +62,7 @@ export async function queryCommand(rest: string[], deps: Partial = {} valueFlags: [...CONNECTION_VALUE_FLAGS, 'save', 'protocol', 'format', 'protocol-version'], multiFlags: CONNECTION_MULTI_FLAGS, }); + rejectUnknownArgs(args, [...CONNECTION_ALLOWED, 'save', 'protocol', 'format', 'protocol-version', 'compact', 'no-node-bytes']); const compact = boolOption(args, 'compact'); const includeNodeBytes = !boolOption(args, 'no-node-bytes'); diff --git a/src/cli/connection.ts b/src/cli/connection.ts index dd53423..e8f6322 100644 --- a/src/cli/connection.ts +++ b/src/cli/connection.ts @@ -21,6 +21,8 @@ const EXPERIMENTAL_SETTINGS: Record = { /** Flags shared by `query` and `capture` for arg-parser specs. */ export const CONNECTION_VALUE_FLAGS = ['query', 'host', 'port', 'user', 'password', 'database', 'client']; export const CONNECTION_MULTI_FLAGS = ['setting']; +/** Every connection-related option name (for unknown-arg rejection). */ +export const CONNECTION_ALLOWED = [...CONNECTION_VALUE_FLAGS, ...CONNECTION_MULTI_FLAGS, 'no-experimental-settings']; function parseSettings(pairs: string[]): Record { const out: Record = {}; From bd9c41ef19f2bd37f7ebdd90321ececb7492b68a Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 12 Jun 2026 13:18:33 +0200 Subject: [PATCH 7/7] fix(cli): reject --save '-' and out-of-range ports (review round 2) Address the second Copilot review on PR #43: - query --save '-' previously wrote a file literally named "-"; since stdout carries the decoded JSON, reject "-" as a usage error with a clear message. - --port accepted values > 65535 (only >0 was checked); require 1..65535 so invalid ports fail fast with a clear message instead of at connect time. Tests added for both. 75 CLI tests pass; lint + tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/cli.test.ts | 11 +++++++++++ src/cli/commands/query.ts | 3 +++ src/cli/connection.ts | 4 ++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index b1c1dfc..2bd4550 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -275,6 +275,12 @@ describe('resolveCaptureOptions', () => { CliError, ); }); + + it('rejects an out-of-range port', () => { + expect(() => + resolveCaptureOptions(parseArgs(['--query', 'x', '--port', '99999'], { valueFlags: ['query', 'port'] })), + ).toThrow(/between 1 and 65535/); + }); }); describe('query / capture (with injected capture)', () => { @@ -483,6 +489,11 @@ describe('query / capture — failure modes', () => { await expect(queryCommand(['--query', 'x', 'extra'])).rejects.toThrow(/unexpected argument: extra/); }); + it("rejects --save '-' (stdout already carries the JSON)", async () => { + await expect(queryCommand(['--query', 'x', '--save', '-'], { captureQuery: async () => fakeCaptureOf(fixtures[0]) })) + .rejects.toThrow(/--save '-' is not supported/); + }); + it('wraps a capture-command failure as an io error', async () => { await expect(captureCommand(['--query', 'x'], throwingCapture)).rejects.toThrow(/capture failed/); }); diff --git a/src/cli/commands/query.ts b/src/cli/commands/query.ts index e3fca33..9b97f85 100644 --- a/src/cli/commands/query.ts +++ b/src/cli/commands/query.ts @@ -88,6 +88,9 @@ async function runTcp( throw new CliError('usage', '--format only applies to --protocol http'); } const save = stringOption(args, 'save'); + if (save === '-') { + throw new CliError('usage', "--save '-' is not supported (stdout carries the decoded JSON); pass a file path"); + } const captureOpts = resolveCaptureOptions(args); let capture: Capture; diff --git a/src/cli/connection.ts b/src/cli/connection.ts index e8f6322..5955299 100644 --- a/src/cli/connection.ts +++ b/src/cli/connection.ts @@ -68,8 +68,8 @@ export function resolveQueryBase(args: ParsedArgs): QueryBase { function parsePort(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const port = Number(raw); - if (!Number.isInteger(port) || port <= 0) { - throw new CliError('usage', `--port must be a positive integer, got: ${raw}`); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new CliError('usage', `--port must be an integer between 1 and 65535, got: ${raw}`); } return port; }