@maschinenlesbar.org/pegel-online-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 +26 -14
  2. package/dist/src/cli/commands/stations.d.ts +0 -1
  3. package/dist/src/cli/commands/stations.js +6 -6
  4. package/dist/src/cli/commands/timeseries.d.ts +0 -1
  5. package/dist/src/cli/commands/timeseries.js +10 -12
  6. package/dist/src/cli/index.d.ts +0 -1
  7. package/dist/src/cli/index.js +2 -1
  8. package/dist/src/cli/io.d.ts +17 -1
  9. package/dist/src/cli/io.js +22 -1
  10. package/dist/src/cli/program.d.ts +0 -1
  11. package/dist/src/cli/program.js +4 -4
  12. package/dist/src/cli/run.d.ts +0 -1
  13. package/dist/src/cli/run.js +7 -2
  14. package/dist/src/cli/shared.d.ts +25 -14
  15. package/dist/src/cli/shared.js +60 -34
  16. package/dist/src/client/client.d.ts +6 -1
  17. package/dist/src/client/client.js +71 -17
  18. package/dist/src/client/engine.d.ts +60 -6
  19. package/dist/src/client/engine.js +157 -54
  20. package/dist/src/client/errors.d.ts +22 -1
  21. package/dist/src/client/errors.js +49 -4
  22. package/dist/src/client/http.d.ts +0 -1
  23. package/dist/src/client/http.js +3 -4
  24. package/dist/src/client/index.d.ts +4 -3
  25. package/dist/src/client/index.js +3 -3
  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 +36 -6
  29. package/dist/src/client/types.js +0 -1
  30. package/dist/src/client/validate.d.ts +51 -0
  31. package/dist/src/client/validate.js +108 -0
  32. package/dist/src/index.d.ts +0 -1
  33. package/dist/src/index.js +0 -1
  34. package/package.json +2 -1
  35. package/dist/src/cli/commands/stations.d.ts.map +0 -1
  36. package/dist/src/cli/commands/stations.js.map +0 -1
  37. package/dist/src/cli/commands/timeseries.d.ts.map +0 -1
  38. package/dist/src/cli/commands/timeseries.js.map +0 -1
  39. package/dist/src/cli/index.d.ts.map +0 -1
  40. package/dist/src/cli/index.js.map +0 -1
  41. package/dist/src/cli/io.d.ts.map +0 -1
  42. package/dist/src/cli/io.js.map +0 -1
  43. package/dist/src/cli/program.d.ts.map +0 -1
  44. package/dist/src/cli/program.js.map +0 -1
  45. package/dist/src/cli/run.d.ts.map +0 -1
  46. package/dist/src/cli/run.js.map +0 -1
  47. package/dist/src/cli/shared.d.ts.map +0 -1
  48. package/dist/src/cli/shared.js.map +0 -1
  49. package/dist/src/client/client.d.ts.map +0 -1
  50. package/dist/src/client/client.js.map +0 -1
  51. package/dist/src/client/engine.d.ts.map +0 -1
  52. package/dist/src/client/engine.js.map +0 -1
  53. package/dist/src/client/errors.d.ts.map +0 -1
  54. package/dist/src/client/errors.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
@@ -86,16 +86,20 @@ level); other codes include `Q` (flow/discharge), `WT` (water temperature), and
86
86
  | `--waters <shortname>` | filter by water shortname (see `waters`) |
87
87
  | `--fuzzy-id <id>` | fuzzy match against short/long name |
88
88
  | `--include-timeseries` | embed each station's timeseries list |
89
- | `--include-current` | embed the current measurement |
90
- | `--include-characteristic` | embed characteristic (gauge-mark) values |
89
+ | `--include-current` | embed the current measurement in each timeseries (implies `--include-timeseries`) |
90
+ | `--include-characteristic` | embed characteristic (gauge-mark) values in each timeseries (implies `--include-timeseries`) |
91
91
 
92
92
  ### `stations get` options
93
93
 
94
94
  | Flag | Meaning |
95
95
  | --- | --- |
96
96
  | `--include-timeseries` | embed the station's timeseries list |
97
- | `--include-current` | embed the current measurement |
98
- | `--include-characteristic` | embed characteristic (gauge-mark) values |
97
+ | `--include-current` | embed the current measurement in each timeseries (implies `--include-timeseries`) |
98
+ | `--include-characteristic` | embed characteristic (gauge-mark) values in each timeseries (implies `--include-timeseries`) |
99
+
100
+ The API nests the current measurement and the gauge marks *inside* each
101
+ timeseries and drops them without the timeseries list, so `--include-current` and
102
+ `--include-characteristic` turn on `--include-timeseries` themselves.
99
103
 
100
104
  ### `measurements` options
101
105
 
@@ -187,15 +191,23 @@ both `pegel --compact waters` and `pegel waters --compact` do the same thing.
187
191
  `PATH`. Run `npm bin -g` to find it and add it, or run via
188
192
  `npx @maschinenlesbar.org/pegel-online-cli …`.
189
193
  - **Exit `2` / "invalid argument"** — check the command syntax: a `<station>`
190
- argument is required, and `--start` / `--end` must be valid ISO-8601 instants or
191
- periods (e.g. `P7D`). Run `pegel <command> --help` for the exact signature.
192
- - **Exit `4` / "not found"** — the station shortname or id doesn't exist. Run
193
- `pegel stations list --fuzzy-id <name>` or `pegel waters` to find the right
194
- shortname.
194
+ argument is required, and no argument or option value may be blank (or `.` /
195
+ `..` for an id). Run `pegel <command> --help` for the exact signature.
196
+ - **Exit `4` / "not found"** — the station shortname or id doesn't exist, or the
197
+ station doesn't publish the requested series (`Timeseries does not exist.` /
198
+ `Current measurement does not exist.`). Run `pegel stations list --fuzzy-id <name>`
199
+ or `pegel waters` to find the right shortname, and
200
+ `pegel stations get <station> --include-timeseries | jq -r '.timeseries[].shortname'`
201
+ to see which series codes it exposes.
202
+ - **Exit `1` / `HTTP 400` on `measurements`** — `--start` / `--end` are not checked
203
+ locally; the API rejects a value that is not an ISO-8601 instant or period
204
+ (`Given start parameter is neither a valid ISO date time, nor an ISO period.`) and
205
+ a window whose start is not before its end (e.g. a start in the future). Use
206
+ `P7D`, not `7d`.
207
+ - **Empty `[]` from `measurements`** — the window lies outside the data the API
208
+ keeps (about the last month), e.g. a date from earlier in the year.
195
209
  - **Exit `1` / network error** — connectivity, DNS, or a timeout. Try again, or
196
210
  raise the limit with `--timeout 60000`.
197
- - **Empty `timeseries` array** — the station doesn't publish the requested series.
198
- Run `pegel timeseries <station>` to see which codes it actually exposes.
199
211
 
200
212
  ## Global options
201
213
 
@@ -206,10 +218,10 @@ These apply to every command and may be given before *or* after it:
206
218
  | `-V, --version` | Print the version number |
207
219
  | `-h, --help` | Show help for the program or a command |
208
220
  | `--compact` | Print JSON on a single line instead of pretty-printed |
209
- | `--base-url <url>` | API base URL (default `https://www.pegelonline.wsv.de`) |
221
+ | `--base-url <url>` | API base URL (default `https://www.pegelonline.wsv.de`); http(s) only, a path prefix is fine, no query (`?`), fragment (`#`), whitespace or control characters; userinfo is sent as Basic auth but shown as `***` in messages |
210
222
  | `--timeout <ms>` | Time limit per request in milliseconds, reading the whole response included (default `30000`; at most `2147483647`) |
211
- | `--user-agent <ua>` | `User-Agent` header value |
212
- | `--max-retries <n>` | Retries for transient `429`/`503` responses (default `2`) |
223
+ | `--user-agent <ua>` | `User-Agent` header value (not blank; Latin-1, no control characters) |
224
+ | `--max-retries <n>` | Retries for transient `429`/`503` responses, `0`–`10` (default `2`); each waits the server's `Retry-After` (up to 30 s; a longer one is not retried), else 200 ms × attempt |
213
225
  | `--max-response-bytes <n>` | Cap response body size in bytes (`0` = unlimited; default 100 MiB) |
214
226
 
215
227
  ## Learn more
@@ -1,4 +1,3 @@
1
1
  import type { Command } from "commander";
2
2
  import type { CliDeps } from "../io.js";
3
3
  export declare function registerStationCommands(program: Command, deps: CliDeps): void;
4
- //# sourceMappingURL=stations.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { Option } from "commander";
2
- import { action, parseNonEmpty, renderJson, requireArg } from "../shared.js";
2
+ import { STATION_HELP, action, parseNonEmpty, parsePathArg, renderJson } from "../shared.js";
3
3
  /** commander accumulator for a repeatable string option. */
4
4
  function collect(value, previous = []) {
5
5
  return previous.concat([parseNonEmpty(value)]);
@@ -15,8 +15,8 @@ function includesFrom(opts) {
15
15
  function addIncludeOptions(cmd) {
16
16
  return cmd
17
17
  .addOption(new Option("--include-timeseries", "embed each station's timeseries list"))
18
- .addOption(new Option("--include-current", "embed the current measurement"))
19
- .addOption(new Option("--include-characteristic", "embed characteristic (gauge-mark) values"));
18
+ .addOption(new Option("--include-current", "embed the current measurement in each timeseries (implies --include-timeseries)"))
19
+ .addOption(new Option("--include-characteristic", "embed characteristic (gauge-mark) values in each timeseries (implies --include-timeseries)"));
20
20
  }
21
21
  export function registerStationCommands(program, deps) {
22
22
  const stations = program.command("stations").description("Measuring stations");
@@ -36,10 +36,10 @@ export function registerStationCommands(program, deps) {
36
36
  renderJson(deps, global, await client.stations.list(params));
37
37
  }));
38
38
  const get = stations
39
- .command("get <station>")
39
+ .command("get")
40
+ .argument("<station>", STATION_HELP, parsePathArg)
40
41
  .description("Get one station by uuid/number/shortname/longname");
41
42
  addIncludeOptions(get).action(action(deps, async ({ client, global, opts }, [station]) => {
42
- renderJson(deps, global, await client.stations.get(requireArg("station", station), includesFrom(opts)));
43
+ renderJson(deps, global, await client.stations.get(station, includesFrom(opts)));
43
44
  }));
44
45
  }
45
- //# sourceMappingURL=stations.js.map
@@ -1,4 +1,3 @@
1
1
  import type { Command } from "commander";
2
2
  import type { CliDeps } from "../io.js";
3
3
  export declare function registerTimeseriesCommands(program: Command, deps: CliDeps): void;
4
- //# sourceMappingURL=timeseries.d.ts.map
@@ -1,32 +1,31 @@
1
- import { action, parseNonEmpty, renderJson, requireArg, timeseriesOr } from "../shared.js";
2
- const STATION_HELP = "station uuid, number, shortname or longname";
1
+ import { STATION_HELP, action, parseNonEmpty, parsePathArg, renderJson, timeseriesOr } from "../shared.js";
3
2
  const TIMESERIES_HELP = "timeseries shortname, e.g. W (water level) or Q (flow)";
4
3
  export function registerTimeseriesCommands(program, deps) {
5
4
  program
6
5
  .command("timeseries")
7
- .argument("<station>", STATION_HELP)
8
- .argument("[timeseries]", TIMESERIES_HELP, parseNonEmpty)
6
+ .argument("<station>", STATION_HELP, parsePathArg)
7
+ .argument("[timeseries]", TIMESERIES_HELP, parsePathArg)
9
8
  .description("Timeseries metadata (timeseries defaults to 'W' = water level)")
10
9
  .action(action(deps, async ({ client, global }, [station, ts]) => {
11
- renderJson(deps, global, await client.timeseries.get(requireArg("station", station), timeseriesOr(ts)));
10
+ renderJson(deps, global, await client.timeseries.get(station, timeseriesOr(ts)));
12
11
  }));
13
12
  program
14
13
  .command("current")
15
- .argument("<station>", STATION_HELP)
16
- .argument("[timeseries]", TIMESERIES_HELP, parseNonEmpty)
14
+ .argument("<station>", STATION_HELP, parsePathArg)
15
+ .argument("[timeseries]", TIMESERIES_HELP, parsePathArg)
17
16
  .description("The current measurement (timeseries defaults to 'W')")
18
17
  .action(action(deps, async ({ client, global }, [station, ts]) => {
19
- renderJson(deps, global, await client.timeseries.currentMeasurement(requireArg("station", station), timeseriesOr(ts)));
18
+ renderJson(deps, global, await client.timeseries.currentMeasurement(station, timeseriesOr(ts)));
20
19
  }));
21
20
  program
22
21
  .command("measurements")
23
- .argument("<station>", STATION_HELP)
24
- .argument("[timeseries]", TIMESERIES_HELP, parseNonEmpty)
22
+ .argument("<station>", STATION_HELP, parsePathArg)
23
+ .argument("[timeseries]", TIMESERIES_HELP, parsePathArg)
25
24
  .description("A window of measurements (timeseries defaults to 'W')")
26
25
  .option("--start <iso>", "window start: ISO-8601 instant, or a period like P7D", parseNonEmpty)
27
26
  .option("--end <iso>", "window end: ISO-8601 instant", parseNonEmpty)
28
27
  .action(action(deps, async ({ client, global, opts }, [station, ts]) => {
29
- renderJson(deps, global, await client.timeseries.measurements(requireArg("station", station), timeseriesOr(ts), {
28
+ renderJson(deps, global, await client.timeseries.measurements(station, timeseriesOr(ts), {
30
29
  start: opts["start"],
31
30
  end: opts["end"],
32
31
  }));
@@ -38,4 +37,3 @@ export function registerTimeseriesCommands(program, deps) {
38
37
  renderJson(deps, global, await client.waters());
39
38
  }));
40
39
  }
41
- //# sourceMappingURL=timeseries.js.map
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
2
  export {};
3
- //# sourceMappingURL=index.d.ts.map
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Binary entry point. Thin shim around run(); all logic lives in run.ts/program.ts.
3
+ import { handleOutputErrors } from "./io.js";
3
4
  import { run } from "./run.js";
5
+ handleOutputErrors();
4
6
  const exitCode = await run(process.argv.slice(2));
5
7
  process.exitCode = exitCode;
6
- //# sourceMappingURL=index.js.map
@@ -9,5 +9,21 @@ export interface CliDeps {
9
9
  /** Build a client from the resolved global options (injectable for tests). */
10
10
  createClient(options: EngineOptions): PegelOnlineClient;
11
11
  }
12
+ /** The two process streams, as far as `handleOutputErrors` needs them. */
13
+ export interface OutputStreams {
14
+ stdout: Pick<NodeJS.WriteStream, "on">;
15
+ stderr: Pick<NodeJS.WriteStream, "on">;
16
+ }
17
+ /**
18
+ * Handle write errors on stdout/stderr, which Node otherwise reports as an
19
+ * unhandled 'error' event: a raw stack trace and exit 1.
20
+ *
21
+ * A reader that stops early — `| head`, `| jq` exiting on the first match, a closed
22
+ * pager — closes the pipe while the CLI is still writing, and the next write fails
23
+ * with EPIPE. That is ordinary use, so the process exits 0 at once, quietly. Any
24
+ * other stdout error prints one `Output error: <message>` line to stderr and exits
25
+ * 1; any other stderr 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;
12
29
  export declare const defaultIO: CliIO;
13
- //# sourceMappingURL=io.d.ts.map
@@ -1,7 +1,28 @@
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; any other stderr error exits 1 silently (there is nowhere left to report it).
12
+ * The bin shim installs this once, before `run()`.
13
+ */
14
+ export function handleOutputErrors(streams = process, exit = (code) => process.exit(code)) {
15
+ streams.stdout.on("error", (err) => {
16
+ if (err.code === "EPIPE")
17
+ return exit(0);
18
+ process.stderr.write(`Output error: ${err.message}\n`);
19
+ exit(1);
20
+ });
21
+ streams.stderr.on("error", (err) => {
22
+ exit(err.code === "EPIPE" ? 0 : 1);
23
+ });
24
+ }
3
25
  export const defaultIO = {
4
26
  out: (text) => process.stdout.write(text + "\n"),
5
27
  err: (text) => process.stderr.write(text + "\n"),
6
28
  };
7
- //# sourceMappingURL=io.js.map
@@ -4,4 +4,3 @@ export declare const VERSION: string;
4
4
  /** Default dependencies: real client + real stdout/stderr/filesystem. */
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 { PegelOnlineClient } from "../client/client.js";
9
9
  import { MAX_TIMEOUT_MS } from "../client/http.js";
10
- import { parseBoundedInt, parseIntArg, parseBaseUrl } from "./shared.js";
10
+ import { MAX_RETRIES } from "../client/engine.js";
11
+ import { parseBoundedInt, parseIntArg, parseBaseUrl, parseHeaderValue } from "./shared.js";
11
12
  import { registerStationCommands } from "./commands/stations.js";
12
13
  import { registerTimeseriesCommands } from "./commands/timeseries.js";
13
14
  /**
@@ -41,8 +42,8 @@ export function buildProgram(deps = defaultDeps) {
41
42
  .version(VERSION)
42
43
  .option("--base-url <url>", "API base URL", parseBaseUrl, "https://www.pegelonline.wsv.de")
43
44
  .option("--timeout <ms>", "time limit per request in milliseconds, whole response included", parseBoundedInt(0, MAX_TIMEOUT_MS))
44
- .option("--user-agent <ua>", "User-Agent header value")
45
- .option("--max-retries <n>", "retries for transient 429/503 responses", parseIntArg)
45
+ .option("--user-agent <ua>", "User-Agent header value", parseHeaderValue)
46
+ .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, MAX_RETRIES))
46
47
  .option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", parseIntArg)
47
48
  .option("--compact", "print JSON on a single line instead of pretty-printed")
48
49
  .showHelpAfterError();
@@ -50,4 +51,3 @@ export function buildProgram(deps = defaultDeps) {
50
51
  registerTimeseriesCommands(program, deps);
51
52
  return program;
52
53
  }
53
- //# sourceMappingURL=program.js.map
@@ -1,3 +1,2 @@
1
1
  import type { CliDeps } from "./io.js";
2
2
  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 { PegelApiError, PegelError } from "../client/errors.js";
6
+ import { PegelApiError, PegelError, PegelValidationError } from "../client/errors.js";
7
7
  /**
8
8
  * Apply exitOverride + output redirection to every command in the tree.
9
9
  * commander does not propagate these to subcommands, so a parse error on a
@@ -51,6 +51,12 @@ export async function run(argv, deps = defaultDeps) {
51
51
  return 4;
52
52
  return 1;
53
53
  }
54
+ if (err instanceof PegelValidationError) {
55
+ // An input the library rejected before any request: a usage error, the same
56
+ // exit code as commander's own parse errors.
57
+ deps.io.err(`Error: ${err.message}`);
58
+ return USAGE_EXIT;
59
+ }
54
60
  if (err instanceof PegelError) {
55
61
  deps.io.err(`Error: ${err.message}`);
56
62
  return 1;
@@ -59,4 +65,3 @@ export async function run(argv, deps = defaultDeps) {
59
65
  return 1;
60
66
  }
61
67
  }
62
- //# sourceMappingURL=run.js.map
@@ -1,29 +1,41 @@
1
1
  import type { CliDeps } from "./io.js";
2
2
  import type { EngineOptions } from "../client/engine.js";
3
+ /** Help text of every `<station>` positional. */
4
+ export declare const STATION_HELP = "station uuid, number, shortname or longname";
3
5
  /** commander value-parser: a non-negative integer. */
4
6
  export declare function parseIntArg(value: string): number;
5
7
  /** Build a commander value-parser for an integer constrained to [min, max]. */
6
8
  export declare function parseBoundedInt(min: number, max: number): (value: string) => number;
7
9
  /**
8
- * commander value-parser: a value that is not blank. A blank filter would
9
- * otherwise be dropped and the command would silently run unfiltered.
10
+ * commander value-parser: a value that is not blank. The rule is the library's
11
+ * nonEmptyProblem, which the client enforces on every filter value too (the API
12
+ * reads an empty parameter as no filter); here it only turns a blank value into an
13
+ * early usage error.
10
14
  */
11
15
  export declare function parseNonEmpty(value: string): string;
12
16
  /**
13
- * commander value-parser for `--base-url`: reject anything that is not a parseable
14
- * absolute `http:`/`https:` URL at *parse* time, so a bad scheme (`file:`, `ftp:`)
15
- * or malformed URL exits 2 (usage) — consistent with the blueprint — instead of
16
- * surfacing later as a runtime PegelNetworkError (exit 1). The transport still
17
- * enforces the same allowlist as the authoritative egress control; this only moves
18
- * the user-facing rejection earlier and to the correct exit code.
17
+ * commander value-parser for a value that ends up in an HTTP header (`--user-agent`):
18
+ * the library's headerValueProblem rule (not blank, no control character except tab,
19
+ * nothing above U+00FF), which the engine enforces when the client is built; here it
20
+ * only turns a bad value into an early usage error.
19
21
  */
20
- export declare function parseBaseUrl(value: string): string;
22
+ export declare function parseHeaderValue(value: string): string;
23
+ /**
24
+ * commander value-parser for an id that becomes a URL path segment (the
25
+ * `<station>` and `[timeseries]` positionals): not blank (which would build
26
+ * `/stations//W/...`), and not "." / "..", which
27
+ * encodeURIComponent leaves untouched and URL parsing would resolve, sending the
28
+ * request to a different resource. A usage error, before any request.
29
+ */
30
+ export declare function parsePathArg(value: string): string;
21
31
  /**
22
- * Validate a required positional argument: reject an empty/blank value rather
23
- * than forwarding it into the URL path (which would produce a malformed request
24
- * like `/stations//W/...`). Returns the trimmed value.
32
+ * commander value-parser for `--base-url`: the library's baseUrlProblem rule (no
33
+ * whitespace or control characters, an absolute http(s) URL, no query or
34
+ * fragment), reported at *parse* time as a usage error (exit 2). The engine
35
+ * enforces the same rule when the client is built, and the transport still gates
36
+ * the scheme on every hop as the authoritative egress control.
25
37
  */
26
- export declare function requireArg(name: string, value: string | undefined): string;
38
+ export declare function parseBaseUrl(value: string): string;
27
39
  /**
28
40
  * Default an omitted optional `[timeseries]` positional to "W" (water level),
29
41
  * matching the documented default. A blank value never reaches here: the
@@ -65,4 +77,3 @@ export interface ActionContext {
65
77
  * the trailing options object and command instance to recover the positionals.
66
78
  */
67
79
  export declare function action(deps: CliDeps, fn: (ctx: ActionContext, positionals: string[]) => Promise<void>): (...args: unknown[]) => Promise<void>;
68
- //# sourceMappingURL=shared.d.ts.map
@@ -2,6 +2,9 @@
2
2
  // option resolver, and the JSON result renderer.
3
3
  import { InvalidArgumentError } from "commander";
4
4
  import { PegelError } from "../client/errors.js";
5
+ import { baseUrlProblem, headerValueProblem, nonEmptyProblem } from "../client/validate.js";
6
+ /** Help text of every `<station>` positional. */
7
+ export const STATION_HELP = "station uuid, number, shortname or longname";
5
8
  /** commander value-parser: a non-negative integer. */
6
9
  export function parseIntArg(value) {
7
10
  // Require a plain decimal integer. Reject blank/whitespace ("" and " " coerce
@@ -28,52 +31,56 @@ export function parseBoundedInt(min, max) {
28
31
  };
29
32
  }
30
33
  /**
31
- * commander value-parser: a value that is not blank. A blank filter would
32
- * otherwise be dropped and the command would silently run unfiltered.
34
+ * commander value-parser: a value that is not blank. The rule is the library's
35
+ * nonEmptyProblem, which the client enforces on every filter value too (the API
36
+ * reads an empty parameter as no filter); here it only turns a blank value into an
37
+ * early usage error.
33
38
  */
34
39
  export function parseNonEmpty(value) {
35
- if (value.trim() === "") {
36
- throw new InvalidArgumentError("Expected a non-empty value.");
37
- }
40
+ const reason = nonEmptyProblem(value);
41
+ if (reason !== undefined)
42
+ throw new InvalidArgumentError(reason);
38
43
  return value;
39
44
  }
40
45
  /**
41
- * commander value-parser for `--base-url`: reject anything that is not a parseable
42
- * absolute `http:`/`https:` URL at *parse* time, so a bad scheme (`file:`, `ftp:`)
43
- * or malformed URL exits 2 (usage) — consistent with the blueprint — instead of
44
- * surfacing later as a runtime PegelNetworkError (exit 1). The transport still
45
- * enforces the same allowlist as the authoritative egress control; this only moves
46
- * the user-facing rejection earlier and to the correct exit code.
46
+ * commander value-parser for a value that ends up in an HTTP header (`--user-agent`):
47
+ * the library's headerValueProblem rule (not blank, no control character except tab,
48
+ * nothing above U+00FF), which the engine enforces when the client is built; here it
49
+ * only turns a bad value into an early usage error.
47
50
  */
48
- export function parseBaseUrl(value) {
49
- let url;
50
- try {
51
- url = new URL(value);
52
- }
53
- catch {
54
- throw new InvalidArgumentError("Expected an absolute http(s) URL.");
55
- }
56
- if (url.protocol !== "http:" && url.protocol !== "https:") {
57
- throw new InvalidArgumentError("Only http and https URLs are supported.");
58
- }
51
+ export function parseHeaderValue(value) {
52
+ const reason = headerValueProblem(value);
53
+ if (reason !== undefined)
54
+ throw new InvalidArgumentError(reason);
59
55
  return value;
60
56
  }
61
57
  /**
62
- * Validate a required positional argument: reject an empty/blank value rather
63
- * than forwarding it into the URL path (which would produce a malformed request
64
- * like `/stations//W/...`). Returns the trimmed value.
58
+ * commander value-parser for an id that becomes a URL path segment (the
59
+ * `<station>` and `[timeseries]` positionals): not blank (which would build
60
+ * `/stations//W/...`), and not "." / "..", which
61
+ * encodeURIComponent leaves untouched and URL parsing would resolve, sending the
62
+ * request to a different resource. A usage error, before any request.
65
63
  */
66
- export function requireArg(name, value) {
67
- if (value === undefined || value.trim() === "") {
68
- throw new PegelError(`Missing required <${name}> argument.`);
69
- }
70
- // Reject "." / ".." which encodeURIComponent leaves untouched and which would
71
- // otherwise inject a relative path segment into the request URL.
64
+ export function parsePathArg(value) {
65
+ parseNonEmpty(value);
72
66
  if (value === "." || value === "..") {
73
- throw new PegelError(`Invalid <${name}> argument: "${value}".`);
67
+ throw new InvalidArgumentError('"." and ".." cannot be used as an id.');
74
68
  }
75
69
  return value;
76
70
  }
71
+ /**
72
+ * commander value-parser for `--base-url`: the library's baseUrlProblem rule (no
73
+ * whitespace or control characters, an absolute http(s) URL, no query or
74
+ * fragment), reported at *parse* time as a usage error (exit 2). The engine
75
+ * enforces the same rule when the client is built, and the transport still gates
76
+ * the scheme on every hop as the authoritative egress control.
77
+ */
78
+ export function parseBaseUrl(value) {
79
+ const reason = baseUrlProblem(value);
80
+ if (reason !== undefined)
81
+ throw new InvalidArgumentError(reason);
82
+ return value;
83
+ }
77
84
  /**
78
85
  * Default an omitted optional `[timeseries]` positional to "W" (water level),
79
86
  * matching the documented default. A blank value never reaches here: the
@@ -116,9 +123,29 @@ export function escapeControlChars(json) {
116
123
  }
117
124
  return from === 0 ? json : result + json.slice(from);
118
125
  }
126
+ /**
127
+ * JSON.stringify, pretty or compact. A deeply nested value (a hostile or broken
128
+ * response) overflows the stack — the pretty form far sooner than the compact one,
129
+ * which is why the message suggests --compact. The RangeError becomes a PegelError so
130
+ * the CLI prints a clear message instead of "Unexpected error: Maximum call stack
131
+ * size exceeded".
132
+ */
133
+ function stringifyJson(value, compact) {
134
+ try {
135
+ return compact ? JSON.stringify(value) : JSON.stringify(value, null, 2);
136
+ }
137
+ catch (err) {
138
+ if (err instanceof RangeError) {
139
+ throw new PegelError(compact
140
+ ? "The response is nested too deeply to print."
141
+ : "The response is nested too deeply to pretty-print; try --compact.", { cause: err });
142
+ }
143
+ throw err;
144
+ }
145
+ }
119
146
  /** Render a JSON value to stdout, pretty by default, compact with --compact. */
120
147
  export function renderJson(deps, global, value) {
121
- const text = escapeControlChars(global.compact ? JSON.stringify(value) : JSON.stringify(value, null, 2));
148
+ const text = escapeControlChars(stringifyJson(value, global.compact === true));
122
149
  deps.io.out(text);
123
150
  }
124
151
  /**
@@ -138,4 +165,3 @@ export function action(deps, fn) {
138
165
  await fn({ client, global, opts: command.opts() }, positionals);
139
166
  };
140
167
  }
141
- //# sourceMappingURL=shared.js.map
@@ -4,6 +4,11 @@ import type { Station, Water, TimeseriesInfo, CurrentMeasurement, Measurement, S
4
4
  declare class StationsResource {
5
5
  private readonly e;
6
6
  constructor(e: RequestEngine);
7
+ /**
8
+ * Rejects (PegelValidationError, no request) a blank `waters` or `fuzzyId`, a
9
+ * blank `ids` entry and an empty `ids` array: the API reads an empty parameter as
10
+ * no filter and would answer with every station.
11
+ */
7
12
  list(params?: StationListParams): Promise<Station[]>;
8
13
  get(station: string, params?: IncludeParams): Promise<Station>;
9
14
  }
@@ -14,6 +19,7 @@ declare class TimeseriesResource {
14
19
  /** Timeseries metadata (e.g. "W" = water level, "Q" = flow). */
15
20
  get(station: string, timeseries?: string, params?: IncludeParams): Promise<TimeseriesInfo>;
16
21
  currentMeasurement(station: string, timeseries?: string): Promise<CurrentMeasurement>;
22
+ /** Rejects (PegelValidationError, no request) a blank `start` or `end`. */
17
23
  measurements(station: string, timeseries?: string, params?: MeasurementsParams): Promise<Measurement[]>;
18
24
  }
19
25
  export declare class PegelOnlineClient {
@@ -25,4 +31,3 @@ export declare class PegelOnlineClient {
25
31
  waters(): Promise<Water[]>;
26
32
  }
27
33
  export {};
28
- //# sourceMappingURL=client.d.ts.map