@maschinenlesbar.org/marktstammdatenregister-cli 0.0.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +19 -8
  2. package/dist/src/cli/commands/units.d.ts +0 -1
  3. package/dist/src/cli/commands/units.js +30 -24
  4. package/dist/src/cli/index.d.ts +0 -1
  5. package/dist/src/cli/index.js +3 -1
  6. package/dist/src/cli/io.d.ts +18 -1
  7. package/dist/src/cli/io.js +26 -1
  8. package/dist/src/cli/program.d.ts +0 -1
  9. package/dist/src/cli/program.js +7 -7
  10. package/dist/src/cli/run.d.ts +10 -1
  11. package/dist/src/cli/run.js +27 -2
  12. package/dist/src/cli/shared.d.ts +33 -18
  13. package/dist/src/cli/shared.js +49 -58
  14. package/dist/src/client/client.d.ts +46 -7
  15. package/dist/src/client/client.js +143 -14
  16. package/dist/src/client/engine.d.ts +95 -14
  17. package/dist/src/client/engine.js +382 -56
  18. package/dist/src/client/errors.d.ts +18 -2
  19. package/dist/src/client/errors.js +54 -4
  20. package/dist/src/client/filter.d.ts +47 -3
  21. package/dist/src/client/filter.js +184 -5
  22. package/dist/src/client/http.d.ts +13 -1
  23. package/dist/src/client/http.js +58 -33
  24. package/dist/src/client/index.d.ts +6 -5
  25. package/dist/src/client/index.js +5 -5
  26. package/dist/src/client/query.d.ts +0 -1
  27. package/dist/src/client/query.js +0 -1
  28. package/dist/src/client/types.d.ts +4 -2
  29. package/dist/src/client/types.js +0 -1
  30. package/dist/src/client/validate.d.ts +65 -0
  31. package/dist/src/client/validate.js +143 -0
  32. package/dist/src/index.d.ts +0 -1
  33. package/dist/src/index.js +0 -1
  34. package/package.json +4 -3
  35. package/dist/src/cli/commands/units.d.ts.map +0 -1
  36. package/dist/src/cli/commands/units.js.map +0 -1
  37. package/dist/src/cli/index.d.ts.map +0 -1
  38. package/dist/src/cli/index.js.map +0 -1
  39. package/dist/src/cli/io.d.ts.map +0 -1
  40. package/dist/src/cli/io.js.map +0 -1
  41. package/dist/src/cli/program.d.ts.map +0 -1
  42. package/dist/src/cli/program.js.map +0 -1
  43. package/dist/src/cli/run.d.ts.map +0 -1
  44. package/dist/src/cli/run.js.map +0 -1
  45. package/dist/src/cli/shared.d.ts.map +0 -1
  46. package/dist/src/cli/shared.js.map +0 -1
  47. package/dist/src/client/client.d.ts.map +0 -1
  48. package/dist/src/client/client.js.map +0 -1
  49. package/dist/src/client/engine.d.ts.map +0 -1
  50. package/dist/src/client/engine.js.map +0 -1
  51. package/dist/src/client/errors.d.ts.map +0 -1
  52. package/dist/src/client/errors.js.map +0 -1
  53. package/dist/src/client/filter.d.ts.map +0 -1
  54. package/dist/src/client/filter.js.map +0 -1
  55. package/dist/src/client/http.d.ts.map +0 -1
  56. package/dist/src/client/http.js.map +0 -1
  57. package/dist/src/client/index.d.ts.map +0 -1
  58. package/dist/src/client/index.js.map +0 -1
  59. package/dist/src/client/query.d.ts.map +0 -1
  60. package/dist/src/client/query.js.map +0 -1
  61. package/dist/src/client/types.d.ts.map +0 -1
  62. package/dist/src/client/types.js.map +0 -1
  63. package/dist/src/index.d.ts.map +0 -1
  64. package/dist/src/index.js.map +0 -1
package/README.md CHANGED
@@ -27,14 +27,16 @@ npm install -g @maschinenlesbar.org/marktstammdatenregister-cli # the `mastr`
27
27
  npm install @maschinenlesbar.org/marktstammdatenregister-cli
28
28
  ```
29
29
 
30
+ Requires **Node.js 22.12+**.
31
+
30
32
  ## CLI
31
33
 
32
34
  Four datasets, each paged and filterable. Prints `{ total, data }` (or just the
33
35
  count with `--total`).
34
36
 
35
37
  ```bash
36
- mastr stromerzeugung --total # how many electricity-generation units? → 9063887
37
- mastr stromerzeugung --filter "Energieträger~eq~'2495'" --total # …how many are solar? → 6267133
38
+ mastr stromerzeugung --total # how many electricity-generation units? → 9562366 (2026-10-05; it grows)
39
+ mastr stromerzeugung --filter "Energieträger~eq~'2495'" --total # …how many are solar? → 6545851
38
40
  mastr stromerzeugung --sort "Bruttoleistung-desc" --page-size 20 --iso-dates
39
41
  mastr gaserzeugung --page 1 --page-size 50
40
42
  mastr filters stromerzeugung # the filterable columns + dropdown codes
@@ -46,12 +48,15 @@ Categories: `stromerzeugung`, `stromverbrauch`, `gaserzeugung`, `gasverbrauch`.
46
48
  (ops `eq|neq|sw|ct|nct|ew|null|nn`, plus the strict `gt`/`lt` for numbers and dates);
47
49
  there is **no working `~or~`** (the register drops everything after it, so the CLI
48
50
  rejects it) — for several codes of one dropdown, list them in one value:
49
- `Energieträger~eq~'2497,2498'`. Discover the German field names and dropdown codes with `mastr filters <category>`. A
50
- **wrong `FilterName` is silently ignored** (you get the unfiltered set), and a **wrong
51
- `--sort` key returns 0 rows**; a malformed spec or an unknown operator (e.g. `gte`) is
52
- rejected before any request (exit 2), because the register would answer it with a wrong
53
- count. Verify filter names
54
- with `mastr filters` and sanity-check with `--total`; sort keys are record field names
51
+ `Energieträger~eq~'2497,2498'`. Discover the German field names and dropdown codes with `mastr filters <category>`.
52
+ The register silently ignores a FilterName it doesn't know (you'd get the unfiltered set)
53
+ and answers an unknown dropdown code with 0 rows, so the CLI checks every FilterName and
54
+ every `eq`/`neq` dropdown code against the category's columns (one extra request) and
55
+ rejects an unknown one (exit 2, with "did you mean"); a name typed with a decomposed umlaut,
56
+ padding or in another case is sent the register's way. A malformed spec or an unknown
57
+ operator (e.g. `gte`) is rejected too. Several `--filter` flags are joined with `~and~`;
58
+ any other option given twice is a usage error. A **wrong `--sort` key returns 0 rows**:
59
+ sanity-check with `--total`; sort keys are record field names
55
60
  (`Bruttoleistung`), not FilterNames — list them with
56
61
  `mastr stromerzeugung --page-size 1 --compact | jq '.data[0] | keys'`. **Dates** come as Microsoft
57
62
  `/Date(ms)/` strings — add `--iso-dates` to convert them to ISO-8601.
@@ -67,9 +72,15 @@ import { MastrClient, parseMsDate } from "@maschinenlesbar.org/marktstammdatenre
67
72
  const mastr = new MastrClient();
68
73
  const solar = await mastr.stromerzeugung({ filter: "Energieträger~eq~'2495'", pageSize: 10 });
69
74
  solar.total; // total matching units
75
+ await mastr.count("stromerzeugung", { filter: "Energieträger~eq~'2495'" }); // just the count, one-row request
70
76
  parseMsDate(String(solar.data[0]?.EinheitRegistrierungsdatum)); // Date | null
71
77
  ```
72
78
 
79
+ The client checks a filter's FilterNames and dropdown codes against the category's columns
80
+ (`filterColumns()`, fetched once per client and category) and throws `MastrValidationError`
81
+ for one the register would ignore; `new MastrClient({ allowUnknownFilters: true })` skips that
82
+ check and its request. A query key it doesn't know (`filtr`) is refused the same way.
83
+
73
84
  Build filters from user input with `buildFilter`, not string interpolation: a filter
74
85
  value cannot contain `~` (the register splits on it and has no escape), so
75
86
  `` `Ort~eq~'${input}'` `` lets an input like `Münster'~and~Energieträger~eq~'2497` add a
@@ -1,4 +1,3 @@
1
1
  import { type Command } from "commander";
2
2
  import type { CliDeps } from "../io.js";
3
3
  export declare function registerCommands(program: Command, deps: CliDeps): void;
4
- //# sourceMappingURL=units.d.ts.map
@@ -3,7 +3,7 @@
3
3
  // `filters` command to discover the filterable columns and their dropdown codes.
4
4
  import { Argument } from "commander";
5
5
  import { MAX_PAGE, MAX_PAGE_SIZE } from "../../client/client.js";
6
- import { action, parseBoundedInt, parseFilter, parseNonEmpty, renderJson } from "../shared.js";
6
+ import { action, once, parseBoundedInt, parseFilter, parseSort, renderJson } from "../shared.js";
7
7
  // `sortKey` is a record field that exists in that category's rows (live 2026-09-26):
8
8
  // only stromerzeugung rows carry Bruttoleistung/Nettonennleistung, so the stderr hint
9
9
  // must not suggest it for the other three.
@@ -19,21 +19,22 @@ const RUN = {
19
19
  gaserzeugung: (c, q) => c.gaserzeugung(q),
20
20
  gasverbrauch: (c, q) => c.gasverbrauch(q),
21
21
  };
22
+ /** Build a CountQuery (filter and sort) from this command's parsed options. */
23
+ function buildCountQuery(opts) {
24
+ const q = {};
25
+ if (typeof opts["sort"] === "string")
26
+ q.sort = opts["sort"];
27
+ if (typeof opts["filter"] === "string")
28
+ q.filter = opts["filter"];
29
+ return q;
30
+ }
22
31
  /** Build a UnitQuery from this command's parsed options. */
23
32
  function buildQuery(opts) {
24
- const q = {};
33
+ const q = buildCountQuery(opts);
25
34
  if (typeof opts["page"] === "number")
26
35
  q.page = opts["page"];
27
36
  if (typeof opts["pageSize"] === "number")
28
37
  q.pageSize = opts["pageSize"];
29
- if (typeof opts["sort"] === "string")
30
- q.sort = opts["sort"];
31
- if (typeof opts["filter"] === "string")
32
- q.filter = opts["filter"];
33
- // `--total` needs only the match count (returned regardless of page size), so
34
- // request a single row instead of fetching and discarding a full page.
35
- if (opts["total"] === true)
36
- q.pageSize = 1;
37
38
  return q;
38
39
  }
39
40
  export function registerCommands(program, deps) {
@@ -41,16 +42,22 @@ export function registerCommands(program, deps) {
41
42
  program
42
43
  .command(cat.name)
43
44
  .description(cat.desc)
44
- .option("--page <n>", "1-based page number", parseBoundedInt(1, MAX_PAGE), 1)
45
- .option("--page-size <n>", `rows per page (1..${MAX_PAGE_SIZE})`, parseBoundedInt(1, MAX_PAGE_SIZE), 25)
45
+ .option("--page <n>", "1-based page number", once("--page", parseBoundedInt(1, MAX_PAGE)), 1)
46
+ .option("--page-size <n>", `rows per page (1..${MAX_PAGE_SIZE})`, once("--page-size", parseBoundedInt(1, MAX_PAGE_SIZE)), 25)
46
47
  .option("--sort <spec>", "sort: FieldKey-asc | FieldKey-desc, where FieldKey is a record field name, not a " +
47
- `FilterName (e.g. ${cat.sortKey}-desc)`, parseNonEmpty)
48
+ `FilterName (e.g. ${cat.sortKey}-desc)`, once("--sort", parseSort))
48
49
  .option("--filter <spec>", "filter: FilterName~op~'value'~and~… (see `filters`; ops eq|neq|sw|ct|nct|ew|null|nn|gt|lt, " +
49
- "gt/lt strict; null/nn take ''). A malformed spec or unknown op is rejected. No ~or~: " +
50
- "for several dropdown codes use one comma list, e.g. Energieträger~eq~'2497,2498'", parseFilter)
51
- .option("--total", "print only the total match count, not the rows")
50
+ "gt/lt strict; null/nn take ''). A malformed spec, an unknown op, a FilterName the " +
51
+ "category doesn't have or a dropdown code it doesn't list is rejected. Repeatable: " +
52
+ "several --filter are joined with ~and~. No ~or~: for several dropdown codes use one " +
53
+ "comma list, e.g. Energieträger~eq~'2497,2498'", parseFilter)
54
+ .option("--total", "print only the total match count, not the rows (a one-row request; --page and --page-size are ignored)")
52
55
  .action(action(deps, async ({ client, global, opts }) => {
53
- const page = await RUN[cat.name](client, buildQuery(opts));
56
+ // `--total` is the library's count(): a one-row request for the match count.
57
+ const total = opts["total"] === true
58
+ ? await client.count(cat.name, buildCountQuery(opts))
59
+ : undefined;
60
+ const page = total === undefined ? await RUN[cat.name](client, buildQuery(opts)) : { total };
54
61
  // An unknown --sort key or --filter operator makes the server return 0 rows
55
62
  // (not an error), which reads like "no matches". Nudge the user toward the
56
63
  // likely cause. Sort keys are the record's field names (`Bruttoleistung`);
@@ -61,14 +68,14 @@ export function registerCommands(program, deps) {
61
68
  "not the FilterNames from `mastr filters`; list them with " +
62
69
  `\`mastr ${cat.name} --page-size 1 --compact | jq '.data[0] | keys'\`.`);
63
70
  }
64
- // An unknown operator is already a usage error (filterProblem); what is left
65
- // that silently gives 0 rows is a value the register can't match.
71
+ // An unknown operator, FilterName or dropdown code is already a usage error; what
72
+ // is left that silently gives 0 rows is a value the register can't match.
66
73
  if (page.total === 0 && typeof opts["filter"] === "string") {
67
- deps.io.err("Note: 0 results with --filter set. If you expected matches, check the values: a " +
68
- "dropdown takes its code (the Value from `mastr filters`), not its label, decimals " +
69
- "take a point ('4999.999'), and gt/lt are strict.");
74
+ deps.io.err("Note: 0 results with --filter set. If you expected matches, check the values: eq " +
75
+ "on a text column matches the whole text (ct matches a part), decimals take a " +
76
+ "point ('4999.999'), and gt/lt are strict.");
70
77
  }
71
- renderJson(deps, global, opts["total"] === true ? page.total : page);
78
+ renderJson(deps, global, total ?? page);
72
79
  }));
73
80
  }
74
81
  program
@@ -79,4 +86,3 @@ export function registerCommands(program, deps) {
79
86
  renderJson(deps, global, await client.filterColumns(category));
80
87
  }));
81
88
  }
82
- //# sourceMappingURL=units.js.map
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
2
  export {};
3
- //# sourceMappingURL=index.d.ts.map
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // Bin shim: parse argv, run the CLI, and set the process exit code. All real
3
3
  // logic lives in run.ts (testable without spawning a subprocess).
4
+ import { handleOutputErrors } from "./io.js";
4
5
  import { run } from "./run.js";
6
+ // Before run(): a reader that closes the pipe early (`| head`) must not end in a stack trace.
7
+ handleOutputErrors();
5
8
  run(process.argv.slice(2)).then((code) => {
6
9
  process.exitCode = code;
7
10
  }, (err) => {
8
11
  process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
9
12
  process.exitCode = 1;
10
13
  });
11
- //# sourceMappingURL=index.js.map
@@ -8,5 +8,22 @@ export interface CliDeps {
8
8
  /** Build a client from the resolved global options (injectable for tests). */
9
9
  createClient(options: MastrClientOptions): MastrClient;
10
10
  }
11
+ /** The two process streams, as far as `handleOutputErrors` needs them. */
12
+ export interface OutputStreams {
13
+ stdout: Pick<NodeJS.WriteStream, "on">;
14
+ stderr: Pick<NodeJS.WriteStream, "on">;
15
+ }
16
+ /**
17
+ * Handle write errors on stdout/stderr, which Node otherwise reports as an
18
+ * unhandled 'error' event: a raw stack trace and exit 1.
19
+ *
20
+ * A reader that stops early — `| head`, `| jq` exiting on the first match, a closed
21
+ * pager — closes the pipe while the CLI is still writing, and the next write fails
22
+ * with EPIPE. That is ordinary use, so the process exits 0 at once, quietly. Any
23
+ * other stdout error prints one `Output error: <message>` line to stderr and exits
24
+ * 1. On stderr an EPIPE is ignored, so a failed run keeps its exit code; any other stderr
25
+ * error exits 1 silently (there is nowhere left to report it).
26
+ * The bin shim installs this once, before `run()`.
27
+ */
28
+ export declare function handleOutputErrors(streams?: OutputStreams, exit?: (code: number) => void): void;
11
29
  export declare const defaultIO: CliIO;
12
- //# sourceMappingURL=io.d.ts.map
@@ -1,7 +1,32 @@
1
1
  // I/O seam for the CLI. Everything the CLI writes goes through a CliIO object so
2
2
  // tests can capture output instead of hitting the real stdout/stderr.
3
+ /**
4
+ * Handle write errors on stdout/stderr, which Node otherwise reports as an
5
+ * unhandled 'error' event: a raw stack trace and exit 1.
6
+ *
7
+ * A reader that stops early — `| head`, `| jq` exiting on the first match, a closed
8
+ * pager — closes the pipe while the CLI is still writing, and the next write fails
9
+ * with EPIPE. That is ordinary use, so the process exits 0 at once, quietly. Any
10
+ * other stdout error prints one `Output error: <message>` line to stderr and exits
11
+ * 1. On stderr an EPIPE is ignored, so a failed run keeps its exit code; any other stderr
12
+ * error exits 1 silently (there is nowhere left to report it).
13
+ * The bin shim installs this once, before `run()`.
14
+ */
15
+ export function handleOutputErrors(streams = process, exit = (code) => process.exit(code)) {
16
+ streams.stdout.on("error", (err) => {
17
+ if (err.code === "EPIPE")
18
+ return exit(0);
19
+ process.stderr.write(`Output error: ${err.message}\n`);
20
+ exit(1);
21
+ });
22
+ // stderr's reader going away doesn't make a failed run a success: ignore EPIPE there and
23
+ // let the run's own exit code stand (`2>&1 | true` must not turn a usage error into 0).
24
+ streams.stderr.on("error", (err) => {
25
+ if (err.code !== "EPIPE")
26
+ exit(1);
27
+ });
28
+ }
3
29
  export const defaultIO = {
4
30
  out: (text) => process.stdout.write(text + "\n"),
5
31
  err: (text) => process.stderr.write(text + "\n"),
6
32
  };
7
- //# sourceMappingURL=io.js.map
@@ -4,4 +4,3 @@ export declare const VERSION: string;
4
4
  /** Default dependencies: real client + real stdout/stderr. */
5
5
  export declare const defaultDeps: CliDeps;
6
6
  export declare function buildProgram(deps?: CliDeps): Command;
7
- //# sourceMappingURL=program.d.ts.map
@@ -7,7 +7,8 @@ import { Command } from "commander";
7
7
  import { defaultIO } from "./io.js";
8
8
  import { MastrClient } from "../client/client.js";
9
9
  import { MAX_TIMEOUT_MS } from "../client/http.js";
10
- import { parseIntArg, parseBoundedInt, parseHeaderValue, parseBaseUrl } from "./shared.js";
10
+ import { MAX_RETRIES } from "../client/engine.js";
11
+ import { once, parseIntArg, parseBoundedInt, parseHeaderValue, parseBaseUrl } from "./shared.js";
11
12
  import { registerCommands } from "./commands/units.js";
12
13
  /**
13
14
  * Single source of truth for the version: read from package.json at runtime
@@ -41,15 +42,14 @@ export function buildProgram(deps = defaultDeps) {
41
42
  "and narrowed with --filter (see `filters` for the field names and codes). Use " +
42
43
  "--total for just the match count, and --iso-dates to convert /Date(…)/ timestamps.")
43
44
  .version(VERSION)
44
- .option("--base-url <url>", "API base URL (http/https only)", parseBaseUrl, "https://www.marktstammdatenregister.de/MaStR")
45
- .option("--timeout <ms>", "time limit per request in ms, whole response included (0 = no timeout)", parseBoundedInt(0, MAX_TIMEOUT_MS))
46
- .option("--user-agent <ua>", "User-Agent header value", parseHeaderValue)
47
- .option("--max-retries <n>", "retries for transient 429/503 responses (0..10; each waits the server's Retry-After, up to 30 s)", parseBoundedInt(0, 10))
48
- .option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", parseIntArg)
45
+ .option("--base-url <url>", "API base URL (http/https only)", once("--base-url", parseBaseUrl), "https://www.marktstammdatenregister.de/MaStR")
46
+ .option("--timeout <ms>", "time limit per request in ms, whole response included (0 = no timeout)", once("--timeout", parseBoundedInt(0, MAX_TIMEOUT_MS)))
47
+ .option("--user-agent <ua>", "User-Agent header value", once("--user-agent", parseHeaderValue))
48
+ .option("--max-retries <n>", `retries for transient 429/503 responses and reset connections (0..${MAX_RETRIES}; each waits the server's Retry-After, up to 30 s)`, once("--max-retries", parseBoundedInt(0, MAX_RETRIES)))
49
+ .option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", once("--max-response-bytes", parseIntArg))
49
50
  .option("--compact", "print JSON on a single line instead of pretty-printed")
50
51
  .option("--iso-dates", "rewrite MaStR /Date(ms)/ timestamps to ISO-8601")
51
52
  .showHelpAfterError();
52
53
  registerCommands(program, deps);
53
54
  return program;
54
55
  }
55
- //# sourceMappingURL=program.js.map
@@ -1,3 +1,12 @@
1
1
  import type { CliDeps } from "./io.js";
2
+ /**
3
+ * `deps` with an `io` that redacts the credentials of every argument from everything it
4
+ * prints. Commander echoes rejected values in its errors (`option '--base-url <url>'
5
+ * argument '…' is invalid`, an unknown command, a surplus argument), so whatever path a
6
+ * credential takes to stdout or stderr, the exact userinfo (as `credentialsIn` finds it,
7
+ * plus its JSON-quoted form) is replaced by `***`. A pattern alone can't delimit a password
8
+ * with spaces, quotes, `#`, `?` or `/`; the exact strings can. Without credentials the
9
+ * output passes through unchanged.
10
+ */
11
+ export declare function withRedactedOutput(deps: CliDeps, argv: readonly string[]): CliDeps;
2
12
  export declare function run(argv: string[], deps?: CliDeps): Promise<number>;
3
- //# sourceMappingURL=run.d.ts.map
@@ -3,7 +3,7 @@
3
3
  // captured output and exit code without spawning a subprocess.
4
4
  import { CommanderError } from "commander";
5
5
  import { buildProgram, defaultDeps } from "./program.js";
6
- import { MastrApiError, MastrError, MastrNetworkError, MastrValidationError, } from "../client/errors.js";
6
+ import { MastrApiError, MastrError, MastrNetworkError, MastrValidationError, credentialsIn, redactCredentials, } from "../client/errors.js";
7
7
  /**
8
8
  * Process exit codes. Distinct codes let scripts tell apart a usage error, a
9
9
  * missing resource, a transport failure, and a catch-all.
@@ -32,7 +32,33 @@ function configureTree(command, deps) {
32
32
  for (const child of command.commands)
33
33
  configureTree(child, deps);
34
34
  }
35
+ /**
36
+ * `deps` with an `io` that redacts the credentials of every argument from everything it
37
+ * prints. Commander echoes rejected values in its errors (`option '--base-url <url>'
38
+ * argument '…' is invalid`, an unknown command, a surplus argument), so whatever path a
39
+ * credential takes to stdout or stderr, the exact userinfo (as `credentialsIn` finds it,
40
+ * plus its JSON-quoted form) is replaced by `***`. A pattern alone can't delimit a password
41
+ * with spaces, quotes, `#`, `?` or `/`; the exact strings can. Without credentials the
42
+ * output passes through unchanged.
43
+ */
44
+ export function withRedactedOutput(deps, argv) {
45
+ // An `--option=value` token is echoed as its value alone.
46
+ const values = argv.map((token) => token.startsWith("-") && token.includes("=") ? token.slice(token.indexOf("=") + 1) : token);
47
+ const secrets = new Set();
48
+ for (const source of [...argv, ...values]) {
49
+ for (const secret of credentialsIn(source)) {
50
+ secrets.add(secret);
51
+ secrets.add(JSON.stringify(secret).slice(1, -1));
52
+ }
53
+ }
54
+ if (secrets.size === 0)
55
+ return deps;
56
+ const list = [...secrets];
57
+ const redact = (text) => redactCredentials(text, list);
58
+ return { ...deps, io: { out: (text) => deps.io.out(redact(text)), err: (text) => deps.io.err(redact(text)) } };
59
+ }
35
60
  export async function run(argv, deps = defaultDeps) {
61
+ deps = withRedactedOutput(deps, argv);
36
62
  const program = buildProgram(deps);
37
63
  configureTree(program, deps);
38
64
  // A bare invocation (no command) is a help request, not an error: print help
@@ -82,4 +108,3 @@ export async function run(argv, deps = defaultDeps) {
82
108
  return EXIT.OTHER;
83
109
  }
84
110
  }
85
- //# sourceMappingURL=run.js.map
@@ -1,5 +1,6 @@
1
1
  import type { CliDeps } from "./io.js";
2
2
  import type { MastrClientOptions } from "../client/client.js";
3
+ import { type Problem } from "../client/validate.js";
3
4
  /**
4
5
  * commander value-parser: a plain base-10 non-negative integer.
5
6
  *
@@ -8,32 +9,47 @@ import type { MastrClientOptions } from "../client/client.js";
8
9
  * literals (`0x10`, `0b10`, `1e3`), signs, padding and decimals.
9
10
  */
10
11
  export declare function parseIntArg(value: string): number;
11
- /** commander value-parser: a non-empty (after trimming) string. */
12
- export declare function parseNonEmpty(value: string): string;
12
+ /**
13
+ * Build a commander value-parser from a library rule: the rule's reason becomes the
14
+ * usage error, a valid value passes unchanged.
15
+ */
16
+ export declare function parseWith(problem: Problem<string>): (value: string) => string;
17
+ /** commander value-parser: a non-empty (after trimming) string (the library's `nonBlankProblem`). */
18
+ export declare const parseNonEmpty: (value: string) => string;
19
+ /** commander value-parser for `--sort`: the library's `sortProblem` (not blank). */
20
+ export declare const parseSort: (value: string) => string;
13
21
  /**
14
22
  * commander value-parser for `--filter`: non-blank, and not a spec the register would
15
- * misread (see `filterProblem`), so it becomes a usage error before any request.
23
+ * misread (see `filterProblem`, run on the `normalizeFilter`ed spec as the client sends
24
+ * it), so it becomes a usage error before any request. The flag can be repeated: a second
25
+ * `--filter` is joined to the first with `~and~` (it used to replace it silently, and
26
+ * "solar in Bavaria" became "every unit in Bavaria"). The FilterNames and dropdown codes
27
+ * are checked by the client against the category's columns.
16
28
  */
17
- export declare function parseFilter(value: string): string;
29
+ export declare function parseFilter(value: string, previous?: string): string;
18
30
  /**
19
- * commander value-parser for `--base-url`: non-empty and http/https-only.
20
- *
21
- * The transport already rejects any non-http(s) scheme, so this is defence in
22
- * depth — but doing the check at parse time turns a bad scheme (e.g. `file:` /
23
- * `data:` / `ftp:`) into a friendly usage error (exit 2) with the value the user
24
- * actually typed, rather than a network error (exit 6) echoing the fully built
25
- * request URL. The transport check stays the enforcement point for library callers.
31
+ * Wrap a value-parser so its option may be given only once: commander keeps the last of a
32
+ * repeated option and drops the others without a word (`--sort A --sort B` sorted by B,
33
+ * `--page 2 --page 3` fetched page 3). A repeat is a usage error naming the flag. A fresh
34
+ * program is built per `run()`, so the state lives as long as one parse.
35
+ */
36
+ export declare function once<T>(flag: string, parse: (value: string) => T): (value: string) => T;
37
+ /**
38
+ * commander value-parser for `--base-url`: the library's `baseUrlProblem` (non-blank,
39
+ * no whitespace, an absolute http(s) URL without a query or fragment), whose reason
40
+ * becomes a usage error (exit 2) naming the value the user typed. The CLI keeps no
41
+ * rules of its own; the client constructor enforces the same rule for library callers.
26
42
  */
27
- export declare function parseBaseUrl(value: string): string;
43
+ export declare const parseBaseUrl: (value: string) => string;
28
44
  /** Build a commander value-parser for an integer constrained to [min, max]. */
29
45
  export declare function parseBoundedInt(min: number, max: number): (value: string) => number;
30
46
  /**
31
- * commander value-parser for a value that ends up in an HTTP header (User-Agent).
32
- * Rejects control characters — a CR/LF (or other C0/DEL byte) would otherwise reach
33
- * Node's HTTP layer and throw an opaque `ERR_INVALID_CHAR`. Tab (0x09) is allowed;
34
- * checked by char code so the source stays free of control bytes.
47
+ * commander value-parser for a value that ends up in an HTTP header (User-Agent):
48
+ * the library's `headerValueProblem` (not blank, Latin-1 without control
49
+ * characters; tab is fine), whose reason becomes the usage error. The engine runs
50
+ * the same rule on `userAgent`.
35
51
  */
36
- export declare function parseHeaderValue(value: string): string;
52
+ export declare const parseHeaderValue: (value: string) => string;
37
53
  export interface GlobalOptions {
38
54
  baseUrl?: string;
39
55
  timeout?: number;
@@ -74,4 +90,3 @@ export interface ActionContext {
74
90
  * the trailing options object and command instance to recover the positionals.
75
91
  */
76
92
  export declare function action(deps: CliDeps, fn: (ctx: ActionContext, positionals: string[]) => Promise<void>): (...args: unknown[]) => Promise<void>;
77
- //# sourceMappingURL=shared.d.ts.map
@@ -3,8 +3,9 @@
3
3
  import { InvalidArgumentError } from "commander";
4
4
  import { isoifyDates } from "../client/client.js";
5
5
  import { MastrParseError } from "../client/errors.js";
6
- import { filterProblem } from "../client/filter.js";
6
+ import { filterProblem, normalizeFilter } from "../client/filter.js";
7
7
  import { isBidiControl } from "../client/engine.js";
8
+ import { baseUrlProblem, headerValueProblem, nonBlankProblem, sortProblem, } from "../client/validate.js";
8
9
  /**
9
10
  * commander value-parser: a plain base-10 non-negative integer.
10
11
  *
@@ -22,60 +23,59 @@ export function parseIntArg(value) {
22
23
  }
23
24
  return n;
24
25
  }
25
- /** commander value-parser: a non-empty (after trimming) string. */
26
- export function parseNonEmpty(value) {
27
- if (value.trim() === "") {
28
- throw new InvalidArgumentError("Expected a non-empty value.");
29
- }
30
- return value;
26
+ /**
27
+ * Build a commander value-parser from a library rule: the rule's reason becomes the
28
+ * usage error, a valid value passes unchanged.
29
+ */
30
+ export function parseWith(problem) {
31
+ return (value) => {
32
+ const reason = problem(value);
33
+ if (reason !== undefined)
34
+ throw new InvalidArgumentError(reason);
35
+ return value;
36
+ };
31
37
  }
38
+ /** commander value-parser: a non-empty (after trimming) string (the library's `nonBlankProblem`). */
39
+ export const parseNonEmpty = parseWith(nonBlankProblem);
40
+ /** commander value-parser for `--sort`: the library's `sortProblem` (not blank). */
41
+ export const parseSort = parseWith(sortProblem);
32
42
  /**
33
43
  * commander value-parser for `--filter`: non-blank, and not a spec the register would
34
- * misread (see `filterProblem`), so it becomes a usage error before any request.
44
+ * misread (see `filterProblem`, run on the `normalizeFilter`ed spec as the client sends
45
+ * it), so it becomes a usage error before any request. The flag can be repeated: a second
46
+ * `--filter` is joined to the first with `~and~` (it used to replace it silently, and
47
+ * "solar in Bavaria" became "every unit in Bavaria"). The FilterNames and dropdown codes
48
+ * are checked by the client against the category's columns.
35
49
  */
36
- export function parseFilter(value) {
50
+ export function parseFilter(value, previous) {
37
51
  parseNonEmpty(value);
38
- const problem = filterProblem(value);
52
+ const problem = filterProblem(normalizeFilter(value));
39
53
  if (problem !== undefined)
40
54
  throw new InvalidArgumentError(problem);
41
- return value;
55
+ return previous === undefined ? value : `${previous}~and~${value}`;
42
56
  }
43
57
  /**
44
- * commander value-parser for `--base-url`: non-empty and http/https-only.
45
- *
46
- * The transport already rejects any non-http(s) scheme, so this is defence in
47
- * depth — but doing the check at parse time turns a bad scheme (e.g. `file:` /
48
- * `data:` / `ftp:`) into a friendly usage error (exit 2) with the value the user
49
- * actually typed, rather than a network error (exit 6) echoing the fully built
50
- * request URL. The transport check stays the enforcement point for library callers.
58
+ * Wrap a value-parser so its option may be given only once: commander keeps the last of a
59
+ * repeated option and drops the others without a word (`--sort A --sort B` sorted by B,
60
+ * `--page 2 --page 3` fetched page 3). A repeat is a usage error naming the flag. A fresh
61
+ * program is built per `run()`, so the state lives as long as one parse.
51
62
  */
52
- export function parseBaseUrl(value) {
53
- const trimmed = value.trim();
54
- if (trimmed === "") {
55
- throw new InvalidArgumentError("Expected a non-empty value.");
56
- }
57
- let parsed;
58
- try {
59
- parsed = new URL(trimmed);
60
- }
61
- catch {
62
- throw new InvalidArgumentError("Expected an absolute http(s) URL.");
63
- }
64
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
65
- throw new InvalidArgumentError("Only http and https URLs are supported.");
66
- }
67
- // Paths are appended to the base URL as a string, so a query or fragment would
68
- // swallow every request path ("http://h/#f" requests "/" for every command).
69
- if (/[?#]/.test(value)) {
70
- throw new InvalidArgumentError("A base URL cannot have a query (?) or fragment (#).");
71
- }
72
- // new URL() trims surrounding whitespace silently; the raw value is what the
73
- // engine uses, so reject it rather than guess.
74
- if (value !== value.trim()) {
75
- throw new InvalidArgumentError("A base URL cannot have surrounding whitespace.");
76
- }
77
- return value;
63
+ export function once(flag, parse) {
64
+ let seen = false;
65
+ return (value) => {
66
+ if (seen)
67
+ throw new InvalidArgumentError(`${flag} was given more than once; give it once.`);
68
+ seen = true;
69
+ return parse(value);
70
+ };
78
71
  }
72
+ /**
73
+ * commander value-parser for `--base-url`: the library's `baseUrlProblem` (non-blank,
74
+ * no whitespace, an absolute http(s) URL without a query or fragment), whose reason
75
+ * becomes a usage error (exit 2) naming the value the user typed. The CLI keeps no
76
+ * rules of its own; the client constructor enforces the same rule for library callers.
77
+ */
78
+ export const parseBaseUrl = parseWith(baseUrlProblem);
79
79
  /** Build a commander value-parser for an integer constrained to [min, max]. */
80
80
  export function parseBoundedInt(min, max) {
81
81
  return (value) => {
@@ -88,20 +88,12 @@ export function parseBoundedInt(min, max) {
88
88
  };
89
89
  }
90
90
  /**
91
- * commander value-parser for a value that ends up in an HTTP header (User-Agent).
92
- * Rejects control characters — a CR/LF (or other C0/DEL byte) would otherwise reach
93
- * Node's HTTP layer and throw an opaque `ERR_INVALID_CHAR`. Tab (0x09) is allowed;
94
- * checked by char code so the source stays free of control bytes.
91
+ * commander value-parser for a value that ends up in an HTTP header (User-Agent):
92
+ * the library's `headerValueProblem` (not blank, Latin-1 without control
93
+ * characters; tab is fine), whose reason becomes the usage error. The engine runs
94
+ * the same rule on `userAgent`.
95
95
  */
96
- export function parseHeaderValue(value) {
97
- for (let i = 0; i < value.length; i++) {
98
- const c = value.charCodeAt(i);
99
- if ((c < 0x20 && c !== 0x09) || c === 0x7f) {
100
- throw new InvalidArgumentError("Value contains control characters.");
101
- }
102
- }
103
- return value;
104
- }
96
+ export const parseHeaderValue = parseWith(headerValueProblem);
105
97
  /** Translate resolved global CLI options into client options. */
106
98
  export function toEngineOptions(global) {
107
99
  const options = {};
@@ -176,4 +168,3 @@ export function action(deps, fn) {
176
168
  await fn({ client, global, opts: command.opts() }, positionals);
177
169
  };
178
170
  }
179
- //# sourceMappingURL=shared.js.map