@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.
- package/README.md +29 -8
- package/dist/src/cli/commands/stations.d.ts +0 -1
- package/dist/src/cli/commands/stations.js +29 -5
- package/dist/src/cli/commands/timeseries.d.ts +0 -1
- package/dist/src/cli/commands/timeseries.js +3 -4
- package/dist/src/cli/index.d.ts +0 -1
- package/dist/src/cli/index.js +0 -1
- package/dist/src/cli/io.d.ts +2 -2
- package/dist/src/cli/io.js +7 -3
- package/dist/src/cli/program.d.ts +0 -1
- package/dist/src/cli/program.js +6 -7
- package/dist/src/cli/run.d.ts +12 -1
- package/dist/src/cli/run.js +35 -2
- package/dist/src/cli/shared.d.ts +21 -15
- package/dist/src/cli/shared.js +39 -48
- package/dist/src/client/client.d.ts +46 -1
- package/dist/src/client/client.js +152 -27
- package/dist/src/client/engine.d.ts +51 -13
- package/dist/src/client/engine.js +404 -81
- package/dist/src/client/errors.d.ts +38 -2
- package/dist/src/client/errors.js +74 -4
- package/dist/src/client/http.d.ts +26 -1
- package/dist/src/client/http.js +16 -2
- package/dist/src/client/index.d.ts +6 -4
- package/dist/src/client/index.js +4 -4
- package/dist/src/client/query.d.ts +0 -1
- package/dist/src/client/query.js +0 -1
- package/dist/src/client/types.d.ts +60 -10
- package/dist/src/client/types.js +0 -1
- package/dist/src/client/validate.d.ts +72 -0
- package/dist/src/client/validate.js +155 -0
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/package.json +4 -3
- package/dist/src/cli/commands/stations.d.ts.map +0 -1
- package/dist/src/cli/commands/stations.js.map +0 -1
- package/dist/src/cli/commands/timeseries.d.ts.map +0 -1
- package/dist/src/cli/commands/timeseries.js.map +0 -1
- package/dist/src/cli/index.d.ts.map +0 -1
- package/dist/src/cli/index.js.map +0 -1
- package/dist/src/cli/io.d.ts.map +0 -1
- package/dist/src/cli/io.js.map +0 -1
- package/dist/src/cli/program.d.ts.map +0 -1
- package/dist/src/cli/program.js.map +0 -1
- package/dist/src/cli/run.d.ts.map +0 -1
- package/dist/src/cli/run.js.map +0 -1
- package/dist/src/cli/shared.d.ts.map +0 -1
- package/dist/src/cli/shared.js.map +0 -1
- package/dist/src/client/client.d.ts.map +0 -1
- package/dist/src/client/client.js.map +0 -1
- package/dist/src/client/engine.d.ts.map +0 -1
- package/dist/src/client/engine.js.map +0 -1
- package/dist/src/client/errors.d.ts.map +0 -1
- package/dist/src/client/errors.js.map +0 -1
- package/dist/src/client/http.d.ts.map +0 -1
- package/dist/src/client/http.js.map +0 -1
- package/dist/src/client/index.d.ts.map +0 -1
- package/dist/src/client/index.js.map +0 -1
- package/dist/src/client/query.d.ts.map +0 -1
- package/dist/src/client/query.js.map +0 -1
- package/dist/src/client/types.d.ts.map +0 -1
- package/dist/src/client/types.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- 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
|
|
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.
|
|
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
|
|
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
|
|
210
|
-
|
|
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
|
|
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
|
|
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,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
|
-
|
|
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,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
|
package/dist/src/cli/index.d.ts
CHANGED
package/dist/src/cli/index.js
CHANGED
package/dist/src/cli/io.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/src/cli/io.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/dist/src/cli/program.js
CHANGED
|
@@ -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
|
package/dist/src/cli/run.d.ts
CHANGED
|
@@ -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
|
package/dist/src/cli/run.js
CHANGED
|
@@ -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
|
package/dist/src/cli/shared.d.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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`:
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
package/dist/src/cli/shared.js
CHANGED
|
@@ -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
|
|
34
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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`:
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|