From eb6e1f0d17cf2afb447c158f22c8d4f06f68f69c Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:13:29 -0300 Subject: [PATCH 1/8] feat(cli): add a brazilian-utils command that runs any utility Validating a CPF, generating a CNPJ for a fixture or looking a bank up from a shell script meant writing a throwaway Node script. The package now has a bin, so `npx @brazilian-utils/brazilian-utils isValidCpf 12345678909` works. The command is a generic dispatcher over src/index.ts, not a set of hand-written commands. The first argument names an export, positional values become its arguments and --key value, --flag, --no-flag and --json become its options or params object, so every utility added later is a command with no wiring. Values stay strings unless src/_cli/constants.ts gives them a kind (number, boolean, list, date). The type of those tables is derived from the public signatures, so a new non-string option that is not listed fails the type check. A value is read from stdin for "-" or when it is left out and stdin is a pipe or a file (not the socket a parent process opens, which would hang). Promises are awaited, which covers getAddressInfoByCep and getCepInfoByAddress. Objects print as JSON, dates as local calendar days, false and null exit with 1 and a usage error with 2. It costs library consumers nothing. No entry point imports it, it is built by a third pack config into dist/cli.js with the library left external, and every other file in dist/ is byte for byte what it was. The dispatcher is a pure function (run-cli) behind a process adapter (run-bin), both fully tested. Only the three-line Node.js entry is excluded from coverage, like src/index.ts. --- knip.json | 8 +- package.json | 3 + src/_cli/bin/bin.ts | 18 + .../coerce-cli-value/coerce-cli-value.test.ts | 149 +++++ src/_cli/coerce-cli-value/coerce-cli-value.ts | 61 ++ src/_cli/constants.ts | 163 +++++ .../parse-cli-arguments.test.ts | 180 +++++ .../parse-cli-arguments.ts | 87 +++ src/_cli/run-bin/run-bin.test.ts | 153 +++++ src/_cli/run-bin/run-bin.ts | 96 +++ src/_cli/run-cli/run-cli.test.ts | 615 ++++++++++++++++++ src/_cli/run-cli/run-cli.ts | 208 ++++++ src/_cli/to-cli-output/to-cli-output.test.ts | 151 +++++ src/_cli/to-cli-output/to-cli-output.ts | 60 ++ stryker.config.json | 3 +- vite.config.ts | 33 +- 16 files changed, 1984 insertions(+), 4 deletions(-) create mode 100644 src/_cli/bin/bin.ts create mode 100644 src/_cli/coerce-cli-value/coerce-cli-value.test.ts create mode 100644 src/_cli/coerce-cli-value/coerce-cli-value.ts create mode 100644 src/_cli/constants.ts create mode 100644 src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts create mode 100644 src/_cli/parse-cli-arguments/parse-cli-arguments.ts create mode 100644 src/_cli/run-bin/run-bin.test.ts create mode 100644 src/_cli/run-bin/run-bin.ts create mode 100644 src/_cli/run-cli/run-cli.test.ts create mode 100644 src/_cli/run-cli/run-cli.ts create mode 100644 src/_cli/to-cli-output/to-cli-output.test.ts create mode 100644 src/_cli/to-cli-output/to-cli-output.ts diff --git a/knip.json b/knip.json index ee3124cf8..7225c7e01 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,12 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/index.ts", "src/*/*.ts!", "scripts/*.ts", "stryker.config.json"], + "entry": [ + "src/index.ts", + "src/*/*.ts!", + "src/_cli/bin/bin.ts!", + "scripts/*.ts", + "stryker.config.json" + ], "project": ["src/**/*.ts", "scripts/**/*.ts"], "ignore": ["src/_internals/test/runtime-deno.ts"], "ignoreDependencies": ["@arethetypeswrong/core", "publint"] diff --git a/package.json b/package.json index 475e1de5c..1d414af23 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,9 @@ "type": "git", "url": "git+https://github.com/brazilian-utils/javascript.git" }, + "bin": { + "brazilian-utils": "./dist/cli.js" + }, "files": [ "./CHANGELOG.md", "./brazilian-utils.cdx.json", diff --git a/src/_cli/bin/bin.ts b/src/_cli/bin/bin.ts new file mode 100644 index 000000000..7dbe12dcc --- /dev/null +++ b/src/_cli/bin/bin.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { fstatSync } from "node:fs"; +import process from "node:process"; + +import packageJson from "../../../package.json" with { type: "json" }; +import * as api from "../../index"; +import { runBin } from "../run-bin/run-bin"; + +await runBin({ + api, + version: packageJson.version, + process, + isStdinPiped: () => { + const stats = fstatSync(process.stdin.fd); + + return stats.isFIFO() || stats.isFile(); + }, +}); diff --git a/src/_cli/coerce-cli-value/coerce-cli-value.test.ts b/src/_cli/coerce-cli-value/coerce-cli-value.test.ts new file mode 100644 index 000000000..ee1fd43cc --- /dev/null +++ b/src/_cli/coerce-cli-value/coerce-cli-value.test.ts @@ -0,0 +1,149 @@ +import * as fc from "fast-check"; + +import { anyText, anyValue } from "../../_internals/test/arbitraries"; +import { expectNeverThrows } from "../../_internals/test/properties"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type CliValueKind } from "../constants"; +import { coerceCliValue } from "./coerce-cli-value"; + +const KINDS: (CliValueKind | "text" | undefined)[] = [ + "boolean", + "date", + "list", + "number", + "text", + undefined, +]; + +describe("coerceCliValue", () => { + test("should keep text with no kind as it is, leading zeros included", () => { + expect(coerceCliValue("001")).toBe("001"); + expect(coerceCliValue("001", "text")).toBe("001"); + expect(coerceCliValue("")).toBe(""); + expect(coerceCliValue("true")).toBe("true"); + }); + + test("should turn the text of a number into that number", () => { + expect(coerceCliValue("2", "number")).toBe(2); + expect(coerceCliValue("-5", "number")).toBe(-5); + expect(coerceCliValue("1234.56", "number")).toBe(1234.56); + expect(coerceCliValue(" 7 ", "number")).toBe(7); + }); + + test("should turn text that is not a number into NaN, a blank text included", () => { + expect(Number.isNaN(coerceCliValue("abc", "number"))).toBe(true); + expect(Number.isNaN(coerceCliValue("", "number"))).toBe(true); + expect(Number.isNaN(coerceCliValue(" ", "number"))).toBe(true); + }); + + test("should read a YYYY-MM-DD date as that local calendar day", () => { + const date = coerceCliValue("2024-12-25", "date") as Date; + + expect(date instanceof Date).toBe(true); + expect(date.getFullYear()).toBe(2024); + expect(date.getMonth()).toBe(11); + expect(date.getDate()).toBe(25); + expect(date.getHours()).toBe(0); + }); + + test("should read a year below 100 as written, not as 19xx", () => { + const date = coerceCliValue("0050-03-04", "date") as Date; + + expect(date.getFullYear()).toBe(50); + expect(date.getMonth()).toBe(2); + expect(date.getDate()).toBe(4); + expect(date.getHours()).toBe(0); + expect(date.getMinutes()).toBe(0); + expect(date.getSeconds()).toBe(0); + expect(date.getMilliseconds()).toBe(0); + }); + + test("should read the last day of a month and a leap day", () => { + const leap = coerceCliValue("2024-02-29", "date") as Date; + const last = coerceCliValue("2024-12-31", "date") as Date; + + expect([leap.getFullYear(), leap.getMonth(), leap.getDate()]).toEqual([2024, 1, 29]); + expect([last.getFullYear(), last.getMonth(), last.getDate()]).toEqual([2024, 11, 31]); + }); + + test("should turn anything that is not a real YYYY-MM-DD day into an invalid date", () => { + for (const text of [ + "2024-02-30", + "2024-13-01", + "2024-00-10", + "2024-01-00", + "2024-12-32", + "2023-02-29", + "25/12/2024", + "2024-12-25T10:00:00", + " 2024-12-25", + "2024-12-25 ", + "today", + "", + ]) { + const date = coerceCliValue(text, "date") as Date; + + expect(date instanceof Date).toBe(true); + expect(Number.isNaN(date.getTime())).toBe(true); + } + }); + + test("should split a list on commas and trim every item", () => { + expect(coerceCliValue("cpf", "list")).toEqual(["cpf"]); + expect(coerceCliValue("cpf,cnpj", "list")).toEqual(["cpf", "cnpj"]); + expect(coerceCliValue(" de , da ,do", "list")).toEqual(["de", "da", "do"]); + }); + + test("should read true and false as booleans and keep any other text", () => { + expect(coerceCliValue("true", "boolean")).toBe(true); + expect(coerceCliValue("false", "boolean")).toBe(false); + expect(coerceCliValue("yes", "boolean")).toBe("yes"); + expect(coerceCliValue("", "boolean")).toBe(""); + }); + + test("should hand back a value that is not text untouched, whatever the kind", () => { + const list = ["cpf"]; + const date = new Date(2024, 0, 1); + + expect(coerceCliValue(true, "boolean")).toBe(true); + expect(coerceCliValue(false, "number")).toBe(false); + expect(coerceCliValue(3, "number")).toBe(3); + expect(coerceCliValue(3, "date")).toBe(3); + expect(coerceCliValue(list, "list")).toBe(list); + expect(coerceCliValue(date, "date")).toBe(date); + expect(coerceCliValue(null, "list")).toBeNull(); + expect((coerceCliValue as unknown as () => unknown)()).toBeUndefined(); + }); + + describe("properties", () => { + test("should never throw, whatever the value and the kind", () => { + for (const kind of KINDS) { + expectNeverThrows((value: unknown) => coerceCliValue(value, kind), anyValue); + } + }); + + test("should keep any text with no kind unchanged", () => { + fc.assert( + fc.property(anyText, (text) => { + expect(coerceCliValue(text)).toBe(text); + }), + ); + }); + + test("should read back any integer written as text", () => { + fc.assert( + fc.property(fc.integer(), (value) => { + expect(coerceCliValue(String(value), "number")).toBe(value); + }), + ); + }); + }); +}); + +describe("coerceCliValue types", () => { + test("should take any value and a kind, and return an unknown value", () => { + expectTypeOf(coerceCliValue).parameter(0).toEqualTypeOf(); + expectTypeOf(coerceCliValue).parameter(1).toEqualTypeOf(); + expectTypeOf(coerceCliValue).returns.toEqualTypeOf(); + }); +}); diff --git a/src/_cli/coerce-cli-value/coerce-cli-value.ts b/src/_cli/coerce-cli-value/coerce-cli-value.ts new file mode 100644 index 000000000..d043d5778 --- /dev/null +++ b/src/_cli/coerce-cli-value/coerce-cli-value.ts @@ -0,0 +1,61 @@ +import { type CliValueKind } from "../constants"; + +const ISO_DATE_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/; + +const toDate = (value: string): Date => { + const match = ISO_DATE_REGEX.exec(value); + if (match === null) return new Date(Number.NaN); + + const month = Number(match[2]) - 1; + const date = new Date(0); + + date.setFullYear(Number(match[1]), month, Number(match[3])); + date.setHours(0, 0, 0, 0); + + return date.getMonth() === month ? date : new Date(Number.NaN); +}; + +const toBoolean = (value: string): boolean | string => { + if (value === "true") return true; + if (value === "false") return false; + return value; +}; + +/** + * Turns the text of a command line argument into the value a utility expects. + * + * Only text is converted: a value that already has a type (a bare `--flag`, anything that came + * from `--json`) is handed back untouched, and so is text with no kind, which stays a string so + * that codes such as `"001"` keep their leading zeros. Text that does not spell its kind becomes + * the invalid value of that kind (`NaN`, an invalid `Date`), which every utility already answers + * with `false`, `null` or `""` instead of throwing. + * + * A date is written `YYYY-MM-DD` and means that local calendar day, the way the date utilities of + * this library read a `Date`. A day that does not exist (`2024-02-30`, month `13`) rolls over into + * another month when it is set, which is how it is told apart and made an invalid `Date`. + * + * @param {unknown} value - The argument, as written on the command line or read from `--json`. + * @param {CliValueKind | "text"} [kind] - The kind the utility expects. Text stays text without one. + * @returns {unknown} The converted value. + * + * @example + * ```typescript + * coerceCliValue("001"); // "001" + * coerceCliValue("2", "number"); // 2 + * coerceCliValue("abc", "number"); // NaN + * coerceCliValue("2024-12-25", "date"); // new Date(2024, 11, 25) + * coerceCliValue("cpf, cnpj", "list"); // ["cpf", "cnpj"] + * coerceCliValue("false", "boolean"); // false + * coerceCliValue(3, "number"); // 3 + * ``` + */ +export const coerceCliValue = (value: unknown, kind?: CliValueKind | "text"): unknown => { + if (typeof value !== "string") return value; + + if (kind === "boolean") return toBoolean(value); + if (kind === "date") return toDate(value); + if (kind === "list") return value.split(",").map((item) => item.trim()); + if (kind === "number") return value.trim() === "" ? Number.NaN : Number(value); + + return value; +}; diff --git a/src/_cli/constants.ts b/src/_cli/constants.ts new file mode 100644 index 000000000..6f9f83c72 --- /dev/null +++ b/src/_cli/constants.ts @@ -0,0 +1,163 @@ +import type * as api from "../index"; + +/** How the command line turns the text of an argument into the value a utility expects. */ +export type CliValueKind = "boolean" | "date" | "list" | "number"; + +/** The kind of a positional argument: `"text"` holds the place of one that stays a string. */ +export type CliPositionalKind = CliValueKind | "text"; + +type Api = typeof api; + +type Callable = (...args: never[]) => unknown; + +type ObjectOf = Value extends Date | readonly unknown[] + ? never + : Value extends object + ? Value + : never; + +type KindOf = [Value] extends [never] + ? never + : [Value] extends [boolean] + ? "boolean" + : [Value] extends [number] + ? "number" + : [Value] extends [Date] + ? "date" + : [Value] extends [readonly unknown[]] + ? "list" + : never; + +type ArgumentsOf = Name extends unknown + ? Api[Name] extends Callable + ? Required> + : never + : never; + +type ObjectArgument = ObjectOf[number]>; + +type KeysOf = Value extends unknown ? keyof Value : never; + +type ValueOf = Value extends unknown + ? Key extends keyof Value + ? NonNullable + : never + : never; + +type PositionalKindsOf = Arguments extends [infer Head, ...infer Rest] + ? [Exclude>] extends [never] + ? [] + : [ + [KindOf>>] extends [never] + ? "text" + : KindOf>>, + ...PositionalKindsOf, + ] + : []; + +type HasKind = [Exclude] extends [never] + ? false + : true; + +type CliOptionKinds = { + [ + Key in KeysOf as [KindOf>] extends [never] + ? never + : Key + ]: KindOf>; +}; + +type CliPositionalKinds = { + [ + Name in keyof Api as HasKind>> extends true ? Name : never + ]: PositionalKindsOf>; +}; + +/** + * Exit codes of the command line: a negative answer (`false`, `null` or a utility that throws) is + * told apart from a call that could not be made at all. + */ +export const CLI_EXIT_CODES = { + success: 0, + failure: 1, + usage: 2, +} as const; + +/** + * Every option key of the public API whose value is not a string. The type is derived from the + * signatures exported by `src/index.ts`, so an option added to a utility without its entry here + * fails `npm run check`. + */ +export const CLI_OPTION_KINDS: Readonly> = { + accept: "list", + amount: "number", + branch: "number", + court: "number", + includeLegacy: "boolean", + includeOptional: "boolean", + lowerCaseWords: "list", + obfuscate: "boolean", + pad: "boolean", + precision: "number", + providers: "list", + referenceDate: "date", + symbol: "boolean", + targetDate: "date", + upperCaseWords: "list", + version: "number", + weekday: "boolean", + year: "number", +} satisfies CliOptionKinds; + +/** + * Every utility with a positional argument that is not a string, checked against the signatures + * the same way. `getHolidays` is listed by hand: its `year` overload is hidden from the derived + * type by the params overload declared after it. + */ +export const CLI_POSITIONAL_KINDS: Readonly> = { + addBusinessDays: ["date", "number"], + convertCurrencyToWords: ["number"], + convertNumberToWords: ["number"], + differenceInBusinessDays: ["date", "date"], + generateCNPJ: ["number"], + generateCnpj: ["number"], + getHolidays: ["number"], + isBusinessDay: ["date"], + subBusinessDays: ["date", "number"], +} satisfies CliPositionalKinds & Record; + +/** The text printed by `--help`. */ +export const CLI_USAGE = `Usage: brazilian-utils [value...] [--option value] [--flag] [--json ''] + +Runs any utility exported by @brazilian-utils/brazilian-utils. + + brazilian-utils isValidCpf 12345678909 + brazilian-utils formatCnpj 12345678000195 --obfuscate + brazilian-utils getBankByCode 001 + brazilian-utils isValidIe SP 110042490114 + brazilian-utils getHolidays --year 2026 --state-code SP + brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","account":"12345678","digit":"9"}' + echo 01001000 | brazilian-utils getAddressInfoByCep + +Arguments: + value Positional arguments of the utility, in order. "-" reads the value from + stdin, and so does leaving the value out while stdin is a pipe or a file. + --key value An entry of the options (or params) object. --key=value and --kebab-case + are accepted too. + --flag, --no-flag A boolean option set to true or to false. + --json '' The options (or params) object as JSON. Options given next to it win. + -- Ends the options: everything after it is a positional value. + +Dates are written YYYY-MM-DD and mean that local calendar day. Lists are comma separated. + +Commands: + list Prints the name of every utility. + --help, -h Prints this help. + --version, -v Prints the version of the package. + +Output: strings and numbers as they are, anything else as JSON. +Exit codes: 0 on success, 1 when the answer is false or null or the utility throws, 2 when the +command line itself is wrong. + +Documentation: https://brazilian-utils.com.br +`; diff --git a/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts b/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts new file mode 100644 index 000000000..1d236138b --- /dev/null +++ b/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts @@ -0,0 +1,180 @@ +import * as fc from "fast-check"; + +import { PROTOTYPE_KEYS, anyText } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type ParsedCliArguments, parseCliArguments } from "./parse-cli-arguments"; + +const plainText = anyText.filter((text) => !text.startsWith("--")); + +describe("parseCliArguments", () => { + test("should return nothing for an empty command line", () => { + expect(parseCliArguments([])).toEqual({ positionals: [], options: {}, error: null }); + }); + + test("should keep the positional values in order", () => { + expect(parseCliArguments(["SP", "110042490114"])).toEqual({ + positionals: ["SP", "110042490114"], + options: {}, + error: null, + }); + }); + + test("should read a negative number and the stdin marker as positional values", () => { + expect(parseCliArguments(["-5", "-"])).toEqual({ + positionals: ["-5", "-"], + options: {}, + error: null, + }); + }); + + test("should read --key value as a text option", () => { + expect(parseCliArguments(["--year", "2026", "--mask", "international"])).toEqual({ + positionals: [], + options: { year: "2026", mask: "international" }, + error: null, + }); + }); + + test("should read --key=value as a text option, keeping every later equals sign", () => { + expect(parseCliArguments(["--year=2026", "--description=a=b", "--txid="])).toEqual({ + positionals: [], + options: { year: "2026", description: "a=b", txid: "" }, + error: null, + }); + }); + + test("should take a value that starts with a single dash", () => { + expect(parseCliArguments(["--amount", "-5"])).toEqual({ + positionals: [], + options: { amount: "-5" }, + error: null, + }); + }); + + test("should read --kebab-case names as camelCase", () => { + expect(parseCliArguments(["--state-code", "SP", "--merchant-name=Loja"])).toEqual({ + positionals: [], + options: { stateCode: "SP", merchantName: "Loja" }, + error: null, + }); + expect(parseCliArguments(["--stateCode", "SP"]).options).toEqual({ stateCode: "SP" }); + }); + + test("should set a boolean option with a bare flag, without taking the next value", () => { + expect(parseCliArguments(["--obfuscate", "12345678000195", "--pad"])).toEqual({ + positionals: ["12345678000195"], + options: { obfuscate: true, pad: true }, + error: null, + }); + expect(parseCliArguments(["--include-optional"]).options).toEqual({ includeOptional: true }); + }); + + test("should unset a boolean option with --no-flag", () => { + expect(parseCliArguments(["--no-symbol", "10", "--no-include-optional"])).toEqual({ + positionals: ["10"], + options: { symbol: false, includeOptional: false }, + error: null, + }); + }); + + test("should keep the text given to a boolean option with an equals sign", () => { + expect(parseCliArguments(["--pad=false", "--no-pad=x"]).options).toEqual({ + pad: "false", + noPad: "x", + }); + }); + + test("should read --no-key as an ordinary text option when key is not a boolean option", () => { + expect(parseCliArguments(["--no-year", "5", "--no-", "x", "--no", "y"])).toEqual({ + positionals: [], + options: { noYear: "5", "no-": "x", no: "y" }, + error: null, + }); + }); + + test("should treat --help as a flag", () => { + expect(parseCliArguments(["--help", "123"])).toEqual({ + positionals: ["123"], + options: { help: true }, + error: null, + }); + }); + + test("should let the last occurrence of an option win", () => { + expect(parseCliArguments(["--year", "2025", "--year", "2026"]).options).toEqual({ + year: "2026", + }); + expect(parseCliArguments(["--pad", "--no-pad"]).options).toEqual({ pad: false }); + }); + + test("should read everything after a bare -- as positional values", () => { + expect(parseCliArguments(["--pad", "--", "--obfuscate", "--", "-x", "--year=1"])).toEqual({ + positionals: ["--obfuscate", "--", "-x", "--year=1"], + options: { pad: true }, + error: null, + }); + }); + + test("should report a text option left without a value", () => { + expect(parseCliArguments(["123", "--year"])).toEqual({ + positionals: ["123"], + options: {}, + error: "Option --year needs a value.", + }); + expect(parseCliArguments(["--state-code", "--pad", "1"])).toEqual({ + positionals: [], + options: {}, + error: "Option --stateCode needs a value.", + }); + expect(parseCliArguments(["--year", "--"]).error).toBe("Option --year needs a value."); + }); + + test("should store prototype keys as plain own options", () => { + for (const key of PROTOTYPE_KEYS) { + const { options, error } = parseCliArguments([`--${key}`, "x"]); + + expect(error).toBeNull(); + expect(Object.hasOwn(options, key)).toBe(true); + expect(Object.getPrototypeOf(options)).toBe(Object.prototype); + } + expect(parseCliArguments(["--__proto__=x"]).options["__proto__"]).toBe("x"); + expect(parseCliArguments(["--toString"]).error).toBe("Option --toString needs a value."); + }); + + describe("properties", () => { + test("should never throw and always return the three keys", () => { + fc.assert( + fc.property(fc.array(anyText), (argv) => { + const parsed = parseCliArguments(argv); + + expect(Object.keys(parsed)).toEqual(["positionals", "options", "error"]); + }), + ); + }); + + test("should hand back every value that does not start with two dashes as positional", () => { + fc.assert( + fc.property(fc.array(plainText), (argv) => { + expect(parseCliArguments(argv)).toEqual({ positionals: argv, options: {}, error: null }); + }), + ); + }); + + test("should hand back anything after a bare -- as positional", () => { + fc.assert( + fc.property(fc.array(anyText), (argv) => { + expect(parseCliArguments(["--", ...argv]).positionals).toEqual(argv); + }), + ); + }); + }); +}); + +describe("parseCliArguments types", () => { + test("should take the arguments and return the parsed command line", () => { + expectTypeOf(parseCliArguments).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCliArguments).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/src/_cli/parse-cli-arguments/parse-cli-arguments.ts b/src/_cli/parse-cli-arguments/parse-cli-arguments.ts new file mode 100644 index 000000000..3fa58194f --- /dev/null +++ b/src/_cli/parse-cli-arguments/parse-cli-arguments.ts @@ -0,0 +1,87 @@ +import { CLI_OPTION_KINDS } from "../constants"; + +/** The command line of a utility, split into its positional values and its options. */ +export type ParsedCliArguments = { + /** The positional values, in the order they were written. */ + positionals: string[]; + /** The options, keyed in camelCase: text for `--key value`, a boolean for a flag. */ + options: Record; + /** Why the command line cannot be read, or `null` when it can. */ + error: string | null; +}; + +const OPTION_PREFIX = "--"; +const NEGATION_PREFIX = "no-"; +const KEBAB_REGEX = /-([a-z])/g; + +const toCamelCase = (name: string): string => + name.replaceAll(KEBAB_REGEX, (_match, letter: string) => letter.toUpperCase()); + +const isFlag = (name: string): boolean => + name === "help" || + (Object.hasOwn(CLI_OPTION_KINDS, name) && CLI_OPTION_KINDS[name] === "boolean"); + +const toEntry = (body: string): [string, string | boolean] | string => { + const separator = body.indexOf("="); + const name = toCamelCase(separator === -1 ? body : body.slice(0, separator)); + const negated = toCamelCase(body.slice(NEGATION_PREFIX.length)); + + if (separator !== -1) return [name, body.slice(separator + 1)]; + if (isFlag(name)) return [name, true]; + if (body.startsWith(NEGATION_PREFIX) && isFlag(negated)) return [negated, false]; + return name; +}; + +/** + * Splits the arguments that follow the utility name into positional values and options. + * + * `--key value` and `--key=value` are text options, `--flag` and `--no-flag` set a boolean option + * (the keys listed as `"boolean"` in `CLI_OPTION_KINDS`, plus `help`) to `true` or `false`, and + * `--kebab-case` names are read as `camelCase`. Everything after a bare `--` is positional, and + * so is anything that does not start with `--`, which covers negative numbers and the `-` that + * stands for stdin. Nothing is converted here: values stay text. + * + * @param {readonly string[]} argv - The arguments after the utility name. + * @returns {ParsedCliArguments} The positional values and the options, or the reason the command line cannot be read. + * + * @example + * ```typescript + * parseCliArguments(["12345678000195", "--obfuscate"]); + * // { positionals: ["12345678000195"], options: { obfuscate: true }, error: null } + * parseCliArguments(["--year", "2026", "--state-code=SP", "--no-pad"]); + * // { positionals: [], options: { year: "2026", stateCode: "SP", pad: false }, error: null } + * parseCliArguments(["--year"]); + * // { positionals: [], options: {}, error: "Option --year needs a value." } + * ``` + */ +export const parseCliArguments = (argv: readonly string[]): ParsedCliArguments => { + const positionals: string[] = []; + const entries: [string, string | boolean][] = []; + let pending: string | null = null; + let isLiteral = false; + + for (const token of argv) { + const isOption = !isLiteral && token.startsWith(OPTION_PREFIX); + + if (pending !== null) { + if (isOption) break; + entries.push([pending, token]); + pending = null; + } else if (isOption && token === OPTION_PREFIX) { + isLiteral = true; + } else if (isOption) { + const entry = toEntry(token.slice(OPTION_PREFIX.length)); + + if (typeof entry === "string") pending = entry; + else entries.push(entry); + } else { + positionals.push(token); + } + } + + return { + positionals, + options: Object.fromEntries(entries), + error: pending === null ? null : `Option --${pending} needs a value.`, + }; +}; diff --git a/src/_cli/run-bin/run-bin.test.ts b/src/_cli/run-bin/run-bin.test.ts new file mode 100644 index 000000000..4a5fd3aa2 --- /dev/null +++ b/src/_cli/run-bin/run-bin.test.ts @@ -0,0 +1,153 @@ +import * as fc from "fast-check"; + +import { anyText } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type BinProcess, type RunBinParams, runBin } from "./run-bin"; + +const createProcess = (args: string[], chunks: unknown[] = []) => { + const written = { stdout: [] as string[], stderr: [] as string[] }; + const stdin = { encodings: [] as string[], reads: 0 }; + const process: BinProcess = { + argv: ["/usr/bin/node", "/usr/bin/brazilian-utils", ...args], + stdin: { + setEncoding: (encoding) => stdin.encodings.push(encoding), + [Symbol.asyncIterator]() { + const iterator = chunks[Symbol.iterator](); + + stdin.reads += 1; + return { next: () => Promise.resolve(iterator.next()) }; + }, + }, + stdout: { write: (text) => written.stdout.push(text) }, + stderr: { write: (text) => written.stderr.push(text) }, + }; + + return { process, written, stdin }; +}; + +const api = { + echo: (value: unknown) => value, + isValidThing: (value: unknown) => value === "yes", + generateThing: () => "generated", +}; + +const runWith = async ( + args: string[], + { chunks = [] as unknown[], isStdinPiped = (): boolean => false } = {}, +) => { + const context = createProcess(args, chunks); + + await runBin({ api, version: "9.8.7", process: context.process, isStdinPiped }); + + return context; +}; + +describe("runBin", () => { + test("should skip the runtime and the script, write the result to stdout and exit with 0", async () => { + const { process, written, stdin } = await runWith(["echo", "12345678909"]); + + expect(written).toEqual({ stdout: ["12345678909\n"], stderr: [] }); + expect(process.exitCode).toBe(0); + expect(stdin).toEqual({ encodings: [], reads: 0 }); + }); + + test("should print the version it was given", async () => { + const { process, written } = await runWith(["--version"]); + + expect(written).toEqual({ stdout: ["9.8.7\n"], stderr: [] }); + expect(process.exitCode).toBe(0); + }); + + test("should exit with 1 for a negative answer", async () => { + const { process, written } = await runWith(["isValidThing", "no"]); + + expect(written).toEqual({ stdout: ["false\n"], stderr: [] }); + expect(process.exitCode).toBe(1); + }); + + test("should write a usage error to stderr only and exit with 2", async () => { + const { process, written } = await runWith(["nope"]); + + expect(written).toEqual({ + stdout: [], + stderr: [ + 'Unknown utility "nope". Run "brazilian-utils list" to see every utility.\nRun "brazilian-utils --help" for usage.\n', + ], + }); + expect(process.exitCode).toBe(2); + }); + + test("should read a piped stdin as UTF-8 text, joining its chunks", async () => { + const { process, written, stdin } = await runWith(["echo"], { + chunks: ["São ", "Paulo\n"], + isStdinPiped: () => true, + }); + + expect(written).toEqual({ stdout: ["São Paulo\n"], stderr: [] }); + expect(process.exitCode).toBe(0); + expect(stdin).toEqual({ encodings: ["utf8"], reads: 1 }); + }); + + test("should turn a chunk that is not text into text", async () => { + const { written } = await runWith(["echo", "-"], { chunks: [12, 34] }); + + expect(written.stdout).toEqual(["1234\n"]); + }); + + test("should read stdin for - even when it is not piped", async () => { + const { written, stdin } = await runWith(["echo", "-"], { chunks: ["typed\n"] }); + + expect(written.stdout).toEqual(["typed\n"]); + expect(stdin).toEqual({ encodings: ["utf8"], reads: 1 }); + }); + + test("should not read a stdin that is not piped", async () => { + const { written, stdin } = await runWith(["echo"], { chunks: ["ignored"] }); + + expect(written.stdout).toEqual(["null\n"]); + expect(stdin.reads).toBe(0); + }); + + test("should count a stdin probe that throws as not piped", async () => { + const { process, written, stdin } = await runWith(["echo"], { + chunks: ["ignored"], + isStdinPiped: () => { + throw new Error("EBADF: bad file descriptor, fstat"); + }, + }); + + expect(written).toEqual({ stdout: ["null\n"], stderr: [] }); + expect(process.exitCode).toBe(1); + expect(stdin.reads).toBe(0); + }); + + test("should not touch a piped stdin for a utility that takes no argument", async () => { + const { written, stdin } = await runWith(["generateThing"], { + chunks: ["ignored"], + isStdinPiped: () => true, + }); + + expect(written.stdout).toEqual(["generated\n"]); + expect(stdin.reads).toBe(0); + }); + + describe("properties", () => { + test("should never reject and always set a known exit code", async () => { + await fc.assert( + fc.asyncProperty(fc.array(anyText), fc.array(anyText), async (args, chunks) => { + const { process } = await runWith(args, { chunks, isStdinPiped: () => true }); + + expect([0, 1, 2].includes(process.exitCode as number)).toBe(true); + }), + ); + }); + }); +}); + +describe("runBin types", () => { + test("should take the params and resolve to nothing", () => { + expectTypeOf(runBin).parameter(0).toEqualTypeOf(); + expectTypeOf(runBin).returns.toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf<() => boolean>(); + }); +}); diff --git a/src/_cli/run-bin/run-bin.ts b/src/_cli/run-bin/run-bin.ts new file mode 100644 index 000000000..65e5b460d --- /dev/null +++ b/src/_cli/run-bin/run-bin.ts @@ -0,0 +1,96 @@ +import { runCli } from "../run-cli/run-cli"; + +/** A stream the command line writes text to. */ +type BinOutput = { + /** Writes the text. */ + write: (text: string) => unknown; +}; + +/** The standard input stream, read as UTF-8 text. */ +type BinInput = AsyncIterable & { + /** Makes the stream yield decoded text instead of bytes. */ + setEncoding: (encoding: "utf8") => unknown; +}; + +/** The part of the Node.js `process` the command line uses. */ +export type BinProcess = { + /** The runtime, the script and then the command line arguments. */ + argv: readonly string[]; + /** The standard input. */ + stdin: BinInput; + /** The standard output. */ + stdout: BinOutput; + /** The standard error. */ + stderr: BinOutput; + /** The exit code of the process, set instead of exiting so that pending output is flushed. */ + exitCode?: number | string | null; +}; + +/** Params of `runBin`. */ +export type RunBinParams = { + /** The exports the command line dispatches to: the public API of the package. */ + api: Readonly>; + /** The version printed by `--version`. */ + version: string; + /** The process to read the arguments and stdin from and to write the result to. */ + process: BinProcess; + /** Tells whether stdin is a pipe or a file. May throw (a closed stdin): that counts as no. */ + isStdinPiped: () => boolean; +}; + +// Stryker disable BlockStatement: with an empty catch block a probe that throws answers `undefined`, which `runCli` reads as not piped exactly like `false`. +const isPiped = (isStdinPiped: () => boolean): boolean => { + try { + return isStdinPiped(); + } catch { + return false; + } +}; +// Stryker restore BlockStatement + +const readAll = async (stdin: BinInput): Promise => { + let text = ""; + + stdin.setEncoding("utf8"); + for await (const chunk of stdin) text += String(chunk); + + return text; +}; + +/** + * Connects `runCli` to a process: reads its arguments and stdin, writes the result to its stdout + * and stderr and sets its exit code. + * + * The exit code is set on the process instead of calling `process.exit()`, so that a long result + * (`getMunicipalities`) is flushed before the process ends. Stdin is only touched when `runCli` + * asks for it. + * + * @param {RunBinParams} params - The API, the version, the process and the stdin probe. + * @returns {Promise} Settles once the result has been written. + * + * @example + * ```typescript + * import process from "node:process"; + * + * import * as api from "@brazilian-utils/brazilian-utils"; + * + * await runBin({ api, version: "2.4.0", process, isStdinPiped: () => false }); + * ``` + */ +export const runBin = async ({ + api, + version, + process, + isStdinPiped, +}: RunBinParams): Promise => { + const result = await runCli({ + argv: process.argv.slice(2), + api, + version, + stdin: { isPiped: isPiped(isStdinPiped), read: () => readAll(process.stdin) }, + }); + + if (result.stdout !== "") process.stdout.write(result.stdout); + if (result.stderr !== "") process.stderr.write(result.stderr); + process.exitCode = result.exitCode; +}; diff --git a/src/_cli/run-cli/run-cli.test.ts b/src/_cli/run-cli/run-cli.test.ts new file mode 100644 index 000000000..b687c11ea --- /dev/null +++ b/src/_cli/run-cli/run-cli.test.ts @@ -0,0 +1,615 @@ +import * as fc from "fast-check"; + +import { PROTOTYPE_KEYS, anyText } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import * as brazilianUtils from "../../index"; +import { CLI_USAGE } from "../constants"; +import { type CliResult, type CliStdin, type RunCliParams, runCli } from "./run-cli"; + +const USAGE_HINT = 'Run "brazilian-utils --help" for usage.\n'; + +const terminal: CliStdin = { + isPiped: false, + read: () => Promise.reject(new Error("stdin must not be read")), +}; + +const pipe = (text: string): CliStdin => ({ isPiped: true, read: () => Promise.resolve(text) }); + +const typed = (text: string): CliStdin => ({ isPiped: false, read: () => Promise.resolve(text) }); + +const createApi = () => { + const calls: unknown[][] = []; + const record = (produce: () => unknown, length = 1) => + Object.defineProperty( + (...args: unknown[]) => { + calls.push(args); + return produce(); + }, + "length", + { value: length }, + ); + + return { + calls, + api: { + formatThing: record(() => "formatted", 2), + isValidThing: record(() => false), + getThing: record(() => null), + getThings: record(() => [{ code: "001" }], 0), + generateThing: record(() => "generated", 0), + addBusinessDays: record(() => new Date(2026, 8, 8), 3), + fetchThing: record(() => Promise.resolve({ cep: "01001000" })), + rejectThing: record(() => Promise.reject(new RangeError("service down"))), + throwThing: record(() => { + throw new TypeError("boom"); + }), + throwText: record(() => { + // eslint-disable-next-line typescript/only-throw-error, sonarjs/no-throw-literal -- a hostile export + throw "plain text"; + }), + ThingError: class ThingError extends Error {}, + VERSION: "1.0.0", + settings: { pad: true }, + } as Record, + }; +}; + +const stdoutOf = async (result: Promise) => { + const { stdout } = await result; + + return stdout; +}; + +const exitCodeOf = async (result: Promise) => { + const { exitCode } = await result; + + return exitCode; +}; + +const runApi = (argv: string[], stdin: CliStdin = terminal) => + runCli({ argv, api: brazilianUtils, version: "9.8.7", stdin }); + +const apiStdout = (argv: string[], stdin: CliStdin = terminal) => stdoutOf(runApi(argv, stdin)); + +const run = (argv: string[], stdin: CliStdin = terminal, api = createApi().api) => + runCli({ argv, api, version: "9.8.7", stdin }); + +describe("runCli", () => { + describe("built-in commands", () => { + test("should print the usage on stderr and exit with 2 when no utility is given", async () => { + expect(await run([])).toEqual({ stdout: "", stderr: CLI_USAGE, exitCode: 2 }); + expect(await run([""])).toEqual({ stdout: "", stderr: CLI_USAGE, exitCode: 2 }); + }); + + test("should print the usage for --help and -h", async () => { + expect(await run(["--help"])).toEqual({ stdout: CLI_USAGE, stderr: "", exitCode: 0 }); + expect(await run(["-h"])).toEqual({ stdout: CLI_USAGE, stderr: "", exitCode: 0 }); + }); + + test("should print the usage for --help after a utility, without calling it", async () => { + const { api, calls } = createApi(); + + expect(await run(["formatThing", "1", "--help"], terminal, api)).toEqual({ + stdout: CLI_USAGE, + stderr: "", + exitCode: 0, + }); + expect(calls).toEqual([]); + }); + + test("should describe the command line in the usage", () => { + expect(CLI_USAGE.startsWith("Usage: brazilian-utils [value...]")).toBe(true); + expect(CLI_USAGE.endsWith("Documentation: https://brazilian-utils.com.br\n")).toBe(true); + for (const term of ["--json", "--no-flag", "--version", "list", "stdin", "YYYY-MM-DD"]) { + expect(CLI_USAGE.includes(term)).toBe(true); + } + }); + + test("should print the version for --version and -v", async () => { + expect(await run(["--version"])).toEqual({ stdout: "9.8.7\n", stderr: "", exitCode: 0 }); + expect(await run(["-v"])).toEqual({ stdout: "9.8.7\n", stderr: "", exitCode: 0 }); + }); + + test("should keep --version after a utility as the version option of that utility", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "1", "--version", "2"], terminal, api); + + expect(calls).toEqual([["1", { version: 2 }]]); + }); + + test("should list every callable lower-camel-case export, sorted, one per line", async () => { + expect(await run(["list"])).toEqual({ + stdout: [ + "addBusinessDays", + "fetchThing", + "formatThing", + "generateThing", + "getThing", + "getThings", + "isValidThing", + "rejectThing", + "throwText", + "throwThing", + "", + ].join("\n"), + stderr: "", + exitCode: 0, + }); + }); + }); + + describe("unknown utilities", () => { + test("should reject a name that is not exported", async () => { + expect(await run(["nope"])).toEqual({ + stdout: "", + stderr: `Unknown utility "nope". Run "brazilian-utils list" to see every utility.\n${USAGE_HINT}`, + exitCode: 2, + }); + }); + + test("should reject an export that is not a utility", async () => { + const names = ["ThingError", "VERSION", "settings", "help", "--nope", "-x"]; + const results = await Promise.all(names.map((name) => run([name]))); + + expect(results.map(({ exitCode }) => exitCode)).toEqual([2, 2, 2, 2, 2, 2]); + expect(results.map(({ stdout }) => stdout)).toEqual(["", "", "", "", "", ""]); + expect(results.map(({ stderr }) => stderr.slice(0, 27))).toEqual([ + 'Unknown utility "ThingError', + 'Unknown utility "VERSION". ', + 'Unknown utility "settings".', + 'Unknown utility "help". Run', + 'Unknown utility "--nope". R', + 'Unknown utility "-x". Run "', + ]); + }); + + test("should reject every prototype key", async () => { + const results = await Promise.all(PROTOTYPE_KEYS.map((name) => run([name]))); + + expect(PROTOTYPE_KEYS.includes("constructor")).toBe(true); + for (const [index, result] of results.entries()) { + expect(result.exitCode).toBe(2); + expect(result.stderr.startsWith(`Unknown utility "${PROTOTYPE_KEYS[index]}".`)).toBe(true); + } + }); + + test("should reject an inherited function", async () => { + const api = Object.create({ inheritedThing: () => "inherited" }) as Record; + + expect(await exitCodeOf(run(["inheritedThing"], terminal, api))).toBe(2); + expect(await stdoutOf(run(["list"], terminal, api))).toBe("\n"); + }); + }); + + describe("arguments", () => { + test("should call the utility with no argument at all when none is given", async () => { + const { api, calls } = createApi(); + + expect(await run(["generateThing"], terminal, api)).toEqual({ + stdout: "generated\n", + stderr: "", + exitCode: 0, + }); + expect(calls).toEqual([[]]); + }); + + test("should pass the positional values in order, as text", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "001", "0002"], terminal, api); + + expect(calls).toEqual([["001", "0002"]]); + }); + + test("should pass the options as the last argument", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "123", "--obfuscate", "--state-code", "SP"], terminal, api); + + expect(calls).toEqual([["123", { obfuscate: true, stateCode: "SP" }]]); + }); + + test("should pass the options as the only argument of a utility that takes params", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "--bank-code", "001", "--agency=1234"], terminal, api); + + expect(calls).toEqual([[{ bankCode: "001", agency: "1234" }]]); + }); + + test("should convert the options that have a kind", async () => { + const { api, calls } = createApi(); + + await run( + [ + "formatThing", + "--year=2026", + "--precision", + "3", + "--accept", + "cpf, cnpj", + "--pad=false", + "--no-symbol", + "--target-date", + "2026-09-07", + "--code", + "0012", + ], + terminal, + api, + ); + + expect(calls).toEqual([ + [ + { + year: 2026, + precision: 3, + accept: ["cpf", "cnpj"], + pad: false, + symbol: false, + targetDate: new Date(2026, 8, 7), + code: "0012", + }, + ], + ]); + }); + + test("should convert the positional values of the utilities that have kinds", async () => { + const { api, calls } = createApi(); + + expect( + await run( + ["addBusinessDays", "2026-09-04", "1", "SP", "--state-code", "SP"], + terminal, + api, + ), + ).toEqual({ stdout: "2026-09-08\n", stderr: "", exitCode: 0 }); + expect(calls).toEqual([[new Date(2026, 8, 4), 1, "SP", { stateCode: "SP" }]]); + }); + + test("should report a text option left without a value", async () => { + const { api, calls } = createApi(); + + expect(await run(["formatThing", "1", "--mask"], terminal, api)).toEqual({ + stdout: "", + stderr: `Option --mask needs a value.\n${USAGE_HINT}`, + exitCode: 2, + }); + expect(calls).toEqual([]); + }); + }); + + describe("--json", () => { + test("should pass the JSON object as the options", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "--json", '{"bankCode":"001","digit":"9"}'], terminal, api); + + expect(calls).toEqual([[{ bankCode: "001", digit: "9" }]]); + }); + + test("should convert the text of the entries that have a kind and keep typed values", async () => { + const { api, calls } = createApi(); + + await run( + ["formatThing", "1", "--json", '{"targetDate":"2026-09-07","year":2026,"pad":"true"}'], + terminal, + api, + ); + + expect(calls).toEqual([["1", { targetDate: new Date(2026, 8, 7), year: 2026, pad: true }]]); + }); + + test("should let the options given next to it win", async () => { + const { api, calls } = createApi(); + + await run( + ["formatThing", "--pad", "--json", '{"pad":false,"mask":"a"}', "--mask=b"], + terminal, + api, + ); + + expect(calls).toEqual([[{ pad: true, mask: "b" }]]); + }); + + test("should pass no options for an empty JSON object", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "1", "--json", "{}"], terminal, api); + + expect(calls).toEqual([["1"]]); + }); + + test("should keep a __proto__ entry as plain data", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "--json", '{"__proto__":{"pad":true}}'], terminal, api); + + const [[options]] = calls as [[Record]]; + + expect(Object.getPrototypeOf(options)).toBe(Object.prototype); + expect(Object.hasOwn(options, "__proto__")).toBe(true); + expect(options["pad"]).toBeUndefined(); + }); + + test("should reject anything that is not a JSON object", async () => { + const { api, calls } = createApi(); + const values = ["nope", "[1]", "null", "1", '"text"', "true", "", "{"]; + const results = await Promise.all( + values.map((json) => run(["formatThing", `--json=${json}`], terminal, api)), + ); + + expect(results).toHaveLength(8); + for (const result of results) { + expect(result).toEqual({ + stdout: "", + stderr: `Option --json needs a JSON object.\n${USAGE_HINT}`, + exitCode: 2, + }); + } + expect(calls).toEqual([]); + }); + }); + + describe("stdin", () => { + test("should read the value left out from a piped stdin, trimmed", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "--obfuscate"], pipe(" 12345678909\n"), api); + + expect(calls).toEqual([["12345678909", { obfuscate: true }]]); + }); + + test("should convert the value read from stdin like a written one", async () => { + const { api, calls } = createApi(); + + await run(["addBusinessDays"], pipe("2026-09-04\n"), api); + + expect(calls).toEqual([[new Date(2026, 8, 4)]]); + }); + + test("should pass no value when the piped stdin is blank", async () => { + const { api, calls } = createApi(); + + await run(["formatThing"], pipe(" \n"), api); + + expect(calls).toEqual([[]]); + }); + + test("should not read stdin when it is not piped", async () => { + const { api, calls } = createApi(); + + expect(await exitCodeOf(run(["formatThing"], terminal, api))).toBe(0); + expect(calls).toEqual([[]]); + }); + + test("should not read a piped stdin when a value is given", async () => { + const { api, calls } = createApi(); + const stdin = { ...terminal, isPiped: true }; + + expect(await exitCodeOf(run(["formatThing", "1"], stdin, api))).toBe(0); + expect(calls).toEqual([["1"]]); + }); + + test("should not read a piped stdin when --json is given", async () => { + const { api, calls } = createApi(); + const stdin = { ...terminal, isPiped: true }; + + expect(await exitCodeOf(run(["formatThing", "--json", '{"a":"b"}'], stdin, api))).toBe(0); + expect(calls).toEqual([[{ a: "b" }]]); + }); + + test("should not read a piped stdin for a utility that takes no argument", async () => { + const { api, calls } = createApi(); + const stdin = { ...terminal, isPiped: true }; + + expect(await exitCodeOf(run(["generateThing"], stdin, api))).toBe(0); + expect(calls).toEqual([[]]); + }); + + test("should read every - from stdin, piped or not", async () => { + const { api, calls } = createApi(); + + await run(["formatThing", "SP", "-"], typed("110042490114\n"), api); + await run(["formatThing", "-", "--json", "{}"], pipe("1"), api); + await run(["generateThing", "-"], typed(""), api); + + expect(calls).toEqual([["SP", "110042490114"], ["1"], [""]]); + }); + + test("should report a stdin that cannot be read and not call the utility", async () => { + const { api, calls } = createApi(); + + expect(await run(["formatThing", "-"], terminal, api)).toEqual({ + stdout: "", + stderr: "Error: stdin must not be read\n", + exitCode: 1, + }); + expect(calls).toEqual([]); + }); + }); + + describe("results", () => { + test("should exit with 1 when a validator returns false", async () => { + expect(await run(["isValidThing", "1"])).toEqual({ + stdout: "false\n", + stderr: "", + exitCode: 1, + }); + }); + + test("should exit with 1 when a lookup returns null", async () => { + expect(await run(["getThing", "1"])).toEqual({ stdout: "null\n", stderr: "", exitCode: 1 }); + }); + + test("should print an object as JSON", async () => { + expect(await run(["getThings"])).toEqual({ + stdout: '[\n {\n "code": "001"\n }\n]\n', + stderr: "", + exitCode: 0, + }); + }); + + test("should await a utility that returns a promise", async () => { + const { api, calls } = createApi(); + + expect(await run(["fetchThing", "01001000"], terminal, api)).toEqual({ + stdout: '{\n "cep": "01001000"\n}\n', + stderr: "", + exitCode: 0, + }); + expect(calls).toEqual([["01001000"]]); + }); + + test("should report a rejected promise on stderr and exit with 1", async () => { + expect(await run(["rejectThing", "1"])).toEqual({ + stdout: "", + stderr: "RangeError: service down\n", + exitCode: 1, + }); + }); + + test("should report a utility that throws on stderr and exit with 1", async () => { + expect(await run(["throwThing", "1"])).toEqual({ + stdout: "", + stderr: "TypeError: boom\n", + exitCode: 1, + }); + expect(await run(["throwText", "1"])).toEqual({ + stdout: "", + stderr: "plain text\n", + exitCode: 1, + }); + }); + }); + + describe("public API", () => { + test("should validate", async () => { + expect(await runApi(["isValidCpf", "12345678909"])).toEqual({ + stdout: "true\n", + stderr: "", + exitCode: 0, + }); + expect(await exitCodeOf(runApi(["isValidCpf", "11111111111"]))).toBe(1); + expect(await apiStdout(["isValidIe", "SP", "110042490114"])).toBe("true\n"); + expect(await apiStdout(["isValidCnpj", "12ABC34501DE35", "--version", "1"])).toBe("false\n"); + expect(await apiStdout(["isValidCnpj", "12ABC34501DE35", "--version", "2"])).toBe("true\n"); + }); + + test("should format, with options", async () => { + expect(await apiStdout(["formatCnpj", "12345678000195"])).toBe("12.345.678/0001-95\n"); + expect(await apiStdout(["formatCnpj", "12345678000195", "--obfuscate"])).toBe( + "**.345.678/0001-**\n", + ); + expect(await apiStdout(["formatCurrency", "1234.5", "--no-symbol"])).toBe("1.234,50\n"); + expect(await apiStdout(["formatCpf", "--obfuscate"], pipe("12345678909\n"))).toBe( + "***.456.789-**\n", + ); + }); + + test("should look up, printing JSON and exiting with 1 for a miss", async () => { + expect(await runApi(["getBankByCode", "001"])).toEqual({ + stdout: '{\n "code": "001",\n "ispb": "00000000",\n "name": "Banco do Brasil S.A."\n}\n', + stderr: "", + exitCode: 0, + }); + expect(await runApi(["getBankByCode", "99999"])).toEqual({ + stdout: "null\n", + stderr: "", + exitCode: 1, + }); + }); + + test("should generate", async () => { + const { stdout, exitCode } = await runApi(["generateCpf"]); + + expect(/^\d{11}\n$/.test(stdout)).toBe(true); + expect(exitCode).toBe(0); + expect(await apiStdout(["generateCnpj", "2"])).toMatch(/^[\dA-Z]{12}\d{2}\n$/); + expect(await apiStdout(["generateCnpj", "--version=1"])).toMatch(/^\d{14}\n$/); + }); + + test("should take numbers and dates", async () => { + expect(await apiStdout(["convertNumberToWords", "-5"])).toBe("menos cinco\n"); + expect(await apiStdout(["addBusinessDays", "2026-09-04", "1"])).toBe("2026-09-08\n"); + expect(await apiStdout(["isBusinessDay", "2026-09-07"])).toBe("false\n"); + expect(await apiStdout(["isHoliday", "--target-date", "2026-09-07"])).toBe("true\n"); + expect(await apiStdout(["isHoliday", "--json", '{"targetDate":"2026-09-08"}'])).toBe( + "false\n", + ); + const holidays = await apiStdout(["getHolidays", "2026"]); + + expect(holidays.slice(0, 83)).toBe( + '[\n {\n "name": "Ano novo",\n "date": "2026-01-01",\n "type": "national"\n },', + ); + }); + + test("should report the rejection of a network utility without touching the network", async () => { + const result = await runApi(["getAddressInfoByCep", "123"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr.startsWith("GetAddressInfoByCepValidationError: ")).toBe(true); + }); + + test("should list every utility and no error class", async () => { + const stdout = await apiStdout(["list"]); + const names = stdout.split("\n"); + + expect(names.includes("isValidCpf")).toBe(true); + expect(names.includes("getCepInfoByAddress")).toBe(true); + expect(names.includes("GetAddressInfoByCepError")).toBe(false); + expect(names.at(-1)).toBe(""); + }); + }); + + describe("properties", () => { + test("should never reject and always return text and a known exit code", async () => { + await fc.assert( + fc.asyncProperty(fc.array(anyText), async (argv) => { + const result = await run(argv, pipe("")); + + expect(typeof result.stdout).toBe("string"); + expect(typeof result.stderr).toBe("string"); + expect([0, 1, 2].includes(result.exitCode)).toBe(true); + }), + ); + }); + + test("should never reject for any arguments handed to a real utility", async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom( + "isValidCpf", + "formatCnpj", + "getBankByCode", + "isHoliday", + "addBusinessDays", + ), + fc.array(anyText), + async (name, argv) => { + const result = await runCli({ + argv: [name, ...argv], + api: brazilianUtils, + version: "9.8.7", + stdin: pipe(""), + }); + + expect([0, 1, 2].includes(result.exitCode)).toBe(true); + }, + ), + ); + }); + }); +}); + +describe("runCli types", () => { + test("should take the params and resolve to the result", () => { + expectTypeOf(runCli).parameter(0).toEqualTypeOf(); + expectTypeOf(runCli).returns.toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf<0 | 1 | 2>(); + expectTypeOf().toEqualTypeOf>>(); + expectTypeOf(brazilianUtils).toExtend(); + }); +}); diff --git a/src/_cli/run-cli/run-cli.ts b/src/_cli/run-cli/run-cli.ts new file mode 100644 index 000000000..e652e4da1 --- /dev/null +++ b/src/_cli/run-cli/run-cli.ts @@ -0,0 +1,208 @@ +import { coerceCliValue } from "../coerce-cli-value/coerce-cli-value"; +import { CLI_EXIT_CODES, CLI_OPTION_KINDS, CLI_POSITIONAL_KINDS, CLI_USAGE } from "../constants"; +import { parseCliArguments } from "../parse-cli-arguments/parse-cli-arguments"; +import { toCliOutput } from "../to-cli-output/to-cli-output"; + +/** The standard input of the command line, read only when a value has to come from it. */ +export type CliStdin = { + /** Whether stdin is a pipe or a file, so that a value left out may be read from it. */ + isPiped: boolean; + /** Reads stdin to its end. */ + read: () => Promise; +}; + +/** Params of `runCli`. */ +export type RunCliParams = { + /** The command line arguments, without the runtime and the script (`process.argv.slice(2)`). */ + argv: readonly string[]; + /** The exports the command line dispatches to: the public API of the package. */ + api: Readonly>; + /** The version printed by `--version`. */ + version: string; + /** The standard input. */ + stdin: CliStdin; +}; + +/** What a run of the command line writes and how it exits. */ +export type CliResult = { + /** The text for stdout, empty when there is none. */ + stdout: string; + /** The text for stderr, empty when there is none. */ + stderr: string; + /** `0` on success, `1` for `false`, `null` or a utility that throws, `2` for a usage error. */ + exitCode: 0 | 1 | 2; +}; + +type Utility = (...args: unknown[]) => unknown; + +const UTILITY_NAME_REGEX = /^[a-z]/; +const STDIN_MARKER = "-"; + +const isUtility = (value: unknown): value is Utility => typeof value === "function"; + +const isPlainObject = (value: unknown): value is Record => + Object.prototype.toString.call(value) === "[object Object]"; + +const getUtility = (api: Readonly>, name: string): Utility | null => { + const value = Object.hasOwn(api, name) && UTILITY_NAME_REGEX.test(name) ? api[name] : null; + + return isUtility(value) ? value : null; +}; + +const succeed = (stdout: string): CliResult => ({ + stdout, + stderr: "", + exitCode: CLI_EXIT_CODES.success, +}); + +const fail = (message: string): CliResult => ({ + stdout: "", + stderr: `${message}\n`, + exitCode: CLI_EXIT_CODES.failure, +}); + +const rejectUsage = (message: string): CliResult => ({ + stdout: "", + stderr: `${message}\nRun "brazilian-utils --help" for usage.\n`, + exitCode: CLI_EXIT_CODES.usage, +}); + +const parseJsonObject = (text: string | boolean): Record | null => { + try { + const value: unknown = JSON.parse(String(text)); + + return isPlainObject(value) ? value : null; + } catch { + return null; + } +}; + +const describeError = (error: unknown): string => + error instanceof Error ? `${error.name}: ${error.message}` : String(error); + +const call = async (utility: Utility, read: () => Promise): Promise => { + try { + const args = await read(); + const { text, exitCode } = toCliOutput(await utility(...args)); + + return { stdout: `${text}\n`, stderr: "", exitCode }; + } catch (error) { + return fail(describeError(error)); + } +}; + +const readValues = async ( + positionals: readonly string[], + isImplicit: boolean, + stdin: CliStdin, +): Promise => { + if (!positionals.includes(STDIN_MARKER) && !isImplicit) return [...positionals]; + + const text = await stdin.read(); + const input = text.trim(); + + if (positionals.length > 0) { + return positionals.map((value) => (value === STDIN_MARKER ? input : value)); + } + + return input === "" ? [] : [input]; +}; + +const coerceOptions = (options: Record): Record[] => { + const entries = Object.entries(options).map(([key, value]): [string, unknown] => [ + key, + coerceCliValue(value, Object.hasOwn(CLI_OPTION_KINDS, key) ? CLI_OPTION_KINDS[key] : undefined), + ]); + + return entries.length === 0 ? [] : [Object.fromEntries(entries)]; +}; + +const dispatch = ( + command: string, + utility: Utility, + argv: readonly string[], + stdin: CliStdin, +): Promise => { + const { positionals, options, error } = parseCliArguments(argv); + if (error !== null) return Promise.resolve(rejectUsage(error)); + if (options["help"] === true) return Promise.resolve(succeed(CLI_USAGE)); + + const { json, ...flags } = options; + const base = json === undefined ? {} : parseJsonObject(json); + if (base === null) return Promise.resolve(rejectUsage("Option --json needs a JSON object.")); + + const isImplicit = + positionals.length === 0 && json === undefined && stdin.isPiped && utility.length > 0; + const kinds = Object.hasOwn(CLI_POSITIONAL_KINDS, command) + ? CLI_POSITIONAL_KINDS[command] + : undefined; + + return call(utility, async () => { + const values = await readValues(positionals, isImplicit, stdin); + + return [ + ...values.map((value, index) => coerceCliValue(value, kinds?.[index])), + ...coerceOptions({ ...base, ...flags }), + ]; + }); +}; + +const resolveCommand = ( + command: string, + api: Readonly>, + version: string, +): CliResult | Utility => { + if (command === "") return { stdout: "", stderr: CLI_USAGE, exitCode: CLI_EXIT_CODES.usage }; + if (command === "--help" || command === "-h") return succeed(CLI_USAGE); + if (command === "--version" || command === "-v") return succeed(`${version}\n`); + + if (command === "list") { + const names = Object.keys(api).filter((name) => getUtility(api, name) !== null); + + return succeed(`${names.toSorted().join("\n")}\n`); + } + + return ( + getUtility(api, command) ?? + rejectUsage(`Unknown utility "${command}". Run "brazilian-utils list" to see every utility.`) + ); +}; + +/** + * Runs the command line as a pure function: arguments and stdin in, output and exit code out. + * + * The command line is a generic dispatcher over the public API. The first argument names an + * exported utility (an own, callable, lower-camel-case export, so error classes and prototype + * keys such as `constructor` are not commands); the positional values after it become the + * utility's arguments, in order, and the `--key value` options, merged over the `--json` object, + * become its last argument. Values stay text unless `CLI_POSITIONAL_KINDS` or `CLI_OPTION_KINDS` + * give them a kind. A value written as `-` is read from stdin, and so is a value left out when + * stdin is piped, the utility takes arguments and no `--json` was given. A promise returned by + * the utility (`getAddressInfoByCep`, `getCepInfoByAddress`) is awaited. + * + * It never throws: a utility that throws or rejects becomes exit code `1` with the error on + * stderr, and a command line that cannot be read becomes exit code `2`. + * + * @param {RunCliParams} params - The arguments, the API to dispatch to, the version and stdin. + * @returns {Promise} The text for stdout and stderr, and the exit code. + * + * @example + * ```typescript + * import * as api from "@brazilian-utils/brazilian-utils"; + * + * const stdin = { isPiped: false, read: async () => "" }; + * + * await runCli({ argv: ["formatCpf", "12345678909"], api, version: "2.4.0", stdin }); + * // { stdout: "123.456.789-09\n", stderr: "", exitCode: 0 } + * await runCli({ argv: ["isValidCpf", "11111111111"], api, version: "2.4.0", stdin }); + * // { stdout: "false\n", stderr: "", exitCode: 1 } + * await runCli({ argv: ["nope"], api, version: "2.4.0", stdin }); + * // { stdout: "", stderr: 'Unknown utility "nope". ...', exitCode: 2 } + * ``` + */ +export const runCli = ({ argv, api, version, stdin }: RunCliParams): Promise => { + const [command = "", ...rest] = argv; + const resolved = resolveCommand(command, api, version); + + return isUtility(resolved) ? dispatch(command, resolved, rest, stdin) : Promise.resolve(resolved); +}; diff --git a/src/_cli/to-cli-output/to-cli-output.test.ts b/src/_cli/to-cli-output/to-cli-output.test.ts new file mode 100644 index 000000000..4bf22722a --- /dev/null +++ b/src/_cli/to-cli-output/to-cli-output.test.ts @@ -0,0 +1,151 @@ +import * as fc from "fast-check"; + +import { anyText } from "../../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; +import { type CliOutput, toCliOutput } from "./to-cli-output"; + +describe("toCliOutput", () => { + test("should print a string as it is", () => { + expect(toCliOutput("123.456.789-09")).toEqual({ text: "123.456.789-09", exitCode: 0 }); + expect(toCliOutput("")).toEqual({ text: "", exitCode: 0 }); + expect(toCliOutput('say "oi"')).toEqual({ text: 'say "oi"', exitCode: 0 }); + }); + + test("should print a number as it is", () => { + expect(toCliOutput(1234.56)).toEqual({ text: "1234.56", exitCode: 0 }); + expect(toCliOutput(0)).toEqual({ text: "0", exitCode: 0 }); + expect(toCliOutput(-3)).toEqual({ text: "-3", exitCode: 0 }); + }); + + test("should print true and exit with 0", () => { + expect(toCliOutput(true)).toEqual({ text: "true", exitCode: 0 }); + }); + + test("should print false and exit with 1", () => { + expect(toCliOutput(false)).toEqual({ text: "false", exitCode: 1 }); + }); + + test("should print null for null and undefined and exit with 1", () => { + expect(toCliOutput(null)).toEqual({ text: "null", exitCode: 1 }); + expect((toCliOutput as unknown as () => unknown)()).toEqual({ text: "null", exitCode: 1 }); + }); + + test("should print an object as indented JSON", () => { + expect(toCliOutput({ code: "001", ispb: "00000000" })).toEqual({ + text: '{\n "code": "001",\n "ispb": "00000000"\n}', + exitCode: 0, + }); + expect(toCliOutput({})).toEqual({ text: "{}", exitCode: 0 }); + }); + + test("should print an array as indented JSON", () => { + expect(toCliOutput(["Acrelândia", "Assis Brasil"])).toEqual({ + text: '[\n "Acrelândia",\n "Assis Brasil"\n]', + exitCode: 0, + }); + expect(toCliOutput([])).toEqual({ text: "[]", exitCode: 0 }); + expect(toCliOutput([11, 12])).toEqual({ text: "[\n 11,\n 12\n]", exitCode: 0 }); + }); + + test("should print a date as its local calendar day", () => { + expect(toCliOutput(new Date(2024, 11, 25))).toEqual({ text: "2024-12-25", exitCode: 0 }); + expect(toCliOutput(new Date(2024, 0, 2, 23, 59, 59))).toEqual({ + text: "2024-01-02", + exitCode: 0, + }); + expect(toCliOutput(new Date(2024, 0, 2, 0, 0, 0))).toEqual({ + text: "2024-01-02", + exitCode: 0, + }); + }); + + test("should pad the year of a date to four digits", () => { + const date = new Date(2024, 4, 9); + date.setFullYear(987); + + expect(toCliOutput(date).text).toBe("0987-05-09"); + }); + + test("should print the dates inside a result as local calendar days too", () => { + const holidays = [ + { name: "Ano novo", date: new Date(2026, 0, 1), type: "national" }, + { name: "Natal", date: new Date(2026, 11, 25, 12), type: "national" }, + ]; + + expect(toCliOutput(holidays).text).toBe( + [ + "[", + " {", + ' "name": "Ano novo",', + ' "date": "2026-01-01",', + ' "type": "national"', + " },", + " {", + ' "name": "Natal",', + ' "date": "2026-12-25",', + ' "type": "national"', + " }", + "]", + ].join("\n"), + ); + expect(toCliOutput({ range: { from: new Date(2026, 5, 4) } }).text).toBe( + '{\n "range": {\n "from": "2026-06-04"\n }\n}', + ); + }); + + test("should keep null, booleans and numbers inside a result", () => { + expect(toCliOutput({ a: null, b: false, c: 0, d: [null] }).text).toBe( + '{\n "a": null,\n "b": false,\n "c": 0,\n "d": [\n null\n ]\n}', + ); + }); + + test("should print the values JSON cannot hold as their text instead of throwing", () => { + expect(toCliOutput(10n)).toEqual({ text: "10", exitCode: 0 }); + expect(toCliOutput(Symbol("pix"))).toEqual({ text: "Symbol(pix)", exitCode: 0 }); + expect(toCliOutput({ total: 10n, key: Symbol("pix") }).text).toBe( + '{\n "total": "10",\n "key": "Symbol(pix)"\n}', + ); + expect(toCliOutput({ run: String }).text).toBe( + JSON.stringify({ run: String(String) }, null, 2), + ); + }); + + describe("properties", () => { + test("should print any string unchanged and exit with 0", () => { + fc.assert( + fc.property(anyText, (text) => { + expect(toCliOutput(text)).toEqual({ text, exitCode: 0 }); + }), + ); + }); + + test("should print any JSON value as text that parses back to it", () => { + fc.assert( + fc.property(fc.jsonValue(), (value) => { + const { text } = toCliOutput(value); + + expect(text).toBe(typeof value === "string" ? value : JSON.stringify(value, null, 2)); + }), + ); + }); + + test("should exit with 1 only for false, null and undefined", () => { + fc.assert( + fc.property(fc.anything(), (value) => { + const { text, exitCode } = toCliOutput(value); + + expect(typeof text).toBe("string"); + expect(exitCode).toBe(value === false || value === null || value === undefined ? 1 : 0); + }), + ); + }); + }); +}); + +describe("toCliOutput types", () => { + test("should take any value and return the text and the exit code", () => { + expectTypeOf(toCliOutput).parameter(0).toEqualTypeOf(); + expectTypeOf(toCliOutput).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<0 | 1>(); + }); +}); diff --git a/src/_cli/to-cli-output/to-cli-output.ts b/src/_cli/to-cli-output/to-cli-output.ts new file mode 100644 index 000000000..f457e45d6 --- /dev/null +++ b/src/_cli/to-cli-output/to-cli-output.ts @@ -0,0 +1,60 @@ +import { CLI_EXIT_CODES } from "../constants"; + +/** What the command line prints for the value a utility returned. */ +export type CliOutput = { + /** The text written to stdout, without the trailing line break. */ + text: string; + /** `1` when the value is `false`, `null` or `undefined`, `0` otherwise. */ + exitCode: 0 | 1; +}; + +const toLocalDate = (date: Date): string => { + const year = String(date.getFullYear()).padStart(4, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +}; + +const toPrintable = (value: unknown): unknown => { + if (value instanceof Date) return toLocalDate(value); + if (Array.isArray(value)) return value.map((item) => toPrintable(item)); + if (typeof value === "bigint" || typeof value === "symbol") return String(value); + if (typeof value === "function") return String(value); + if (typeof value !== "object" || value === null) return value; + + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toPrintable(item)])); +}; + +/** + * Renders the value a utility returned for the command line. + * + * A string is printed as it is, anything else as indented JSON (which prints a number or `true` + * as they are too). A `Date`, alone or inside a result, is printed as its local calendar day + * (`YYYY-MM-DD`), the way the date utilities of this library read one, instead of the UTC instant + * `JSON.stringify` would write. The exit code is `1` for the negative answers of the library: + * `false` from a validator and `null` from a lookup. The values JSON cannot hold (a bigint, a + * symbol, a function) are printed as their text, so rendering never throws. + * + * @param {unknown} value - The value returned by the utility. + * @returns {CliOutput} The text to print and the exit code. + * + * @example + * ```typescript + * toCliOutput("123.456.789-09"); // { text: "123.456.789-09", exitCode: 0 } + * toCliOutput(false); // { text: "false", exitCode: 1 } + * toCliOutput(null); // { text: "null", exitCode: 1 } + * toCliOutput(new Date(2024, 11, 25)); // { text: "2024-12-25", exitCode: 0 } + * toCliOutput({ code: "001" }); // { text: '{\n "code": "001"\n}', exitCode: 0 } + * ``` + */ +export const toCliOutput = (value: unknown): CliOutput => { + if (value === undefined || value === null || value === false) { + return { text: value === false ? "false" : "null", exitCode: CLI_EXIT_CODES.failure }; + } + + const printable = toPrintable(value); + const text = typeof printable === "string" ? printable : JSON.stringify(printable, null, 2); + + return { text, exitCode: CLI_EXIT_CODES.success }; +}; diff --git a/stryker.config.json b/stryker.config.json index a6f745b68..175bc0043 100644 --- a/stryker.config.json +++ b/stryker.config.json @@ -11,7 +11,8 @@ "!src/_internals/test/**", "!src/**/constants.ts", "!src/_internals/constants/**", - "!src/index.ts" + "!src/index.ts", + "!src/_cli/bin/bin.ts" ], "coverageAnalysis": "perTest", "reporters": ["clear-text", "progress", "html", "json"], diff --git a/vite.config.ts b/vite.config.ts index 341200a82..d0fd04fb6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync } from "node:fs"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { transform } from "esbuild"; import { rules as sonarjsPluginRules } from "eslint-plugin-sonarjs"; @@ -55,6 +55,21 @@ const minifyUmdChunk = (): PackPlugin => ({ }, }); +/** + * The command line (`src/_cli/bin/bin.ts`, published as the `bin` of the package) dispatches to + * the whole public API. Bundling `src/index.ts` into it would ship every dataset a second time, + * so its import of the barrel is rewritten to the root ESM bundle that sits next to it in `dist/`. + * @returns {PackPlugin} The pack plugin that points the command line at `dist/brazilian-utils.js`. + */ +const externalizeLibrary = (): PackPlugin => ({ + name: "brazilian-utils:externalize-library", + resolveId(source, importer) { + if (importer === undefined) return null; + if (resolve(dirname(importer), source) !== resolve(srcDir, "index")) return null; + return { id: "./brazilian-utils.js", external: true }; + }, +}); + /** * Every util directory under `src/` (skipping the `_`-prefixed internal ones) that has a * same-named entry file (`src/get-cities/get-cities.ts`, `src/is-valid-cpf/is-valid-cpf.ts`, @@ -465,10 +480,12 @@ export default defineConfig({ }, // The barrel re-exports every deprecated alias, and a deprecated util's own tests (plus // the `getMunicipalities` property that cross-checks it against `getCities`) have to - // keep calling it for as long as it is still supported. + // keep calling it for as long as it is still supported. The command line lists the + // deprecated `generateCNPJ` next to `generateCnpj` because both are commands. { files: [ "src/index.ts", + "src/_cli/constants.ts", "src/index.test.ts", "src/get-cities/get-cities.test.ts", "src/get-municipalities/get-municipalities.test.ts", @@ -548,6 +565,7 @@ export default defineConfig({ "src/**/constants.ts", "src/_internals/constants/**", "src/index.ts", + "src/_cli/bin/bin.ts", ], thresholds: { statements: 100, @@ -571,5 +589,16 @@ export default defineConfig({ entry: utilEntries, format: ["es", "cjs"], }, + // The command line: one ESM file with a shebang, no declarations (it exports nothing) and + // the library left external (see `externalizeLibrary`), so it costs library consumers + // nothing: no entry point imports it and `exports` does not list it. + { + ...sharedPack, + sourcemap: false, + dts: false, + entry: { cli: resolve(rootDir, "src/_cli/bin/bin.ts") }, + format: ["es"], + plugins: [externalizeLibrary()], + }, ], }); From 31ec49d073b266fdb1bf3c394bbe48c709c620f9 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:13:29 -0300 Subject: [PATCH 2/8] docs: document the command line Adds a Command line section to the README and to both getting-started pages (arguments, options, stdin, dates, output and exit codes), regenerates llms-full.txt and describes src/_cli and its kind tables in the Architecture section of CONTRIBUTING. --- CONTRIBUTING.md | 10 +++++++++ README.md | 13 +++++++++++ docs/getting-started.md | 41 ++++++++++++++++++++++++++++++++++ docs/llms-full.txt | 42 +++++++++++++++++++++++++++++++++++ docs/pt-br/getting-started.md | 41 ++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb98fd889..f23e9bc74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,16 @@ but the few rules below hold everywhere: ViaCEP and the alternatives listed in their docs) over HTTPS through `src/_internals/fetch-with-retry`, treat every response as untrusted input and never send anything but the CEP or address being looked up. +- `src/_cli/` is the `brazilian-utils` command (the `bin` of `package.json`), laid out like + `src/_internals/`: one function per folder. It is a generic dispatcher over `src/index.ts`, so a + new utility is a new command with no wiring. `run-cli` is the dispatcher as a pure function + (arguments and stdin in, output and exit code out), `run-bin` connects it to a process, and + `bin/bin.ts` is the three-line Node.js entry, built into `dist/cli.js` by its own pack config in + `vite.config.ts` with the library left external. No library entry point imports it, so it never + reaches a consumer's bundle. Arguments are strings unless `src/_cli/constants.ts` says otherwise: + `CLI_OPTION_KINDS` and `CLI_POSITIONAL_KINDS` list the options and positional arguments that are + a number, a boolean, a list or a date, and their types are derived from the public signatures, + so `npm run check` fails when a new non-string option or argument is missing from them. - There are no runtime dependencies (see [Zero runtime dependencies](#zero-runtime-dependencies)), so the trust boundary of the published package is this repository, its build toolchain and the npm registry; [MAINTAINERS.md](MAINTAINERS.md) lists who can change what, and diff --git a/README.md b/README.md index abb4df232..033592f5c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,19 @@ You can check a list of utilities [by clicking here](https://brazilian-utils.com - Using an AI coding assistant? The docs are indexed on Context7 as [`/brazilian-utils/javascript`](https://context7.com/brazilian-utils/javascript), and [llms.txt](https://brazilian-utils.com.br/llms.txt) lists every util for other tools. See [AI assistants](https://brazilian-utils.com.br/getting-started?id=ai-assistants). - The package is tree-shakeable. Every util is also available as its own subpath (e.g. `@brazilian-utils/brazilian-utils/get-cities`) so you can lazy-load the few heavy ones. See [Bundle size](https://brazilian-utils.com.br/getting-started?id=bundle-size). +## Command line + +Every utility also runs from a terminal or a shell script, with no install needed: + +```bash +npx @brazilian-utils/brazilian-utils isValidCpf 12345678909 # true (exit code 0; 1 for false or null) +npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **.345.678/0001-** +npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", ... } as JSON +echo 01001000 | npx @brazilian-utils/brazilian-utils getAddressInfoByCep # reads stdin, awaits the lookup +``` + +The first argument is the name of a utility, the positional values are its arguments and `--key value`, `--flag` or `--json ''` become its options object. `list` prints every utility and `--help` the full usage. See [Command line](https://brazilian-utils.com.br/getting-started?id=command-line). + ## Development This repository uses Vite+ as the local toolchain; it is installed as a dependency, so nothing has diff --git a/docs/getting-started.md b/docs/getting-started.md index b88032c2d..d8fc231db 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -65,6 +65,47 @@ isValidCpf('1232454233345'); // false You can check a list of utilities [by clicking here](utilities.md). +## Command line + +The package ships a `brazilian-utils` command that runs any utility from a terminal or a shell script, with no install needed: + +```bash +npx @brazilian-utils/brazilian-utils isValidCpf 12345678909 # true +npx @brazilian-utils/brazilian-utils generateCpf # 45654643304 +npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **.345.678/0001-** +npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", "ispb": "00000000", ... } +``` + +`bunx @brazilian-utils/brazilian-utils` and `deno run -A npm:@brazilian-utils/brazilian-utils` run the same command, and once the package is installed in a project it is `npx brazilian-utils` (or plain `brazilian-utils` in a `package.json` script). + +The command is a generic dispatcher over the public API: the first argument is the name of a utility, exactly as it is exported, and the rest maps to its arguments. + +| You write | The utility receives | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `isValidIe SP 110042490114` | Positional values, in order: `isValidIe("SP", "110042490114")` | +| `--key value`, `--key=value` | An entry of the options (or params) object; `--state-code` and `--stateCode` are the same key | +| `--flag`, `--no-flag` | A boolean option set to `true` or to `false`: `formatCurrency 10 --no-symbol` | +| `--json ''` | The options (or params) object as JSON; options given next to it win | +| `-`, or no value in a pipe | The value is read from stdin: `echo 12345678909 \| brazilian-utils formatCpf --obfuscate` | +| `--` | Ends the options, so that a value starting with `--` is read as a value | + +Values stay strings (so `001` keeps its zeros), except where the utility expects a number, a list (comma separated: `--accept cpf,cnpj`) or a date. Dates are written `YYYY-MM-DD`, mean that local calendar day, and are printed the same way: + +```bash +brazilian-utils addBusinessDays 2026-09-04 1 # 2026-09-08 +brazilian-utils getHolidays --year 2026 --state-code SP # [{ "name": "Ano novo", "date": "2026-01-01", ... }] +brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","account":"12345678","digit":"9"}' +brazilian-utils getAddressInfoByCep 01001000 # awaits the lookup, then prints the address +``` + +Strings and numbers are printed as they are, anything else as JSON. The exit code is `0` on success, `1` when the answer is `false` or `null` or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: + +```bash +if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi +``` + +`brazilian-utils list` prints the name of every utility, `--help` the usage and `--version` the version of the package. The command is a separate file that no entry point of the library imports, so it adds nothing to your bundle. + ## AI assistants The documentation is indexed on Context7 as [`/brazilian-utils/javascript`](https://context7.com/brazilian-utils/javascript). In a coding agent connected to the Context7 MCP server, name the library in the prompt and the agent skips the library search: diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 11a71008d..2f98e9319 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -9,6 +9,7 @@ - [Installation](#installation) - [Runtime support](#runtime-support) - [Usage](#usage) + - [Command line](#command-line) - [AI assistants](#ai-assistants) - [Bundle size](#bundle-size) - [Utilities](#utilities) @@ -214,6 +215,47 @@ isValidCpf('1232454233345'); // false You can check a list of utilities [by clicking here](utilities.md). +### Command line + +The package ships a `brazilian-utils` command that runs any utility from a terminal or a shell script, with no install needed: + +```bash +npx @brazilian-utils/brazilian-utils isValidCpf 12345678909 # true +npx @brazilian-utils/brazilian-utils generateCpf # 45654643304 +npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **.345.678/0001-** +npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", "ispb": "00000000", ... } +``` + +`bunx @brazilian-utils/brazilian-utils` and `deno run -A npm:@brazilian-utils/brazilian-utils` run the same command, and once the package is installed in a project it is `npx brazilian-utils` (or plain `brazilian-utils` in a `package.json` script). + +The command is a generic dispatcher over the public API: the first argument is the name of a utility, exactly as it is exported, and the rest maps to its arguments. + +| You write | The utility receives | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `isValidIe SP 110042490114` | Positional values, in order: `isValidIe("SP", "110042490114")` | +| `--key value`, `--key=value` | An entry of the options (or params) object; `--state-code` and `--stateCode` are the same key | +| `--flag`, `--no-flag` | A boolean option set to `true` or to `false`: `formatCurrency 10 --no-symbol` | +| `--json ''` | The options (or params) object as JSON; options given next to it win | +| `-`, or no value in a pipe | The value is read from stdin: `echo 12345678909 \| brazilian-utils formatCpf --obfuscate` | +| `--` | Ends the options, so that a value starting with `--` is read as a value | + +Values stay strings (so `001` keeps its zeros), except where the utility expects a number, a list (comma separated: `--accept cpf,cnpj`) or a date. Dates are written `YYYY-MM-DD`, mean that local calendar day, and are printed the same way: + +```bash +brazilian-utils addBusinessDays 2026-09-04 1 # 2026-09-08 +brazilian-utils getHolidays --year 2026 --state-code SP # [{ "name": "Ano novo", "date": "2026-01-01", ... }] +brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","account":"12345678","digit":"9"}' +brazilian-utils getAddressInfoByCep 01001000 # awaits the lookup, then prints the address +``` + +Strings and numbers are printed as they are, anything else as JSON. The exit code is `0` on success, `1` when the answer is `false` or `null` or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: + +```bash +if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi +``` + +`brazilian-utils list` prints the name of every utility, `--help` the usage and `--version` the version of the package. The command is a separate file that no entry point of the library imports, so it adds nothing to your bundle. + ### AI assistants The documentation is indexed on Context7 as [`/brazilian-utils/javascript`](https://context7.com/brazilian-utils/javascript). In a coding agent connected to the Context7 MCP server, name the library in the prompt and the agent skips the library search: diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 51c7bbb5d..32bb7fb48 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -65,6 +65,47 @@ isValidCpf('1232454233345'); // false Você pode conferir a lista de utilitários [clicando aqui](pt-br/utilities.md). +## Linha de comando + +O pacote traz o comando `brazilian-utils`, que executa qualquer utilitário a partir do terminal ou de um shell script, sem precisar instalar nada: + +```bash +npx @brazilian-utils/brazilian-utils isValidCpf 12345678909 # true +npx @brazilian-utils/brazilian-utils generateCpf # 45654643304 +npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **.345.678/0001-** +npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", "ispb": "00000000", ... } +``` + +`bunx @brazilian-utils/brazilian-utils` e `deno run -A npm:@brazilian-utils/brazilian-utils` executam o mesmo comando, e com o pacote instalado no projeto ele vira `npx brazilian-utils` (ou só `brazilian-utils` em um script do `package.json`). + +O comando é um despachante genérico sobre a API pública: o primeiro argumento é o nome de um utilitário, exatamente como ele é exportado, e o restante vira os argumentos dele. + +| Você escreve | O utilitário recebe | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `isValidIe SP 110042490114` | Valores posicionais, em ordem: `isValidIe("SP", "110042490114")` | +| `--key value`, `--key=value` | Uma entrada do objeto de opções (ou de parâmetros); `--state-code` e `--stateCode` são a mesma chave | +| `--flag`, `--no-flag` | Uma opção booleana com valor `true` ou `false`: `formatCurrency 10 --no-symbol` | +| `--json ''` | O objeto de opções (ou de parâmetros) em JSON; as opções passadas junto com ele têm prioridade | +| `-`, ou nenhum valor em pipe | O valor é lido da stdin: `echo 12345678909 \| brazilian-utils formatCpf --obfuscate` | +| `--` | Encerra as opções, para que um valor que começa com `--` seja lido como valor | + +Os valores continuam sendo strings (assim `001` mantém os zeros), exceto onde o utilitário espera um número, uma lista (separada por vírgulas: `--accept cpf,cnpj`) ou uma data. Datas são escritas como `YYYY-MM-DD`, representam esse dia do calendário local e são impressas do mesmo jeito: + +```bash +brazilian-utils addBusinessDays 2026-09-04 1 # 2026-09-08 +brazilian-utils getHolidays --year 2026 --state-code SP # [{ "name": "Ano novo", "date": "2026-01-01", ... }] +brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","account":"12345678","digit":"9"}' +brazilian-utils getAddressInfoByCep 01001000 # aguarda a consulta e imprime o endereço +``` + +Strings e números são impressos como estão, e todo o resto como JSON. O código de saída é `0` em caso de sucesso, `1` quando a resposta é `false` ou `null` ou quando o utilitário lança um erro (que vai para a stderr), e `2` quando a própria linha de comando está errada, então um validador funciona como condição no shell: + +```bash +if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi +``` + +`brazilian-utils list` imprime o nome de todos os utilitários, `--help` o modo de uso e `--version` a versão do pacote. O comando é um arquivo separado que nenhum ponto de entrada da biblioteca importa, então não acrescenta nada ao seu bundle. + ## Assistentes de IA A documentação está indexada no Context7 como [`/brazilian-utils/javascript`](https://context7.com/brazilian-utils/javascript). Em um agente de código conectado ao servidor MCP do Context7, cite a biblioteca no prompt e o agente pula a busca pela biblioteca: From 5f76e00ce84691c7d4db7dbf329a0beca2c3b458 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:54:25 -0300 Subject: [PATCH 3/8] fix(cli): block the ./cli subpath of the package The `"./*"` wildcard in `exports` matched the new `dist/cli.js`, so `@brazilian-utils/brazilian-utils/cli` resolved and importing it ran the command inside the consumer's process: it wrote the usage to their stderr and set their `process.exitCode`. It also had no `cli.d.ts` and no `cli.cjs`, so the `types` and `require` conditions of the wildcard dangled for that path, and it made the package-wide `"sideEffects": false` untrue. Mapping `"./cli"` to `null` before the wildcard leaves the `bin` as the only way to reach the file. Every other subpath keeps resolving, and attw and publint stay clean. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 1d414af23..a398d2625 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "default": "./dist/brazilian-utils.umd.cjs" } }, + "./cli": null, "./*": { "import": { "types": "./dist/*.d.ts", From a215aa40ced800350e2f3a55a800cd55b5110407 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:54:36 -0300 Subject: [PATCH 4/8] fix(cli): correct stdin, boolean options and empty answers Three answers the command line could give wrongly, plus the browser test run that could not carry the test as it was written. The implicit stdin rule asked whether positional values or `--json` were given, but not whether the utility takes its arguments as an object. Every such utility has `Function.length >= 1`, so `isHoliday --target-date 2026-09-07` read stdin and passed its text as the first argument, pushing the options to second place: inside a script run as `bash script.sh < input`, or in a `while read` loop, the command silently answered `false` instead of `true` (and consumed the loop's input). `CLI_PARAMS_UTILITIES` now lists those utilities, derived from the public signatures the same way `CLI_OPTION_KINDS` and `CLI_POSITIONAL_KINDS` are, so a new one that is missing fails `npm run check`, and they only read stdin where a `-` asks for it. A boolean option spelled `--symbol=no` kept the text, which is truthy, so it turned the flag on instead of off, and `--no-symbol=true` invented a `noSymbol` key and never set `symbol`. Both are usage errors now, exit 2, the way a missing value already was. A formatter that cannot read its value answers `""`, which exited 0 with a blank line and broke the `if brazilian-utils ...` idiom the docs advertise for the whole `format*` family. `""` is the library's third negative answer, next to `false` and `null`, so it exits 1 as well. The test of the dispatcher drove the public API through `src/index.ts`, which made it the second test file to instantiate the whole 278 module graph in one browser page. Chrome gave out on it: the run died on a dynamic import and a lost iframe, taking two unrelated files with it and cancelling edge and firefox. It now imports the utilities it exercises one by one, keeps the barrel as a type-only import for the type test, and `import/max-dependencies` is turned off for that file. --- CONTRIBUTING.md | 6 +- src/_cli/coerce-cli-value/coerce-cli-value.ts | 4 +- src/_cli/constants.ts | 39 +++++++- .../parse-cli-arguments.test.ts | 34 ++++++- .../parse-cli-arguments.ts | 47 ++++++--- src/_cli/run-cli/run-cli.test.ts | 95 +++++++++++++++++-- src/_cli/run-cli/run-cli.ts | 19 +++- src/_cli/to-cli-output/to-cli-output.test.ts | 15 ++- src/_cli/to-cli-output/to-cli-output.ts | 12 ++- vite.config.ts | 12 ++- 10 files changed, 239 insertions(+), 44 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f23e9bc74..cd7bf0c81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,8 +87,10 @@ but the few rules below hold everywhere: `vite.config.ts` with the library left external. No library entry point imports it, so it never reaches a consumer's bundle. Arguments are strings unless `src/_cli/constants.ts` says otherwise: `CLI_OPTION_KINDS` and `CLI_POSITIONAL_KINDS` list the options and positional arguments that are - a number, a boolean, a list or a date, and their types are derived from the public signatures, - so `npm run check` fails when a new non-string option or argument is missing from them. + a number, a boolean, a list or a date, and `CLI_PARAMS_UTILITIES` the utilities whose first + argument is an options object, which therefore never read stdin on their own. All three types + are derived from the public signatures, so `npm run check` fails when a new non-string option or + argument, or a new params-only utility, is missing from them. - There are no runtime dependencies (see [Zero runtime dependencies](#zero-runtime-dependencies)), so the trust boundary of the published package is this repository, its build toolchain and the npm registry; [MAINTAINERS.md](MAINTAINERS.md) lists who can change what, and diff --git a/src/_cli/coerce-cli-value/coerce-cli-value.ts b/src/_cli/coerce-cli-value/coerce-cli-value.ts index d043d5778..454f5d0be 100644 --- a/src/_cli/coerce-cli-value/coerce-cli-value.ts +++ b/src/_cli/coerce-cli-value/coerce-cli-value.ts @@ -28,7 +28,9 @@ const toBoolean = (value: string): boolean | string => { * from `--json`) is handed back untouched, and so is text with no kind, which stays a string so * that codes such as `"001"` keep their leading zeros. Text that does not spell its kind becomes * the invalid value of that kind (`NaN`, an invalid `Date`), which every utility already answers - * with `false`, `null` or `""` instead of throwing. + * with `false`, `null` or `""` instead of throwing. A boolean has no invalid value, so text that + * spells neither `"true"` nor `"false"` is left as it is; `parseCliArguments` rejects that + * spelling before it gets here, and only a `--json` entry can still carry it. * * A date is written `YYYY-MM-DD` and means that local calendar day, the way the date utilities of * this library read a `Date`. A day that does not exist (`2024-02-30`, month `13`) rolls over into diff --git a/src/_cli/constants.ts b/src/_cli/constants.ts index 6f9f83c72..9b2080fec 100644 --- a/src/_cli/constants.ts +++ b/src/_cli/constants.ts @@ -73,6 +73,18 @@ type CliPositionalKinds = { ]: PositionalKindsOf>; }; +type CliParamsUtilities = { + [ + Name in keyof Api as Name extends Capitalize + ? never + : ArgumentsOf extends [unknown, ...unknown[]] + ? PositionalKindsOf> extends [] + ? Name + : never + : never + ]: true; +}; + /** * Exit codes of the command line: a negative answer (`false`, `null` or a utility that throws) is * told apart from a call that could not be made at all. @@ -126,6 +138,26 @@ export const CLI_POSITIONAL_KINDS: Readonly; +/** + * Every utility whose first argument is an options or params object, derived from the signatures + * the same way. Their values are written as options, so stdin is only read for them when a `-` + * asks for it: a command run with stdin attached to a file or a pipe, as a shell script is, must + * not turn that text into a first argument. `getHolidays` is here through its params overload, + * and still takes a year as a written positional value. + */ +export const CLI_PARAMS_UTILITIES: Readonly> = { + generateBoleto: true, + generatePixPayload: true, + generateProcessoJuridico: true, + getCepInfoByAddress: true, + getHolidays: true, + getLegalNatures: true, + getMunicipality: true, + isHoliday: true, + isValidBankAccount: true, + isValidRegistroProfissional: true, +} satisfies CliParamsUtilities; + /** The text printed by `--help`. */ export const CLI_USAGE = `Usage: brazilian-utils [value...] [--option value] [--flag] [--json ''] @@ -141,7 +173,8 @@ Runs any utility exported by @brazilian-utils/brazilian-utils. Arguments: value Positional arguments of the utility, in order. "-" reads the value from - stdin, and so does leaving the value out while stdin is a pipe or a file. + stdin, and so does leaving the value out while stdin is a pipe or a file, + unless the first argument of the utility is an options object. --key value An entry of the options (or params) object. --key=value and --kebab-case are accepted too. --flag, --no-flag A boolean option set to true or to false. @@ -156,8 +189,8 @@ Commands: --version, -v Prints the version of the package. Output: strings and numbers as they are, anything else as JSON. -Exit codes: 0 on success, 1 when the answer is false or null or the utility throws, 2 when the -command line itself is wrong. +Exit codes: 0 on success, 1 when the answer is negative (false, null or the empty string a +formatter answers with) or the utility throws, 2 when the command line itself is wrong. Documentation: https://brazilian-utils.com.br `; diff --git a/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts b/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts index 1d236138b..7d78cd897 100644 --- a/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts +++ b/src/_cli/parse-cli-arguments/parse-cli-arguments.test.ts @@ -77,13 +77,37 @@ describe("parseCliArguments", () => { }); }); - test("should keep the text given to a boolean option with an equals sign", () => { - expect(parseCliArguments(["--pad=false", "--no-pad=x"]).options).toEqual({ - pad: "false", - noPad: "x", + test("should read the value given to a boolean option with an equals sign as the boolean", () => { + expect(parseCliArguments(["--pad=false", "--symbol=true"]).options).toEqual({ + pad: false, + symbol: true, }); }); + test("should reject a boolean option whose value spells neither true nor false", () => { + expect(parseCliArguments(["--pad=x"])).toEqual({ + positionals: [], + options: {}, + error: "Option --pad needs true or false.", + }); + expect(parseCliArguments(["1", "--include-optional=yes", "--pad"])).toEqual({ + positionals: ["1"], + options: {}, + error: "Option --include-optional needs true or false.", + }); + }); + + test("should reject a value given to --no-flag", () => { + expect(parseCliArguments(["--no-pad=x"])).toEqual({ + positionals: [], + options: {}, + error: "Option --no-pad does not take a value.", + }); + expect(parseCliArguments(["--no-symbol=false"]).error).toBe( + "Option --no-symbol does not take a value.", + ); + }); + test("should read --no-key as an ordinary text option when key is not a boolean option", () => { expect(parseCliArguments(["--no-year", "5", "--no-", "x", "--no", "y"])).toEqual({ positionals: [], @@ -98,6 +122,8 @@ describe("parseCliArguments", () => { options: { help: true }, error: null, }); + expect(parseCliArguments(["--help=true"]).options).toEqual({ help: true }); + expect(parseCliArguments(["--help=x"]).error).toBe("Option --help needs true or false."); }); test("should let the last occurrence of an option win", () => { diff --git a/src/_cli/parse-cli-arguments/parse-cli-arguments.ts b/src/_cli/parse-cli-arguments/parse-cli-arguments.ts index 3fa58194f..453f7b8e4 100644 --- a/src/_cli/parse-cli-arguments/parse-cli-arguments.ts +++ b/src/_cli/parse-cli-arguments/parse-cli-arguments.ts @@ -21,15 +21,27 @@ const isFlag = (name: string): boolean => name === "help" || (Object.hasOwn(CLI_OPTION_KINDS, name) && CLI_OPTION_KINDS[name] === "boolean"); -const toEntry = (body: string): [string, string | boolean] | string => { +type Token = { option: [string, string | boolean] } | { pending: string } | { error: string }; + +const toToken = (body: string): Token => { const separator = body.indexOf("="); - const name = toCamelCase(separator === -1 ? body : body.slice(0, separator)); - const negated = toCamelCase(body.slice(NEGATION_PREFIX.length)); + const written = separator === -1 ? body : body.slice(0, separator); + const name = toCamelCase(written); + const negated = toCamelCase(written.slice(NEGATION_PREFIX.length)); + const isNegated = written.startsWith(NEGATION_PREFIX) && isFlag(negated); + + if (separator === -1) { + if (isFlag(name)) return { option: [name, true] }; + if (isNegated) return { option: [negated, false] }; + return { pending: name }; + } - if (separator !== -1) return [name, body.slice(separator + 1)]; - if (isFlag(name)) return [name, true]; - if (body.startsWith(NEGATION_PREFIX) && isFlag(negated)) return [negated, false]; - return name; + const value = body.slice(separator + 1); + + if (isNegated) return { error: `Option --${written} does not take a value.` }; + if (!isFlag(name)) return { option: [name, value] }; + if (value === "true" || value === "false") return { option: [name, value === "true"] }; + return { error: `Option --${written} needs true or false.` }; }; /** @@ -39,7 +51,11 @@ const toEntry = (body: string): [string, string | boolean] | string => { * (the keys listed as `"boolean"` in `CLI_OPTION_KINDS`, plus `help`) to `true` or `false`, and * `--kebab-case` names are read as `camelCase`. Everything after a bare `--` is positional, and * so is anything that does not start with `--`, which covers negative numbers and the `-` that - * stands for stdin. Nothing is converted here: values stay text. + * stands for stdin. Apart from the booleans, nothing is converted here: values stay text. + * + * A boolean option spelled `--flag=value` takes only `true` or `false`, and `--no-flag` takes no + * value at all. Anything else is a usage error rather than an option that looks set and holds + * text, which any non-empty value would make true. * * @param {readonly string[]} argv - The arguments after the utility name. * @returns {ParsedCliArguments} The positional values and the options, or the reason the command line cannot be read. @@ -52,12 +68,15 @@ const toEntry = (body: string): [string, string | boolean] | string => { * // { positionals: [], options: { year: "2026", stateCode: "SP", pad: false }, error: null } * parseCliArguments(["--year"]); * // { positionals: [], options: {}, error: "Option --year needs a value." } + * parseCliArguments(["--pad=yes"]); + * // { positionals: [], options: {}, error: "Option --pad needs true or false." } * ``` */ export const parseCliArguments = (argv: readonly string[]): ParsedCliArguments => { const positionals: string[] = []; const entries: [string, string | boolean][] = []; let pending: string | null = null; + let rejected: string | null = null; let isLiteral = false; for (const token of argv) { @@ -70,10 +89,14 @@ export const parseCliArguments = (argv: readonly string[]): ParsedCliArguments = } else if (isOption && token === OPTION_PREFIX) { isLiteral = true; } else if (isOption) { - const entry = toEntry(token.slice(OPTION_PREFIX.length)); + const parsed = toToken(token.slice(OPTION_PREFIX.length)); - if (typeof entry === "string") pending = entry; - else entries.push(entry); + if ("error" in parsed) { + rejected = parsed.error; + break; + } + if ("pending" in parsed) pending = parsed.pending; + else entries.push(parsed.option); } else { positionals.push(token); } @@ -82,6 +105,6 @@ export const parseCliArguments = (argv: readonly string[]): ParsedCliArguments = return { positionals, options: Object.fromEntries(entries), - error: pending === null ? null : `Option --${pending} needs a value.`, + error: rejected ?? (pending === null ? null : `Option --${pending} needs a value.`), }; }; diff --git a/src/_cli/run-cli/run-cli.test.ts b/src/_cli/run-cli/run-cli.test.ts index b687c11ea..af83a4c88 100644 --- a/src/_cli/run-cli/run-cli.test.ts +++ b/src/_cli/run-cli/run-cli.test.ts @@ -2,12 +2,34 @@ import * as fc from "fast-check"; import { PROTOTYPE_KEYS, anyText } from "../../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, test } from "../../_internals/test/runtime"; -import * as brazilianUtils from "../../index"; +import { addBusinessDays } from "../../add-business-days/add-business-days"; +import { convertNumberToWords } from "../../convert-number-to-words/convert-number-to-words"; +import { formatCnpj } from "../../format-cnpj/format-cnpj"; +import { formatCpf } from "../../format-cpf/format-cpf"; +import { formatCurrency } from "../../format-currency/format-currency"; +import { generateCnpj } from "../../generate-cnpj/generate-cnpj"; +import { generateCpf } from "../../generate-cpf/generate-cpf"; +import { + GetAddressInfoByCepError, + getAddressInfoByCep, +} from "../../get-address-info-by-cep/get-address-info-by-cep"; +import { getBankByCode } from "../../get-bank-by-code/get-bank-by-code"; +import { getCepInfoByAddress } from "../../get-cep-info-by-address/get-cep-info-by-address"; +import { getHolidays } from "../../get-holidays/get-holidays"; +import type * as brazilianUtils from "../../index"; +import { isBusinessDay } from "../../is-business-day/is-business-day"; +import { isHoliday } from "../../is-holiday/is-holiday"; +import { isValidCnpj } from "../../is-valid-cnpj/is-valid-cnpj"; +import { isValidCpf } from "../../is-valid-cpf/is-valid-cpf"; +import { isValidIe } from "../../is-valid-ie/is-valid-ie"; import { CLI_USAGE } from "../constants"; import { type CliResult, type CliStdin, type RunCliParams, runCli } from "./run-cli"; const USAGE_HINT = 'Run "brazilian-utils --help" for usage.\n'; +const HOLIDAYS_2026_HEAD = + '[\n {\n "name": "Ano novo",\n "date": "2026-01-01",\n "type": "national"\n },'; + const terminal: CliStdin = { isPiped: false, read: () => Promise.reject(new Error("stdin must not be read")), @@ -66,8 +88,28 @@ const exitCodeOf = async (result: Promise) => { return exitCode; }; +const publicApi = { + GetAddressInfoByCepError, + addBusinessDays, + convertNumberToWords, + formatCnpj, + formatCpf, + formatCurrency, + generateCnpj, + generateCpf, + getAddressInfoByCep, + getBankByCode, + getCepInfoByAddress, + getHolidays, + isBusinessDay, + isHoliday, + isValidCnpj, + isValidCpf, + isValidIe, +}; + const runApi = (argv: string[], stdin: CliStdin = terminal) => - runCli({ argv, api: brazilianUtils, version: "9.8.7", stdin }); + runCli({ argv, api: publicApi, version: "9.8.7", stdin }); const apiStdout = (argv: string[], stdin: CliStdin = terminal) => stdoutOf(runApi(argv, stdin)); @@ -408,6 +450,22 @@ describe("runCli", () => { expect(calls).toEqual([[]]); }); + test("should not read a piped stdin for a utility whose first argument is an options object", async () => { + const script = pipe("hello world\n"); + const holidays = await apiStdout(["getHolidays", "--year", "2026"], script); + + expect(await apiStdout(["isHoliday", "--target-date", "2026-09-07"], script)).toBe("true\n"); + expect(await apiStdout(["isHoliday", "--target-date", "2026-09-08"], script)).toBe("false\n"); + expect(holidays.slice(0, 83)).toBe(HOLIDAYS_2026_HEAD); + expect(await apiStdout(["isHoliday"], script)).toBe("false\n"); + }); + + test("should still read a - from stdin for a utility that takes an options object", async () => { + const holidays = await apiStdout(["getHolidays", "-"], pipe("2026\n")); + + expect(holidays.slice(0, 83)).toBe(HOLIDAYS_2026_HEAD); + }); + test("should read every - from stdin, piped or not", async () => { const { api, calls } = createApi(); @@ -502,12 +560,35 @@ describe("runCli", () => { expect(await apiStdout(["formatCnpj", "12345678000195", "--obfuscate"])).toBe( "**.345.678/0001-**\n", ); - expect(await apiStdout(["formatCurrency", "1234.5", "--no-symbol"])).toBe("1.234,50\n"); + expect(await apiStdout(["formatCurrency", "1234.5"])).toBe("1.234,50\n"); + expect(await apiStdout(["formatCurrency", "1234.5", "--symbol"])).toBe("R$ 1.234,50\n"); + expect(await apiStdout(["formatCurrency", "1234.5", "--symbol=false"])).toBe("1.234,50\n"); expect(await apiStdout(["formatCpf", "--obfuscate"], pipe("12345678909\n"))).toBe( "***.456.789-**\n", ); }); + test("should exit with 1 when a formatter cannot read its value", async () => { + expect(await runApi(["formatCpf", "abc"])).toEqual({ + stdout: "\n", + stderr: "", + exitCode: 1, + }); + }); + + test("should reject a boolean option that spells neither true nor false", async () => { + expect(await runApi(["formatCurrency", "1234.5", "--symbol=no"])).toEqual({ + stdout: "", + stderr: `Option --symbol needs true or false.\n${USAGE_HINT}`, + exitCode: 2, + }); + expect(await runApi(["formatCurrency", "1234.5", "--no-symbol=true"])).toEqual({ + stdout: "", + stderr: `Option --no-symbol does not take a value.\n${USAGE_HINT}`, + exitCode: 2, + }); + }); + test("should look up, printing JSON and exiting with 1 for a miss", async () => { expect(await runApi(["getBankByCode", "001"])).toEqual({ stdout: '{\n "code": "001",\n "ispb": "00000000",\n "name": "Banco do Brasil S.A."\n}\n', @@ -540,9 +621,7 @@ describe("runCli", () => { ); const holidays = await apiStdout(["getHolidays", "2026"]); - expect(holidays.slice(0, 83)).toBe( - '[\n {\n "name": "Ano novo",\n "date": "2026-01-01",\n "type": "national"\n },', - ); + expect(holidays.slice(0, 83)).toBe(HOLIDAYS_2026_HEAD); }); test("should report the rejection of a network utility without touching the network", async () => { @@ -591,7 +670,7 @@ describe("runCli", () => { async (name, argv) => { const result = await runCli({ argv: [name, ...argv], - api: brazilianUtils, + api: publicApi, version: "9.8.7", stdin: pipe(""), }); @@ -610,6 +689,6 @@ describe("runCli types", () => { expectTypeOf(runCli).returns.toEqualTypeOf>(); expectTypeOf().toEqualTypeOf<0 | 1 | 2>(); expectTypeOf().toEqualTypeOf>>(); - expectTypeOf(brazilianUtils).toExtend(); + expectTypeOf().toExtend(); }); }); diff --git a/src/_cli/run-cli/run-cli.ts b/src/_cli/run-cli/run-cli.ts index e652e4da1..187629027 100644 --- a/src/_cli/run-cli/run-cli.ts +++ b/src/_cli/run-cli/run-cli.ts @@ -1,5 +1,11 @@ import { coerceCliValue } from "../coerce-cli-value/coerce-cli-value"; -import { CLI_EXIT_CODES, CLI_OPTION_KINDS, CLI_POSITIONAL_KINDS, CLI_USAGE } from "../constants"; +import { + CLI_EXIT_CODES, + CLI_OPTION_KINDS, + CLI_PARAMS_UTILITIES, + CLI_POSITIONAL_KINDS, + CLI_USAGE, +} from "../constants"; import { parseCliArguments } from "../parse-cli-arguments/parse-cli-arguments"; import { toCliOutput } from "../to-cli-output/to-cli-output"; @@ -132,7 +138,11 @@ const dispatch = ( if (base === null) return Promise.resolve(rejectUsage("Option --json needs a JSON object.")); const isImplicit = - positionals.length === 0 && json === undefined && stdin.isPiped && utility.length > 0; + positionals.length === 0 && + json === undefined && + stdin.isPiped && + utility.length > 0 && + !Object.hasOwn(CLI_PARAMS_UTILITIES, command); const kinds = Object.hasOwn(CLI_POSITIONAL_KINDS, command) ? CLI_POSITIONAL_KINDS[command] : undefined; @@ -177,8 +187,9 @@ const resolveCommand = ( * utility's arguments, in order, and the `--key value` options, merged over the `--json` object, * become its last argument. Values stay text unless `CLI_POSITIONAL_KINDS` or `CLI_OPTION_KINDS` * give them a kind. A value written as `-` is read from stdin, and so is a value left out when - * stdin is piped, the utility takes arguments and no `--json` was given. A promise returned by - * the utility (`getAddressInfoByCep`, `getCepInfoByAddress`) is awaited. + * stdin is piped, the utility takes a value as its first argument (so not one of + * `CLI_PARAMS_UTILITIES`) and no `--json` was given. A promise returned by the utility + * (`getAddressInfoByCep`, `getCepInfoByAddress`) is awaited. * * It never throws: a utility that throws or rejects becomes exit code `1` with the error on * stderr, and a command line that cannot be read becomes exit code `2`. diff --git a/src/_cli/to-cli-output/to-cli-output.test.ts b/src/_cli/to-cli-output/to-cli-output.test.ts index 4bf22722a..d98c2de41 100644 --- a/src/_cli/to-cli-output/to-cli-output.test.ts +++ b/src/_cli/to-cli-output/to-cli-output.test.ts @@ -7,10 +7,13 @@ import { type CliOutput, toCliOutput } from "./to-cli-output"; describe("toCliOutput", () => { test("should print a string as it is", () => { expect(toCliOutput("123.456.789-09")).toEqual({ text: "123.456.789-09", exitCode: 0 }); - expect(toCliOutput("")).toEqual({ text: "", exitCode: 0 }); expect(toCliOutput('say "oi"')).toEqual({ text: 'say "oi"', exitCode: 0 }); }); + test("should exit with 1 for the empty string a formatter answers with", () => { + expect(toCliOutput("")).toEqual({ text: "", exitCode: 1 }); + }); + test("should print a number as it is", () => { expect(toCliOutput(1234.56)).toEqual({ text: "1234.56", exitCode: 0 }); expect(toCliOutput(0)).toEqual({ text: "0", exitCode: 0 }); @@ -111,10 +114,10 @@ describe("toCliOutput", () => { }); describe("properties", () => { - test("should print any string unchanged and exit with 0", () => { + test("should print any string unchanged", () => { fc.assert( fc.property(anyText, (text) => { - expect(toCliOutput(text)).toEqual({ text, exitCode: 0 }); + expect(toCliOutput(text)).toEqual({ text, exitCode: text === "" ? 1 : 0 }); }), ); }); @@ -129,13 +132,15 @@ describe("toCliOutput", () => { ); }); - test("should exit with 1 only for false, null and undefined", () => { + test("should exit with 1 only for false, null, undefined and the empty string", () => { fc.assert( fc.property(fc.anything(), (value) => { const { text, exitCode } = toCliOutput(value); + const isNegative = + value === false || value === null || value === undefined || value === ""; expect(typeof text).toBe("string"); - expect(exitCode).toBe(value === false || value === null || value === undefined ? 1 : 0); + expect(exitCode).toBe(isNegative ? 1 : 0); }), ); }); diff --git a/src/_cli/to-cli-output/to-cli-output.ts b/src/_cli/to-cli-output/to-cli-output.ts index f457e45d6..9fc58247f 100644 --- a/src/_cli/to-cli-output/to-cli-output.ts +++ b/src/_cli/to-cli-output/to-cli-output.ts @@ -4,7 +4,7 @@ import { CLI_EXIT_CODES } from "../constants"; export type CliOutput = { /** The text written to stdout, without the trailing line break. */ text: string; - /** `1` when the value is `false`, `null` or `undefined`, `0` otherwise. */ + /** `1` when the value is `false`, `null`, `undefined` or the empty string, `0` otherwise. */ exitCode: 0 | 1; }; @@ -32,9 +32,10 @@ const toPrintable = (value: unknown): unknown => { * A string is printed as it is, anything else as indented JSON (which prints a number or `true` * as they are too). A `Date`, alone or inside a result, is printed as its local calendar day * (`YYYY-MM-DD`), the way the date utilities of this library read one, instead of the UTC instant - * `JSON.stringify` would write. The exit code is `1` for the negative answers of the library: - * `false` from a validator and `null` from a lookup. The values JSON cannot hold (a bigint, a - * symbol, a function) are printed as their text, so rendering never throws. + * `JSON.stringify` would write. The exit code is `1` for the three negative answers of the + * library: `false` from a validator, `null` from a lookup and `""` from a formatter or a parser + * that was given something it cannot read. The values JSON cannot hold (a bigint, a symbol, a + * function) are printed as their text, so rendering never throws. * * @param {unknown} value - The value returned by the utility. * @returns {CliOutput} The text to print and the exit code. @@ -44,6 +45,7 @@ const toPrintable = (value: unknown): unknown => { * toCliOutput("123.456.789-09"); // { text: "123.456.789-09", exitCode: 0 } * toCliOutput(false); // { text: "false", exitCode: 1 } * toCliOutput(null); // { text: "null", exitCode: 1 } + * toCliOutput(""); // { text: "", exitCode: 1 } * toCliOutput(new Date(2024, 11, 25)); // { text: "2024-12-25", exitCode: 0 } * toCliOutput({ code: "001" }); // { text: '{\n "code": "001"\n}', exitCode: 0 } * ``` @@ -53,6 +55,8 @@ export const toCliOutput = (value: unknown): CliOutput => { return { text: value === false ? "false" : "null", exitCode: CLI_EXIT_CODES.failure }; } + if (value === "") return { text: "", exitCode: CLI_EXIT_CODES.failure }; + const printable = toPrintable(value); const text = typeof printable === "string" ? printable : JSON.stringify(printable, null, 2); diff --git a/vite.config.ts b/vite.config.ts index d0fd04fb6..88427e716 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -517,6 +517,15 @@ export default defineConfig({ "jsdoc/require-returns": "off", }, }, + // The command line dispatches to the public API, so its test drives a sample of real + // utilities. They are imported one by one, never through the barrel: a second copy of + // the whole module graph is more than a browser test page can instantiate. + { + files: ["src/_cli/run-cli/run-cli.test.ts"], + rules: { + "import/max-dependencies": "off", + }, + }, { env: { node: true, @@ -591,7 +600,8 @@ export default defineConfig({ }, // The command line: one ESM file with a shebang, no declarations (it exports nothing) and // the library left external (see `externalizeLibrary`), so it costs library consumers - // nothing: no entry point imports it and `exports` does not list it. + // nothing: no entry point imports it and `exports` maps "./cli" to null, so the only way + // to reach it is the `bin`. { ...sharedPack, sourcemap: false, From 2d3b97bb38e7b09d52294c8ec0d2ffa536a00f7f Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:54:43 -0300 Subject: [PATCH 5/8] docs: correct the command line examples and the exit codes `formatCurrency 10 --no-symbol` illustrated `--flag`/`--no-flag` with a flag that changes nothing: `symbol` already defaults to `false`, so the example printed the same `10,00` either way. The row now shows `formatCurrency 10 --symbol` (`R$ 10,00`) and `isBusinessDay 2026-02-17 --no-include-optional` (`true` on Carnaval instead of `false`). The exit code paragraph gains the empty string a formatter answers with, and a new paragraph says that a utility whose first argument is an options object reads stdin only where a `-` asks for it. Deno needs no `-A`: the two CEP lookups are the only utilities that reach the network, so the sentence now states the plain `deno run npm:` form and says the runtime asks for what it needs. --- README.md | 2 +- docs/getting-started.md | 8 +++++--- docs/llms-full.txt | 8 +++++--- docs/pt-br/getting-started.md | 8 +++++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 033592f5c..759917378 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ You can check a list of utilities [by clicking here](https://brazilian-utils.com Every utility also runs from a terminal or a shell script, with no install needed: ```bash -npx @brazilian-utils/brazilian-utils isValidCpf 12345678909 # true (exit code 0; 1 for false or null) +npx @brazilian-utils/brazilian-utils isValidCpf 12345678909 # true (exit code 0; 1 for a negative answer) npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **.345.678/0001-** npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", ... } as JSON echo 01001000 | npx @brazilian-utils/brazilian-utils getAddressInfoByCep # reads stdin, awaits the lookup diff --git a/docs/getting-started.md b/docs/getting-started.md index d8fc231db..5121b8f4d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -76,7 +76,7 @@ npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **. npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", "ispb": "00000000", ... } ``` -`bunx @brazilian-utils/brazilian-utils` and `deno run -A npm:@brazilian-utils/brazilian-utils` run the same command, and once the package is installed in a project it is `npx brazilian-utils` (or plain `brazilian-utils` in a `package.json` script). +`bunx @brazilian-utils/brazilian-utils` runs the same command, and Deno runs it as `deno run npm:@brazilian-utils/brazilian-utils`, asking for the permissions it needs (only the two CEP lookups reach the network). Once the package is installed in a project it is `npx brazilian-utils`, or plain `brazilian-utils` in a `package.json` script. The command is a generic dispatcher over the public API: the first argument is the name of a utility, exactly as it is exported, and the rest maps to its arguments. @@ -84,11 +84,13 @@ The command is a generic dispatcher over the public API: the first argument is t | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | | `isValidIe SP 110042490114` | Positional values, in order: `isValidIe("SP", "110042490114")` | | `--key value`, `--key=value` | An entry of the options (or params) object; `--state-code` and `--stateCode` are the same key | -| `--flag`, `--no-flag` | A boolean option set to `true` or to `false`: `formatCurrency 10 --no-symbol` | +| `--flag`, `--no-flag` | A boolean option set to `true` or to `false`: `formatCurrency 10 --symbol`, `isBusinessDay 2026-02-17 --no-include-optional` | | `--json ''` | The options (or params) object as JSON; options given next to it win | | `-`, or no value in a pipe | The value is read from stdin: `echo 12345678909 \| brazilian-utils formatCpf --obfuscate` | | `--` | Ends the options, so that a value starting with `--` is read as a value | +A utility whose first argument is an options object, such as `isHoliday`, `getHolidays` or `isValidBankAccount`, never takes a value from stdin on its own: it reads stdin only where you write `-`. So `brazilian-utils isHoliday --target-date 2026-09-07` answers the same inside a script whose own stdin is a file or a pipe. + Values stay strings (so `001` keeps its zeros), except where the utility expects a number, a list (comma separated: `--accept cpf,cnpj`) or a date. Dates are written `YYYY-MM-DD`, mean that local calendar day, and are printed the same way: ```bash @@ -98,7 +100,7 @@ brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","ac brazilian-utils getAddressInfoByCep 01001000 # awaits the lookup, then prints the address ``` -Strings and numbers are printed as they are, anything else as JSON. The exit code is `0` on success, `1` when the answer is `false` or `null` or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: +Strings and numbers are printed as they are, anything else as JSON. The exit code is `0` on success, `1` when the answer is negative (`false`, `null`, or the empty string a formatter answers with when it cannot read its value) or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: ```bash if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 2f98e9319..3e699c593 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -226,7 +226,7 @@ npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **. npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", "ispb": "00000000", ... } ``` -`bunx @brazilian-utils/brazilian-utils` and `deno run -A npm:@brazilian-utils/brazilian-utils` run the same command, and once the package is installed in a project it is `npx brazilian-utils` (or plain `brazilian-utils` in a `package.json` script). +`bunx @brazilian-utils/brazilian-utils` runs the same command, and Deno runs it as `deno run npm:@brazilian-utils/brazilian-utils`, asking for the permissions it needs (only the two CEP lookups reach the network). Once the package is installed in a project it is `npx brazilian-utils`, or plain `brazilian-utils` in a `package.json` script. The command is a generic dispatcher over the public API: the first argument is the name of a utility, exactly as it is exported, and the rest maps to its arguments. @@ -234,11 +234,13 @@ The command is a generic dispatcher over the public API: the first argument is t | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | | `isValidIe SP 110042490114` | Positional values, in order: `isValidIe("SP", "110042490114")` | | `--key value`, `--key=value` | An entry of the options (or params) object; `--state-code` and `--stateCode` are the same key | -| `--flag`, `--no-flag` | A boolean option set to `true` or to `false`: `formatCurrency 10 --no-symbol` | +| `--flag`, `--no-flag` | A boolean option set to `true` or to `false`: `formatCurrency 10 --symbol`, `isBusinessDay 2026-02-17 --no-include-optional` | | `--json ''` | The options (or params) object as JSON; options given next to it win | | `-`, or no value in a pipe | The value is read from stdin: `echo 12345678909 \| brazilian-utils formatCpf --obfuscate` | | `--` | Ends the options, so that a value starting with `--` is read as a value | +A utility whose first argument is an options object, such as `isHoliday`, `getHolidays` or `isValidBankAccount`, never takes a value from stdin on its own: it reads stdin only where you write `-`. So `brazilian-utils isHoliday --target-date 2026-09-07` answers the same inside a script whose own stdin is a file or a pipe. + Values stay strings (so `001` keeps its zeros), except where the utility expects a number, a list (comma separated: `--accept cpf,cnpj`) or a date. Dates are written `YYYY-MM-DD`, mean that local calendar day, and are printed the same way: ```bash @@ -248,7 +250,7 @@ brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","ac brazilian-utils getAddressInfoByCep 01001000 # awaits the lookup, then prints the address ``` -Strings and numbers are printed as they are, anything else as JSON. The exit code is `0` on success, `1` when the answer is `false` or `null` or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: +Strings and numbers are printed as they are, anything else as JSON. The exit code is `0` on success, `1` when the answer is negative (`false`, `null`, or the empty string a formatter answers with when it cannot read its value) or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: ```bash if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 32bb7fb48..8693be080 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -76,7 +76,7 @@ npx @brazilian-utils/brazilian-utils formatCnpj 12345678000195 --obfuscate # **. npx @brazilian-utils/brazilian-utils getBankByCode 001 # { "code": "001", "ispb": "00000000", ... } ``` -`bunx @brazilian-utils/brazilian-utils` e `deno run -A npm:@brazilian-utils/brazilian-utils` executam o mesmo comando, e com o pacote instalado no projeto ele vira `npx brazilian-utils` (ou só `brazilian-utils` em um script do `package.json`). +`bunx @brazilian-utils/brazilian-utils` executa o mesmo comando, e no Deno ele é `deno run npm:@brazilian-utils/brazilian-utils`, que pede as permissões necessárias (só as duas consultas de CEP acessam a rede). Com o pacote instalado no projeto ele vira `npx brazilian-utils`, ou só `brazilian-utils` em um script do `package.json`. O comando é um despachante genérico sobre a API pública: o primeiro argumento é o nome de um utilitário, exatamente como ele é exportado, e o restante vira os argumentos dele. @@ -84,11 +84,13 @@ O comando é um despachante genérico sobre a API pública: o primeiro argumento | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | | `isValidIe SP 110042490114` | Valores posicionais, em ordem: `isValidIe("SP", "110042490114")` | | `--key value`, `--key=value` | Uma entrada do objeto de opções (ou de parâmetros); `--state-code` e `--stateCode` são a mesma chave | -| `--flag`, `--no-flag` | Uma opção booleana com valor `true` ou `false`: `formatCurrency 10 --no-symbol` | +| `--flag`, `--no-flag` | Uma opção booleana com valor `true` ou `false`: `formatCurrency 10 --symbol`, `isBusinessDay 2026-02-17 --no-include-optional` | | `--json ''` | O objeto de opções (ou de parâmetros) em JSON; as opções passadas junto com ele têm prioridade | | `-`, ou nenhum valor em pipe | O valor é lido da stdin: `echo 12345678909 \| brazilian-utils formatCpf --obfuscate` | | `--` | Encerra as opções, para que um valor que começa com `--` seja lido como valor | +Um utilitário cujo primeiro argumento é um objeto de opções, como `isHoliday`, `getHolidays` ou `isValidBankAccount`, nunca pega um valor da stdin por conta própria: ele só lê a stdin onde você escreve `-`. Assim `brazilian-utils isHoliday --target-date 2026-09-07` responde a mesma coisa dentro de um script cuja stdin é um arquivo ou um pipe. + Os valores continuam sendo strings (assim `001` mantém os zeros), exceto onde o utilitário espera um número, uma lista (separada por vírgulas: `--accept cpf,cnpj`) ou uma data. Datas são escritas como `YYYY-MM-DD`, representam esse dia do calendário local e são impressas do mesmo jeito: ```bash @@ -98,7 +100,7 @@ brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","ac brazilian-utils getAddressInfoByCep 01001000 # aguarda a consulta e imprime o endereço ``` -Strings e números são impressos como estão, e todo o resto como JSON. O código de saída é `0` em caso de sucesso, `1` quando a resposta é `false` ou `null` ou quando o utilitário lança um erro (que vai para a stderr), e `2` quando a própria linha de comando está errada, então um validador funciona como condição no shell: +Strings e números são impressos como estão, e todo o resto como JSON. O código de saída é `0` em caso de sucesso, `1` quando a resposta é negativa (`false`, `null` ou a string vazia que um formatador devolve quando não consegue ler o valor) ou quando o utilitário lança um erro (que vai para a stderr), e `2` quando a própria linha de comando está errada, então um validador funciona como condição no shell: ```bash if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi From 69f85c08d510c44b7febce40d75b97e8aa3fa173 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:24:11 -0300 Subject: [PATCH 6/8] fix(cli): build a date without depending on the local offset `toDate` started from `new Date(0)`, set the calendar day on it and then zeroed the time with `setHours(0, 0, 0, 0)`. In UTC that call does nothing at all, because the epoch is already local midnight there, so the Stryker job (which runs in UTC) reported the `setHours` to `setMinutes` mutant as survived: the whole statement is dead weight on that machine. It only looked alive here because this machine is UTC-3. The day now comes from the `Date` constructor, which already hands back local midnight with no time fields to clear, and `setFullYear` follows because the constructor reads a year below 100 as 19xx. Behaviour and the rollover check for a day that does not exist are unchanged; the file is back at a 100% mutation score in UTC and in UTC-3. --- src/_cli/coerce-cli-value/coerce-cli-value.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/_cli/coerce-cli-value/coerce-cli-value.ts b/src/_cli/coerce-cli-value/coerce-cli-value.ts index 454f5d0be..ffae9a18f 100644 --- a/src/_cli/coerce-cli-value/coerce-cli-value.ts +++ b/src/_cli/coerce-cli-value/coerce-cli-value.ts @@ -6,11 +6,11 @@ const toDate = (value: string): Date => { const match = ISO_DATE_REGEX.exec(value); if (match === null) return new Date(Number.NaN); + const year = Number(match[1]); const month = Number(match[2]) - 1; - const date = new Date(0); + const date = new Date(year, month, Number(match[3])); - date.setFullYear(Number(match[1]), month, Number(match[3])); - date.setHours(0, 0, 0, 0); + date.setFullYear(year); return date.getMonth() === month ? date : new Date(Number.NaN); }; @@ -34,7 +34,8 @@ const toBoolean = (value: string): boolean | string => { * * A date is written `YYYY-MM-DD` and means that local calendar day, the way the date utilities of * this library read a `Date`. A day that does not exist (`2024-02-30`, month `13`) rolls over into - * another month when it is set, which is how it is told apart and made an invalid `Date`. + * another month when it is built, which is how it is told apart and made an invalid `Date`. The + * year is set again afterwards because the `Date` constructor reads one below 100 as 19xx. * * @param {unknown} value - The argument, as written on the command line or read from `--json`. * @param {CliValueKind | "text"} [kind] - The kind the utility expects. Text stays text without one. From 5ca538b80729903a0052e00bb9ed18a92f7e0c5d Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:33:08 -0300 Subject: [PATCH 7/8] fix(cli): read a leap day of a year below 100 The `Date` constructor reads a year below 100 as 19xx, so `0000-02-29` was first built as 1900-02-29, which does not exist and rolled over to 1 March before `setFullYear` put the year back. The month check then rejected a day the proleptic Gregorian calendar has. The whole day is now set again with `setFullYear(year, month, day)`, and the month check still tells a day that does not exist apart (`0001-02-29` stays invalid). --- src/_cli/coerce-cli-value/coerce-cli-value.test.ts | 8 ++++++++ src/_cli/coerce-cli-value/coerce-cli-value.ts | 8 +++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/_cli/coerce-cli-value/coerce-cli-value.test.ts b/src/_cli/coerce-cli-value/coerce-cli-value.test.ts index ee1fd43cc..a18ae56cf 100644 --- a/src/_cli/coerce-cli-value/coerce-cli-value.test.ts +++ b/src/_cli/coerce-cli-value/coerce-cli-value.test.ts @@ -66,6 +66,14 @@ describe("coerceCliValue", () => { expect([last.getFullYear(), last.getMonth(), last.getDate()]).toEqual([2024, 11, 31]); }); + test("should read a leap day of a year below 100 that 19xx does not have", () => { + const date = coerceCliValue("0000-02-29", "date") as Date; + + expect([date.getFullYear(), date.getMonth(), date.getDate()]).toEqual([0, 1, 29]); + expect(date.getHours()).toBe(0); + expect(Number.isNaN((coerceCliValue("0001-02-29", "date") as Date).getTime())).toBe(true); + }); + test("should turn anything that is not a real YYYY-MM-DD day into an invalid date", () => { for (const text of [ "2024-02-30", diff --git a/src/_cli/coerce-cli-value/coerce-cli-value.ts b/src/_cli/coerce-cli-value/coerce-cli-value.ts index ffae9a18f..64c3116ad 100644 --- a/src/_cli/coerce-cli-value/coerce-cli-value.ts +++ b/src/_cli/coerce-cli-value/coerce-cli-value.ts @@ -8,9 +8,10 @@ const toDate = (value: string): Date => { const year = Number(match[1]); const month = Number(match[2]) - 1; - const date = new Date(year, month, Number(match[3])); + const day = Number(match[3]); + const date = new Date(year, month, day); - date.setFullYear(year); + date.setFullYear(year, month, day); return date.getMonth() === month ? date : new Date(Number.NaN); }; @@ -35,7 +36,8 @@ const toBoolean = (value: string): boolean | string => { * A date is written `YYYY-MM-DD` and means that local calendar day, the way the date utilities of * this library read a `Date`. A day that does not exist (`2024-02-30`, month `13`) rolls over into * another month when it is built, which is how it is told apart and made an invalid `Date`. The - * year is set again afterwards because the `Date` constructor reads one below 100 as 19xx. + * whole day is set again afterwards because the `Date` constructor reads a year below 100 as 19xx, + * where a day such as `0000-02-29` would already have rolled over. * * @param {unknown} value - The argument, as written on the command line or read from `--json`. * @param {CliValueKind | "text"} [kind] - The kind the utility expects. Text stays text without one. From 3bee6e72bb5779557ddb8ff26b4e2c7066c5fc36 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:33:08 -0300 Subject: [PATCH 8/8] docs: say that the command line prints a date as its day The output sentence said that anything but a string or a number is printed as JSON, while `toCliOutput` prints a `Date` as its `YYYY-MM-DD` local day, alone or inside a JSON result (the `addBusinessDays` example just above shows it). --- docs/getting-started.md | 2 +- docs/llms-full.txt | 2 +- docs/pt-br/getting-started.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 5121b8f4d..604cfcb65 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -100,7 +100,7 @@ brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","ac brazilian-utils getAddressInfoByCep 01001000 # awaits the lookup, then prints the address ``` -Strings and numbers are printed as they are, anything else as JSON. The exit code is `0` on success, `1` when the answer is negative (`false`, `null`, or the empty string a formatter answers with when it cannot read its value) or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: +Strings and numbers are printed as they are, a date as its `YYYY-MM-DD` local day (inside JSON too), anything else as JSON. The exit code is `0` on success, `1` when the answer is negative (`false`, `null`, or the empty string a formatter answers with when it cannot read its value) or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: ```bash if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 3e699c593..950a7fbb9 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -250,7 +250,7 @@ brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","ac brazilian-utils getAddressInfoByCep 01001000 # awaits the lookup, then prints the address ``` -Strings and numbers are printed as they are, anything else as JSON. The exit code is `0` on success, `1` when the answer is negative (`false`, `null`, or the empty string a formatter answers with when it cannot read its value) or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: +Strings and numbers are printed as they are, a date as its `YYYY-MM-DD` local day (inside JSON too), anything else as JSON. The exit code is `0` on success, `1` when the answer is negative (`false`, `null`, or the empty string a formatter answers with when it cannot read its value) or the utility throws (the error goes to stderr), and `2` when the command line itself is wrong, so a validator works as a shell condition: ```bash if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 8693be080..c68f0b3ce 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -100,7 +100,7 @@ brazilian-utils isValidBankAccount --json '{"bankCode":"001","agency":"1234","ac brazilian-utils getAddressInfoByCep 01001000 # aguarda a consulta e imprime o endereço ``` -Strings e números são impressos como estão, e todo o resto como JSON. O código de saída é `0` em caso de sucesso, `1` quando a resposta é negativa (`false`, `null` ou a string vazia que um formatador devolve quando não consegue ler o valor) ou quando o utilitário lança um erro (que vai para a stderr), e `2` quando a própria linha de comando está errada, então um validador funciona como condição no shell: +Strings e números são impressos como estão, uma data como o seu dia local `YYYY-MM-DD` (também dentro do JSON), e todo o resto como JSON. O código de saída é `0` em caso de sucesso, `1` quando a resposta é negativa (`false`, `null` ou a string vazia que um formatador devolve quando não consegue ler o valor) ou quando o utilitário lança um erro (que vai para a stderr), e `2` quando a própria linha de comando está errada, então um validador funciona como condição no shell: ```bash if brazilian-utils isValidCnpj "$CNPJ" > /dev/null; then echo "ok"; fi