@maschinenlesbar.org/pegel-online-cli 0.1.0 → 0.3.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 +29 -8
  2. package/dist/src/cli/commands/stations.d.ts +0 -1
  3. package/dist/src/cli/commands/stations.js +29 -5
  4. package/dist/src/cli/commands/timeseries.d.ts +0 -1
  5. package/dist/src/cli/commands/timeseries.js +3 -4
  6. package/dist/src/cli/index.d.ts +0 -1
  7. package/dist/src/cli/index.js +0 -1
  8. package/dist/src/cli/io.d.ts +2 -2
  9. package/dist/src/cli/io.js +7 -3
  10. package/dist/src/cli/program.d.ts +0 -1
  11. package/dist/src/cli/program.js +6 -7
  12. package/dist/src/cli/run.d.ts +12 -1
  13. package/dist/src/cli/run.js +35 -2
  14. package/dist/src/cli/shared.d.ts +21 -15
  15. package/dist/src/cli/shared.js +39 -48
  16. package/dist/src/client/client.d.ts +46 -1
  17. package/dist/src/client/client.js +152 -27
  18. package/dist/src/client/engine.d.ts +51 -13
  19. package/dist/src/client/engine.js +404 -81
  20. package/dist/src/client/errors.d.ts +38 -2
  21. package/dist/src/client/errors.js +74 -4
  22. package/dist/src/client/http.d.ts +26 -1
  23. package/dist/src/client/http.js +16 -2
  24. package/dist/src/client/index.d.ts +6 -4
  25. package/dist/src/client/index.js +4 -4
  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 +60 -10
  29. package/dist/src/client/types.js +0 -1
  30. package/dist/src/client/validate.d.ts +72 -0
  31. package/dist/src/client/validate.js +155 -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/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
@@ -27,7 +27,7 @@ you can pipe straight into [`jq`](https://jqlang.github.io/jq/).
27
27
  npm i -g @maschinenlesbar.org/pegel-online-cli
28
28
  ```
29
29
 
30
- This installs the **`pegel`** command. Requires **Node.js 20+**.
30
+ This installs the **`pegel`** command. Requires **Node.js 22.12+**.
31
31
 
32
32
  Check it works:
33
33
 
@@ -74,7 +74,10 @@ waters list all bodies of water (Gewässer)
74
74
  ```
75
75
 
76
76
  A `<station>` may be a **uuid**, **number**, **shortname** or **longname** — e.g.
77
- `BONN`, `6302010`, or a full UUID. A `[timeseries]` defaults to **`W`** (water
77
+ `BONN`, `6302010`, or a full UUID. Names are not unique (`NEUSTADT` is a gauge on the
78
+ Leine and one on the Baltic coast), and a lookup by such a name returns one of them
79
+ without a warning; `pegel stations list --ids NEUSTADT` lists both and says so on
80
+ stderr — then use the number or uuid. A `[timeseries]` defaults to **`W`** (water
78
81
  level); other codes include `Q` (flow/discharge), `WT` (water temperature), and
79
82
  `LT` (air temperature) depending on the station.
80
83
 
@@ -101,6 +104,11 @@ The API nests the current measurement and the gauge marks *inside* each
101
104
  timeseries and drops them without the timeseries list, so `--include-current` and
102
105
  `--include-characteristic` turn on `--include-timeseries` themselves.
103
106
 
107
+ The API answers an unknown `--ids` entry by leaving it out, and an unknown `--waters` or
108
+ `--fuzzy-id` with `[]`. The CLI says so on stderr (`Note: --ids "KOELN" matched no
109
+ station; …`) and still exits `0`. Every option but `--ids` takes one value; giving one
110
+ twice is a usage error (exit `2`).
111
+
104
112
  ### `measurements` options
105
113
 
106
114
  | Flag | Meaning |
@@ -149,6 +157,12 @@ pegel stations list --ids BONN --ids KÖLN --ids EMMERICH --include-current
149
157
  Every command prints **pretty JSON to stdout**. Errors and diagnostics go to
150
158
  stderr, so piping stdout into `jq` stays clean.
151
159
 
160
+ A reading's `value` is in the unit of its timeseries (see `pegel timeseries <station>`) —
161
+ `cm` for most water levels, but `m+NN` or `m+PNP` (metres) on canal and reservoir gauges —
162
+ and it is `null` when the gauge sent no value: some gauges report the placeholder `99999`
163
+ instead, which `pegel` turns into `null` so it never reads as a 1 km water level. Drop
164
+ `null` points before a minimum, maximum or trend (`jq 'map(select(.value != null))'`).
165
+
152
166
  ```bash
153
167
  # Water shortnames, one per line
154
168
  pegel waters | jq -r '.[].shortname'
@@ -157,7 +171,7 @@ pegel waters | jq -r '.[].shortname'
157
171
  pegel current BONN | jq '{value, timestamp}'
158
172
 
159
173
  # CSV-ish series for a spreadsheet
160
- pegel measurements BONN W --start P3D | jq -r '.[] | [.timestamp, .value] | @csv'
174
+ pegel measurements BONN W --start P3D | jq -r '.[] | select(.value != null) | [.timestamp, .value] | @csv'
161
175
 
162
176
  # Station names and coordinates on the Rhine (tab-separated)
163
177
  pegel stations list --waters RHEIN | jq -r '.[] | [.shortname, .longitude, .latitude] | @tsv'
@@ -185,10 +199,15 @@ both `pegel --compact waters` and `pegel waters --compact` do the same thing.
185
199
  | `4` | station or resource not found (`404`) |
186
200
  | `1` | any other error (network, timeout, unexpected response) |
187
201
 
202
+ A reader that stops early (`| head`, `| jq` exiting on a match) is ordinary use: the CLI
203
+ stops quietly with exit `0`. A failed run keeps its exit code even when nothing reads
204
+ stderr any more (`2>&1 | head -1`).
205
+
188
206
  ## Troubleshooting
189
207
 
190
208
  - **`command not found: pegel`** — the global npm bin directory isn't on your
191
- `PATH`. Run `npm bin -g` to find it and add it, or run via
209
+ `PATH`. Run `npm prefix -g` to find it (the commands are in its `bin`
210
+ subdirectory) and add that to your `PATH`, or run via
192
211
  `npx @maschinenlesbar.org/pegel-online-cli …`.
193
212
  - **Exit `2` / "invalid argument"** — check the command syntax: a `<station>`
194
213
  argument is required, and no argument or option value may be blank (or `.` /
@@ -206,8 +225,10 @@ both `pegel --compact waters` and `pegel waters --compact` do the same thing.
206
225
  `P7D`, not `7d`.
207
226
  - **Empty `[]` from `measurements`** — the window lies outside the data the API
208
227
  keeps (about the last month), e.g. a date from earlier in the year.
209
- - **Exit `1` / network error** — connectivity, DNS, or a timeout. Try again, or
210
- raise the limit with `--timeout 60000`.
228
+ - **Exit `1` / network error** — connectivity, DNS, or a timeout; the message names
229
+ the request that failed (`GET https://… failed: socket hang up`). Try again, or
230
+ raise the limit with `--timeout 60000`. A body larger than `--max-response-bytes`
231
+ fails the same way and says so.
211
232
 
212
233
  ## Global options
213
234
 
@@ -218,10 +239,10 @@ These apply to every command and may be given before *or* after it:
218
239
  | `-V, --version` | Print the version number |
219
240
  | `-h, --help` | Show help for the program or a command |
220
241
  | `--compact` | Print JSON on a single line instead of pretty-printed |
221
- | `--base-url <url>` | API base URL (default `https://www.pegelonline.wsv.de`); http(s) only, a path prefix is fine, no query (`?`), fragment (`#`) or surrounding whitespace; userinfo is sent as Basic auth but shown as `***` in messages |
242
+ | `--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 (write a literal `%` in it as `%25`) |
222
243
  | `--timeout <ms>` | Time limit per request in milliseconds, reading the whole response included (default `30000`; at most `2147483647`) |
223
244
  | `--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 |
245
+ | `--max-retries <n>` | Retries for transient `429`/`503` responses and reset connections, `0`–`10` (default `2`); each waits 200 ms × attempt, or longer if the server's `Retry-After` asks (up to 30 s; a longer one is not retried, and the error names the wait). A timeout is not retried |
225
246
  | `--max-response-bytes <n>` | Cap response body size in bytes (`0` = unlimited; default 100 MiB) |
226
247
 
227
248
  ## 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,25 @@
1
1
  import { Option } from "commander";
2
- import { STATION_HELP, action, parseNonEmpty, parsePathArg, renderJson } from "../shared.js";
2
+ import { STATION_HELP, action, once, parseNonEmpty, parsePathArg, renderJson } from "../shared.js";
3
+ import { stationListNotes } from "../../client/client.js";
4
+ /** A library note about the listing, worded with the CLI's flag names. */
5
+ function noteText(note) {
6
+ if (note.kind === "ambiguous") {
7
+ const which = note.stations
8
+ .map((s) => `${s.shortname}${s.water !== undefined ? ` on ${s.water}` : ""} (number ${s.number}, uuid ${s.uuid})`)
9
+ .join(" and ");
10
+ return (`Note: ${JSON.stringify(note.name)} names ${note.stations.length} stations: ${which}. ` +
11
+ "A lookup by that name (stations get, timeseries, current, measurements) returns only one of them; use the number or uuid.");
12
+ }
13
+ const value = JSON.stringify(note.value);
14
+ switch (note.filter) {
15
+ case "ids":
16
+ return `Note: --ids ${value} matched no station; the list has only the others. Find the name with --fuzzy-id.`;
17
+ case "waters":
18
+ return `Note: --waters ${value} matched no station; it takes a water shortname as \`pegel waters\` lists it (e.g. RHEIN).`;
19
+ case "fuzzyId":
20
+ return `Note: --fuzzy-id ${value} matched no station; it is matched literally, umlauts included (köln, not koeln).`;
21
+ }
22
+ }
3
23
  /** commander accumulator for a repeatable string option. */
4
24
  function collect(value, previous = []) {
5
25
  return previous.concat([parseNonEmpty(value)]);
@@ -24,8 +44,8 @@ export function registerStationCommands(program, deps) {
24
44
  .command("list")
25
45
  .description("List/filter stations")
26
46
  .option("--ids <id>", "station id (uuid/number/shortname/longname); repeatable", collect)
27
- .option("--waters <shortname>", "filter by water shortname (see `waters`)", parseNonEmpty)
28
- .option("--fuzzy-id <id>", "fuzzy id match", parseNonEmpty);
47
+ .option("--waters <shortname>", "filter by water shortname (see `waters`)", once("--waters", parseNonEmpty))
48
+ .option("--fuzzy-id <id>", "fuzzy id match", once("--fuzzy-id", parseNonEmpty));
29
49
  addIncludeOptions(list).action(action(deps, async ({ client, global, opts }) => {
30
50
  const params = {
31
51
  ids: opts["ids"],
@@ -33,7 +53,12 @@ export function registerStationCommands(program, deps) {
33
53
  fuzzyId: opts["fuzzyId"],
34
54
  ...includesFrom(opts),
35
55
  };
36
- renderJson(deps, global, await client.stations.list(params));
56
+ const stations = await client.stations.list(params);
57
+ renderJson(deps, global, stations);
58
+ // Filter values the API matched nothing for: still exit 0 (the answer is valid),
59
+ // but say so on stderr rather than silently printing fewer stations or [].
60
+ for (const note of stationListNotes(params, stations))
61
+ deps.io.err(noteText(note));
37
62
  }));
38
63
  const get = stations
39
64
  .command("get")
@@ -43,4 +68,3 @@ export function registerStationCommands(program, deps) {
43
68
  renderJson(deps, global, await client.stations.get(station, includesFrom(opts)));
44
69
  }));
45
70
  }
46
- //# 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,4 +1,4 @@
1
- import { STATION_HELP, action, parseNonEmpty, parsePathArg, renderJson, timeseriesOr } from "../shared.js";
1
+ import { STATION_HELP, action, once, parseNonEmpty, parsePathArg, renderJson, timeseriesOr } from "../shared.js";
2
2
  const TIMESERIES_HELP = "timeseries shortname, e.g. W (water level) or Q (flow)";
3
3
  export function registerTimeseriesCommands(program, deps) {
4
4
  program
@@ -22,8 +22,8 @@ export function registerTimeseriesCommands(program, deps) {
22
22
  .argument("<station>", STATION_HELP, parsePathArg)
23
23
  .argument("[timeseries]", TIMESERIES_HELP, parsePathArg)
24
24
  .description("A window of measurements (timeseries defaults to 'W')")
25
- .option("--start <iso>", "window start: ISO-8601 instant, or a period like P7D", parseNonEmpty)
26
- .option("--end <iso>", "window end: ISO-8601 instant", parseNonEmpty)
25
+ .option("--start <iso>", "window start: ISO-8601 instant, or a period like P7D", once("--start", parseNonEmpty))
26
+ .option("--end <iso>", "window end: ISO-8601 instant", once("--end", parseNonEmpty))
27
27
  .action(action(deps, async ({ client, global, opts }, [station, ts]) => {
28
28
  renderJson(deps, global, await client.timeseries.measurements(station, timeseriesOr(ts), {
29
29
  start: opts["start"],
@@ -37,4 +37,3 @@ export function registerTimeseriesCommands(program, deps) {
37
37
  renderJson(deps, global, await client.waters());
38
38
  }));
39
39
  }
40
- //# sourceMappingURL=timeseries.js.map
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
2
  export {};
3
- //# sourceMappingURL=index.d.ts.map
@@ -5,4 +5,3 @@ import { run } from "./run.js";
5
5
  handleOutputErrors();
6
6
  const exitCode = await run(process.argv.slice(2));
7
7
  process.exitCode = exitCode;
8
- //# sourceMappingURL=index.js.map
@@ -22,9 +22,9 @@ export interface OutputStreams {
22
22
  * pager — closes the pipe while the CLI is still writing, and the next write fails
23
23
  * with EPIPE. That is ordinary use, so the process exits 0 at once, quietly. Any
24
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).
25
+ * 1. On stderr an EPIPE is ignored, so a failed run keeps its exit code; any other
26
+ * stderr error exits 1 silently (there is nowhere left to report it).
26
27
  * The bin shim installs this once, before `run()`.
27
28
  */
28
29
  export declare function handleOutputErrors(streams?: OutputStreams, exit?: (code: number) => void): void;
29
30
  export declare const defaultIO: CliIO;
30
- //# sourceMappingURL=io.d.ts.map
@@ -8,7 +8,8 @@
8
8
  * pager — closes the pipe while the CLI is still writing, and the next write fails
9
9
  * with EPIPE. That is ordinary use, so the process exits 0 at once, quietly. Any
10
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).
11
+ * 1. On stderr an EPIPE is ignored, so a failed run keeps its exit code; any other
12
+ * stderr error exits 1 silently (there is nowhere left to report it).
12
13
  * The bin shim installs this once, before `run()`.
13
14
  */
14
15
  export function handleOutputErrors(streams = process, exit = (code) => process.exit(code)) {
@@ -18,12 +19,15 @@ export function handleOutputErrors(streams = process, exit = (code) => process.e
18
19
  process.stderr.write(`Output error: ${err.message}\n`);
19
20
  exit(1);
20
21
  });
22
+ // stderr's reader going away doesn't make a failed run a success: ignore EPIPE there
23
+ // and let the run's own exit code stand (`2>&1 | head -1` used to turn a usage error
24
+ // into 0).
21
25
  streams.stderr.on("error", (err) => {
22
- exit(err.code === "EPIPE" ? 0 : 1);
26
+ if (err.code !== "EPIPE")
27
+ exit(1);
23
28
  });
24
29
  }
25
30
  export const defaultIO = {
26
31
  out: (text) => process.stdout.write(text + "\n"),
27
32
  err: (text) => process.stderr.write(text + "\n"),
28
33
  };
29
- //# 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
@@ -8,7 +8,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
10
  import { MAX_RETRIES } from "../client/engine.js";
11
- import { parseBoundedInt, parseIntArg, parseBaseUrl, parseHeaderValue } from "./shared.js";
11
+ import { once, parseBoundedInt, parseIntArg, parseBaseUrl, parseHeaderValue } from "./shared.js";
12
12
  import { registerStationCommands } from "./commands/stations.js";
13
13
  import { registerTimeseriesCommands } from "./commands/timeseries.js";
14
14
  /**
@@ -40,15 +40,14 @@ export function buildProgram(deps = defaultDeps) {
40
40
  .description("CLI for the open PEGELONLINE water-level REST API " +
41
41
  "(https://www.pegelonline.wsv.de/webservices/rest-api/v2)")
42
42
  .version(VERSION)
43
- .option("--base-url <url>", "API base URL", parseBaseUrl, "https://www.pegelonline.wsv.de")
44
- .option("--timeout <ms>", "time limit per request in milliseconds, whole response included", parseBoundedInt(0, MAX_TIMEOUT_MS))
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))
47
- .option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", parseIntArg)
43
+ .option("--base-url <url>", "API base URL", once("--base-url", parseBaseUrl), "https://www.pegelonline.wsv.de")
44
+ .option("--timeout <ms>", "time limit per request in milliseconds, whole response included", once("--timeout", parseBoundedInt(0, MAX_TIMEOUT_MS)))
45
+ .option("--user-agent <ua>", "User-Agent header value", once("--user-agent", parseHeaderValue))
46
+ .option("--max-retries <n>", "retries for transient 429/503 responses and reset connections (0..10; each waits the server's Retry-After, up to 30 s)", once("--max-retries", parseBoundedInt(0, MAX_RETRIES)))
47
+ .option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", once("--max-response-bytes", parseIntArg))
48
48
  .option("--compact", "print JSON on a single line instead of pretty-printed")
49
49
  .showHelpAfterError();
50
50
  registerStationCommands(program, deps);
51
51
  registerTimeseriesCommands(program, deps);
52
52
  return program;
53
53
  }
54
- //# sourceMappingURL=program.js.map
@@ -1,3 +1,14 @@
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 (`argument '<value>' is
5
+ * invalid`), names unknown commands and options, and a URL typed where a station id
6
+ * belongs ends up in an error message: whatever path a credential takes to stdout or
7
+ * stderr, its exact userinfo (as `credentialsIn` finds it, plus its JSON-quoted form) is
8
+ * replaced by `***`. A pattern alone can't delimit a password with spaces, quotes, `#`,
9
+ * `?` or `/`; the exact strings can. Without credentials the output passes through
10
+ * unchanged. (This CLI reads no environment variable, so the arguments are the only
11
+ * source.)
12
+ */
13
+ export declare function withRedactedOutput(deps: CliDeps, argv: readonly string[]): CliDeps;
2
14
  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, credentialsIn, redactCredentials, } 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
@@ -21,7 +21,35 @@ function configureTree(command, deps) {
21
21
  /** Distinct exit code for usage/parse errors, so scripts can tell a user mistake
22
22
  * apart from a runtime/network failure (which exit 1). */
23
23
  const USAGE_EXIT = 2;
24
+ /**
25
+ * `deps` with an `io` that redacts the credentials of every argument from everything it
26
+ * prints. Commander echoes rejected values in its errors (`argument '<value>' is
27
+ * invalid`), names unknown commands and options, and a URL typed where a station id
28
+ * belongs ends up in an error message: whatever path a credential takes to stdout or
29
+ * stderr, its exact userinfo (as `credentialsIn` finds it, plus its JSON-quoted form) is
30
+ * replaced by `***`. A pattern alone can't delimit a password with spaces, quotes, `#`,
31
+ * `?` or `/`; the exact strings can. Without credentials the output passes through
32
+ * unchanged. (This CLI reads no environment variable, so the arguments are the only
33
+ * source.)
34
+ */
35
+ export function withRedactedOutput(deps, argv) {
36
+ // An `--option=value` token is echoed as its value alone.
37
+ const values = argv.map((token) => token.startsWith("-") && token.includes("=") ? token.slice(token.indexOf("=") + 1) : token);
38
+ const secrets = new Set();
39
+ for (const source of [...argv, ...values]) {
40
+ for (const secret of credentialsIn(source)) {
41
+ secrets.add(secret);
42
+ secrets.add(JSON.stringify(secret).slice(1, -1));
43
+ }
44
+ }
45
+ if (secrets.size === 0)
46
+ return deps;
47
+ const list = [...secrets];
48
+ const redact = (text) => redactCredentials(text, list);
49
+ return { ...deps, io: { out: (text) => deps.io.out(redact(text)), err: (text) => deps.io.err(redact(text)) } };
50
+ }
24
51
  export async function run(argv, deps = defaultDeps) {
52
+ deps = withRedactedOutput(deps, argv);
25
53
  const program = buildProgram(deps);
26
54
  configureTree(program, deps);
27
55
  // A bare invocation with no command should show help on stdout and exit 0,
@@ -51,6 +79,12 @@ export async function run(argv, deps = defaultDeps) {
51
79
  return 4;
52
80
  return 1;
53
81
  }
82
+ if (err instanceof PegelValidationError) {
83
+ // An input the library rejected before any request: a usage error, the same
84
+ // exit code as commander's own parse errors.
85
+ deps.io.err(`Error: ${err.message}`);
86
+ return USAGE_EXIT;
87
+ }
54
88
  if (err instanceof PegelError) {
55
89
  deps.io.err(`Error: ${err.message}`);
56
90
  return 1;
@@ -59,4 +93,3 @@ export async function run(argv, deps = defaultDeps) {
59
93
  return 1;
60
94
  }
61
95
  }
62
- //# sourceMappingURL=run.js.map
@@ -7,17 +7,25 @@ export declare function parseIntArg(value: string): number;
7
7
  /** Build a commander value-parser for an integer constrained to [min, max]. */
8
8
  export declare function parseBoundedInt(min: number, max: number): (value: string) => number;
9
9
  /**
10
- * commander value-parser: a value that is not blank. A blank filter would
11
- * otherwise be dropped and the command would silently run unfiltered.
10
+ * Wrap a commander value-parser so its option may be given only once. Commander keeps
11
+ * the last of a repeated single-value option silently: `--waters ELBE --waters RHEIN`
12
+ * listed the Rhine only, `--start P7D --start P1D` fetched one day. A repetition is a
13
+ * usage error instead. The program is built anew for every run, so the flag starts
14
+ * fresh each time.
15
+ */
16
+ export declare function once<T>(flag: string, parser: (value: string) => T): (value: string) => T;
17
+ /**
18
+ * commander value-parser: a value that is not blank. The rule is the library's
19
+ * nonEmptyProblem, which the client enforces on every filter value too (the API
20
+ * reads an empty parameter as no filter); here it only turns a blank value into an
21
+ * early usage error.
12
22
  */
13
23
  export declare function parseNonEmpty(value: string): string;
14
24
  /**
15
- * commander value-parser for a value that ends up in an HTTP header (`--user-agent`).
16
- * Node's HTTP layer throws an opaque "Invalid character in header content" at request
17
- * time for a CR/LF (or any other C0 control or DEL) and for any character above
18
- * U+00FF, which surfaced as "Unexpected error". Reject those here as a usage error,
19
- * along with a blank value. Tab is allowed, as in HTTP. Checked by char code so the
20
- * source stays free of control bytes.
25
+ * commander value-parser for a value that ends up in an HTTP header (`--user-agent`):
26
+ * the library's headerValueProblem rule (not blank, no control character except tab,
27
+ * nothing above U+00FF), which the engine enforces when the client is built; here it
28
+ * only turns a bad value into an early usage error.
21
29
  */
22
30
  export declare function parseHeaderValue(value: string): string;
23
31
  /**
@@ -29,12 +37,11 @@ export declare function parseHeaderValue(value: string): string;
29
37
  */
30
38
  export declare function parsePathArg(value: string): string;
31
39
  /**
32
- * commander value-parser for `--base-url`: reject anything that is not a parseable
33
- * absolute `http:`/`https:` URL at *parse* time, so a bad scheme (`file:`, `ftp:`)
34
- * or malformed URL exits 2 (usage) — consistent with the blueprint — instead of
35
- * surfacing later as a runtime PegelNetworkError (exit 1). The transport still
36
- * enforces the same allowlist as the authoritative egress control; this only moves
37
- * the user-facing rejection earlier and to the correct exit code.
40
+ * commander value-parser for `--base-url`: the library's baseUrlProblem rule (no
41
+ * whitespace or control characters, an absolute http(s) URL, no query or
42
+ * fragment), reported at *parse* time as a usage error (exit 2). The engine
43
+ * enforces the same rule when the client is built, and the transport still gates
44
+ * the scheme on every hop as the authoritative egress control.
38
45
  */
39
46
  export declare function parseBaseUrl(value: string): string;
40
47
  /**
@@ -78,4 +85,3 @@ export interface ActionContext {
78
85
  * the trailing options object and command instance to recover the positionals.
79
86
  */
80
87
  export declare function action(deps: CliDeps, fn: (ctx: ActionContext, positionals: string[]) => Promise<void>): (...args: unknown[]) => Promise<void>;
81
- //# sourceMappingURL=shared.d.ts.map
@@ -2,6 +2,7 @@
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";
5
6
  /** Help text of every `<station>` positional. */
6
7
  export const STATION_HELP = "station uuid, number, shortname or longname";
7
8
  /** commander value-parser: a non-negative integer. */
@@ -30,34 +31,43 @@ export function parseBoundedInt(min, max) {
30
31
  };
31
32
  }
32
33
  /**
33
- * commander value-parser: a value that is not blank. A blank filter would
34
- * otherwise be dropped and the command would silently run unfiltered.
34
+ * Wrap a commander value-parser so its option may be given only once. Commander keeps
35
+ * the last of a repeated single-value option silently: `--waters ELBE --waters RHEIN`
36
+ * listed the Rhine only, `--start P7D --start P1D` fetched one day. A repetition is a
37
+ * usage error instead. The program is built anew for every run, so the flag starts
38
+ * fresh each time.
39
+ */
40
+ export function once(flag, parser) {
41
+ let seen = false;
42
+ return (value) => {
43
+ if (seen)
44
+ throw new InvalidArgumentError(`${flag} may be given only once.`);
45
+ seen = true;
46
+ return parser(value);
47
+ };
48
+ }
49
+ /**
50
+ * commander value-parser: a value that is not blank. The rule is the library's
51
+ * nonEmptyProblem, which the client enforces on every filter value too (the API
52
+ * reads an empty parameter as no filter); here it only turns a blank value into an
53
+ * early usage error.
35
54
  */
36
55
  export function parseNonEmpty(value) {
37
- if (value.trim() === "") {
38
- throw new InvalidArgumentError("Expected a non-empty value.");
39
- }
56
+ const reason = nonEmptyProblem(value);
57
+ if (reason !== undefined)
58
+ throw new InvalidArgumentError(reason);
40
59
  return value;
41
60
  }
42
61
  /**
43
- * commander value-parser for a value that ends up in an HTTP header (`--user-agent`).
44
- * Node's HTTP layer throws an opaque "Invalid character in header content" at request
45
- * time for a CR/LF (or any other C0 control or DEL) and for any character above
46
- * U+00FF, which surfaced as "Unexpected error". Reject those here as a usage error,
47
- * along with a blank value. Tab is allowed, as in HTTP. Checked by char code so the
48
- * source stays free of control bytes.
62
+ * commander value-parser for a value that ends up in an HTTP header (`--user-agent`):
63
+ * the library's headerValueProblem rule (not blank, no control character except tab,
64
+ * nothing above U+00FF), which the engine enforces when the client is built; here it
65
+ * only turns a bad value into an early usage error.
49
66
  */
50
67
  export function parseHeaderValue(value) {
51
- parseNonEmpty(value);
52
- for (let i = 0; i < value.length; i++) {
53
- const c = value.charCodeAt(i);
54
- if ((c < 0x20 && c !== 0x09) || c === 0x7f) {
55
- throw new InvalidArgumentError("Value contains control characters.");
56
- }
57
- if (c > 0xff) {
58
- throw new InvalidArgumentError("Value contains characters outside Latin-1 (above U+00FF).");
59
- }
60
- }
68
+ const reason = headerValueProblem(value);
69
+ if (reason !== undefined)
70
+ throw new InvalidArgumentError(reason);
61
71
  return value;
62
72
  }
63
73
  /**
@@ -75,34 +85,16 @@ export function parsePathArg(value) {
75
85
  return value;
76
86
  }
77
87
  /**
78
- * commander value-parser for `--base-url`: reject anything that is not a parseable
79
- * absolute `http:`/`https:` URL at *parse* time, so a bad scheme (`file:`, `ftp:`)
80
- * or malformed URL exits 2 (usage) — consistent with the blueprint — instead of
81
- * surfacing later as a runtime PegelNetworkError (exit 1). The transport still
82
- * enforces the same allowlist as the authoritative egress control; this only moves
83
- * the user-facing rejection earlier and to the correct exit code.
88
+ * commander value-parser for `--base-url`: the library's baseUrlProblem rule (no
89
+ * whitespace or control characters, an absolute http(s) URL, no query or
90
+ * fragment), reported at *parse* time as a usage error (exit 2). The engine
91
+ * enforces the same rule when the client is built, and the transport still gates
92
+ * the scheme on every hop as the authoritative egress control.
84
93
  */
85
94
  export function parseBaseUrl(value) {
86
- let url;
87
- try {
88
- url = new URL(value);
89
- }
90
- catch {
91
- throw new InvalidArgumentError("Expected an absolute http(s) URL.");
92
- }
93
- if (url.protocol !== "http:" && url.protocol !== "https:") {
94
- throw new InvalidArgumentError("Only http and https URLs are supported.");
95
- }
96
- // Paths are appended to the base URL as a string, so a query or fragment would
97
- // swallow every request path ("http://h/#f" requests "/" for every command).
98
- if (/[?#]/.test(value)) {
99
- throw new InvalidArgumentError("A base URL cannot have a query (?) or fragment (#).");
100
- }
101
- // new URL() trims surrounding whitespace silently; the raw value is what the
102
- // engine uses, so reject it rather than guess.
103
- if (value !== value.trim()) {
104
- throw new InvalidArgumentError("A base URL cannot have surrounding whitespace.");
105
- }
95
+ const reason = baseUrlProblem(value);
96
+ if (reason !== undefined)
97
+ throw new InvalidArgumentError(reason);
106
98
  return value;
107
99
  }
108
100
  /**
@@ -189,4 +181,3 @@ export function action(deps, fn) {
189
181
  await fn({ client, global, opts: command.opts() }, positionals);
190
182
  };
191
183
  }
192
- //# sourceMappingURL=shared.js.map
@@ -1,9 +1,21 @@
1
1
  import { RequestEngine, type EngineOptions } from "./engine.js";
2
2
  import type { Station, Water, TimeseriesInfo, CurrentMeasurement, Measurement, StationListParams, IncludeParams, MeasurementsParams } from "./types.js";
3
+ /**
4
+ * The value PEGELONLINE relays for "no reading" on some gauges (seen on the
5
+ * Rijkswaterstaat gauge PANNERDENSE KOP: `99999` cm, interleaved with real readings of
6
+ * 576–597 cm in a measurement window). As a number it read as a 1 km water level and
7
+ * broke every minimum, maximum, trend and map built on it.
8
+ */
9
+ export declare const NO_VALUE_SENTINEL = 99999;
3
10
  /** Stations: list with filters, or fetch one by uuid/number/shortname/longname. */
4
11
  declare class StationsResource {
5
12
  private readonly e;
6
13
  constructor(e: RequestEngine);
14
+ /**
15
+ * Rejects (PegelValidationError, no request) a blank `waters` or `fuzzyId`, a
16
+ * blank `ids` entry and an empty `ids` array: the API reads an empty parameter as
17
+ * no filter and would answer with every station.
18
+ */
7
19
  list(params?: StationListParams): Promise<Station[]>;
8
20
  get(station: string, params?: IncludeParams): Promise<Station>;
9
21
  }
@@ -14,8 +26,42 @@ declare class TimeseriesResource {
14
26
  /** Timeseries metadata (e.g. "W" = water level, "Q" = flow). */
15
27
  get(station: string, timeseries?: string, params?: IncludeParams): Promise<TimeseriesInfo>;
16
28
  currentMeasurement(station: string, timeseries?: string): Promise<CurrentMeasurement>;
29
+ /** Rejects (PegelValidationError, no request) a blank `start` or `end`. */
17
30
  measurements(station: string, timeseries?: string, params?: MeasurementsParams): Promise<Measurement[]>;
18
31
  }
32
+ /**
33
+ * What {@link stationListNotes} reports about a `stations.list` result: a filter value
34
+ * that matched no station, or a name that more than one returned station carries.
35
+ */
36
+ export type StationListNote = {
37
+ kind: "unmatched";
38
+ filter: "ids" | "waters" | "fuzzyId";
39
+ value: string;
40
+ } | {
41
+ kind: "ambiguous";
42
+ /** The shortname two or more returned stations share (e.g. "NEUSTADT"). */
43
+ name: string;
44
+ /** Those stations, to pick one by its unambiguous uuid or number. */
45
+ stations: Array<Pick<Station, "uuid" | "number" | "shortname" | "longname"> & {
46
+ water?: string;
47
+ }>;
48
+ };
49
+ /**
50
+ * The filter values of a `stations.list` call that matched nothing in its result. The
51
+ * API drops an `ids` entry it doesn't know and answers an unknown `waters` or `fuzzyId`
52
+ * with `[]`, both with HTTP 200, so `ids: ["BONN", "KOELN", "EMMERICH"]` silently gave two
53
+ * stations and `waters: "Rhine"` an empty river. This reports each `ids` entry that names
54
+ * none of the returned stations (by uuid, number, shortname or longname, ignoring case),
55
+ * and a `waters` or `fuzzyId` filter whose result is empty. An empty array means every
56
+ * filter matched.
57
+ *
58
+ * When the call looked stations up by name (`ids` or `fuzzyId`), it also reports every
59
+ * shortname that two or more returned stations share: NEUSTADT names a gauge on the
60
+ * LEINE and one on the OSTSEE, and a lookup by that name (`stations.get("NEUSTADT")`,
61
+ * `timeseries.currentMeasurement("NEUSTADT")`) silently returns one of them — the uuid
62
+ * or number picks the right one. The CLI prints all notes on stderr.
63
+ */
64
+ export declare function stationListNotes(params: StationListParams, stations: readonly Station[]): StationListNote[];
19
65
  export declare class PegelOnlineClient {
20
66
  private readonly engine;
21
67
  readonly stations: StationsResource;
@@ -25,4 +71,3 @@ export declare class PegelOnlineClient {
25
71
  waters(): Promise<Water[]>;
26
72
  }
27
73
  export {};
28
- //# sourceMappingURL=client.d.ts.map