#!/usr/bin/env node
// The `jsmql` command — a `jq`-style transpiler: JSMQL source in (positional
// arg / --file / stdin), MQL JSON out (stdout). A thin wrapper over the public
// API in ./index.ts — every shape it can emit already exists there as an
// entry point, so there is no compilation logic here, only argument routing,
// output formatting, and compiler-style error rendering.
//
// This file stays in TypeScript's strippable subset (see src/CLAUDE.md) and
// carries a classic Node shebang. esbuild bundles it to dist/cjs/cli.cjs
// (Node 14 target, shebang preserved) which package.json#bin maps to `jsmql`.
// Pull @types/node into scope for tsc only: this is the one src/ file that
// touches Node globals (`process`) and the `node:` builtins, and the project's
// tsconfig intentionally omits node types from the default src build. The
// directive is a comment, so the native type-stripper, esbuild, and Node all
// ignore it at runtime.
///
import { jsmql } from "./index.ts";
import { readFileSync } from "node:fs";
// Replaced at build time by esbuild `define` (scripts/build-cjs.mjs) with the
// package.json version. The `typeof` guard keeps the un-bundled `node
// src/cli.ts` run (where the identifier is genuinely undefined) from throwing.
declare const __JSMQL_VERSION__: string;
const VERSION: string = typeof __JSMQL_VERSION__ === "string" ? __JSMQL_VERSION__ : "0.0.0-dev";
const HELP = `jsmql — compile JSMQL source to MongoDB MQL JSON
Usage:
jsmql [options] [source]
Reads JSMQL from the positional [source], --file, or stdin (in that order) and
writes MQL JSON to stdout. With no mode flag the output shape is dispatched the
same way the jsmql() library call does (a top-level ';' makes it a Pipeline).
Output shape (default: polymorphic):
--filter force a Filter document (single object)
--pipeline force a Pipeline (array of stages)
--expr force an aggregation expression
--update force an update pipeline (whitelisted stages)
--validate report {valid, errors} as JSON; exit 1 if invalid
--check alias for --validate
Formatting (default: pretty, 2-space):
-c, --compact single-line minified JSON
--tab indent with tabs
--indent N indent with N spaces (0-10)
Input:
-f, --file PATH read source from PATH instead of stdin/positional
Parameters (route through jsmql.compile — the source must be a parameterised
arrow, e.g. '({ minAge }, $) => $.age > minAge'):
--arg NAME VALUE bind NAME to the string VALUE
--argjson NAME VALUE bind NAME to the JSON-parsed VALUE
Meta:
-h, --help show this help
--version print the jsmql version
Examples:
echo '$.age > 18' | jsmql
echo '$.age > 18; $sort({ age: -1 })' | jsmql --pipeline
echo '({ minAge }, $) => $.age > minAge' | jsmql --argjson minAge 18
`;
type Mode = "auto" | "filter" | "pipeline" | "expr" | "update" | "validate";
type Options = {
mode: Mode;
// `number` → that many spaces; `"\t"` → tabs; 0 → compact. Passed straight to
// JSON.stringify's third argument.
indent: number | string;
params: Record;
hasParams: boolean;
help: boolean;
version: boolean;
source?: string;
file?: string;
};
// Thrown for command-line mistakes (unknown/conflicting flags, a missing value,
// no input on a TTY). Distinct from a compile error so main() can map it to
// exit code 2 instead of 1.
class UsageError extends Error {}
function requireValue(v: string | undefined, flag: string): string {
if (v === undefined) throw new UsageError(`option '${flag}' requires a value.`);
return v;
}
function parseIndent(v: string): number {
const n = Number(v);
if (!Number.isInteger(n) || n < 0 || n > 10) {
throw new UsageError(`--indent expects an integer between 0 and 10, got '${v}'.`);
}
return n;
}
function parseJsonArg(name: string, raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
throw new UsageError(`--argjson ${name}: value is not valid JSON — ${raw}`);
}
}
function parseArgs(argv: string[]): Options {
const opts: Options = { mode: "auto", indent: 2, params: {}, hasParams: false, help: false, version: false };
const setMode = (m: Mode): void => {
if (opts.mode !== "auto") {
throw new UsageError(`conflicting output-shape flags: --${opts.mode} and --${m}. Pick one.`);
}
opts.mode = m;
};
let i = 0;
while (i < argv.length) {
const a = argv[i];
if (a === "-h" || a === "--help") opts.help = true;
else if (a === "--version") opts.version = true;
else if (a === "--filter") setMode("filter");
else if (a === "--pipeline") setMode("pipeline");
else if (a === "--expr") setMode("expr");
else if (a === "--update") setMode("update");
else if (a === "--validate" || a === "--check") setMode("validate");
else if (a === "-c" || a === "--compact") opts.indent = 0;
else if (a === "--tab") opts.indent = "\t";
else if (a === "--indent") {
i++;
opts.indent = parseIndent(requireValue(argv[i], "--indent"));
} else if (a === "-f" || a === "--file") {
i++;
opts.file = requireValue(argv[i], "--file");
} else if (a === "--arg") {
i++;
const name = requireValue(argv[i], "--arg");
i++;
opts.params[name] = requireValue(argv[i], "--arg");
opts.hasParams = true;
} else if (a === "--argjson") {
i++;
const name = requireValue(argv[i], "--argjson");
i++;
opts.params[name] = parseJsonArg(name, requireValue(argv[i], "--argjson"));
opts.hasParams = true;
} else if (a.length > 1 && a[0] === "-") {
throw new UsageError(`unknown option '${a}'.`);
} else {
if (opts.source !== undefined) {
throw new UsageError(`unexpected extra argument '${a}'. Provide a single source, or use --file.`);
}
opts.source = a;
}
i++;
}
return opts;
}
function resolveSource(opts: Options): string {
if (opts.source !== undefined) return opts.source;
if (opts.file !== undefined) return readFileSync(opts.file, "utf8");
if (process.stdin.isTTY) {
throw new UsageError("no input — pass a source argument, --file , or pipe JSMQL on stdin.");
}
// fd 0 = stdin; the synchronous read keeps the CLI dependency-free and
// Node-14 compatible (no streams, no util.parseArgs).
return readFileSync(0, "utf8");
}
function compile(mode: Mode, source: string): unknown {
if (mode === "filter") return jsmql.filter(source);
if (mode === "pipeline") return jsmql.pipeline(source);
if (mode === "expr") return jsmql.expr(source);
if (mode === "update") return jsmql.update(source);
return jsmql(source);
}
// Compiler-style error output: the message, then the offending source line with
// a caret under the column derived from the error's `.pos`. Every jsmql compile
// error (LexError / ParseError / CodegenError / UnknownIdentifierError /
// FunctionInputError) carries `pos: number`; when it is absent we print the
// message alone.
function renderError(err: unknown, source: string): string {
const e = err as { message?: string; pos?: number };
const message = typeof e.message === "string" ? e.message : String(err);
let out = `jsmql: error: ${message}\n`;
const pos = typeof e.pos === "number" ? e.pos : -1;
if (pos >= 0 && pos <= source.length && source.length > 0) {
const lineStart = source.lastIndexOf("\n", pos - 1) + 1;
const nl = source.indexOf("\n", pos);
const lineEnd = nl === -1 ? source.length : nl;
const lineText = source.slice(lineStart, lineEnd);
const col = pos - lineStart;
out += ` ${lineText}\n ${" ".repeat(col)}^\n`;
}
return out;
}
function usageExit(message: string): number {
process.stderr.write(`jsmql: ${message}\nTry 'jsmql --help'.\n`);
return 2;
}
function main(): number {
let opts: Options;
try {
opts = parseArgs(process.argv.slice(2));
} catch (err) {
if (err instanceof UsageError) return usageExit(err.message);
throw err;
}
if (opts.help) {
process.stdout.write(HELP);
return 0;
}
if (opts.version) {
process.stdout.write(VERSION + "\n");
return 0;
}
let source: string;
try {
// [DEF-028] Params (--arg/--argjson) route through jsmql.compile(), which
// the strict-shape entries and the structured validate() path don't expose.
// Rejecting the combination beats silently ignoring the bound values.
if (opts.hasParams && opts.mode !== "auto") {
throw new UsageError(
`parameters (--arg/--argjson) work only with the default output shape, not --${opts.mode}. ` +
"Drop the mode flag, or bind the values directly into the arrow body.",
);
}
// Trailing whitespace (notably the newline a shell `echo`/heredoc appends)
// is insignificant to the language; trimming it keeps an end-of-input
// error's caret on the source line instead of a dangling blank one.
source = resolveSource(opts).trimEnd();
} catch (err) {
if (err instanceof UsageError) return usageExit(err.message);
throw err;
}
try {
if (opts.mode === "validate") {
const result = jsmql.validate(source);
process.stdout.write(JSON.stringify(result, null, opts.indent) + "\n");
return result.valid ? 0 : 1;
}
const result = opts.hasParams ? jsmql.compile(source)(opts.params) : compile(opts.mode, source);
process.stdout.write(JSON.stringify(result, null, opts.indent) + "\n");
return 0;
} catch (err) {
process.stderr.write(renderError(err, source));
return 1;
}
}
process.exitCode = main();