homebridge-plugin-utils 1.35.0 → 2.0.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 (198) hide show
  1. package/README.md +130 -2
  2. package/build/eslint-plugin/README.md +164 -0
  3. package/build/eslint-plugin/config.mjs +308 -0
  4. package/build/eslint-plugin/index.mjs +7 -0
  5. package/build/eslint-plugin/plugin.mjs +46 -0
  6. package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
  7. package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
  8. package/build/eslint-plugin/rules/comment-style.mjs +190 -0
  9. package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
  10. package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
  11. package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
  12. package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
  13. package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
  14. package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
  15. package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
  16. package/build/eslint-plugin/test-setup.mjs +21 -0
  17. package/build/fs-ops.mjs +184 -0
  18. package/build/tsconfig.json +18 -3
  19. package/dist/backpressure.d.ts +94 -45
  20. package/dist/backpressure.js +229 -93
  21. package/dist/backpressure.js.map +1 -1
  22. package/dist/cli/index.d.ts +95 -0
  23. package/dist/cli/index.js +355 -0
  24. package/dist/cli/index.js.map +1 -0
  25. package/dist/clock-double.d.ts +93 -0
  26. package/dist/clock-double.js +141 -0
  27. package/dist/clock-double.js.map +1 -0
  28. package/dist/clock.d.ts +39 -0
  29. package/dist/clock.js +34 -0
  30. package/dist/clock.js.map +1 -0
  31. package/dist/eslint-plugin/config.d.mts +193 -0
  32. package/dist/eslint-plugin/index.d.mts +2 -0
  33. package/dist/eslint-plugin/plugin.d.mts +87 -0
  34. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  35. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  36. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  37. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  38. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  39. package/dist/featureOptions-docs.d.ts +96 -0
  40. package/dist/featureOptions-docs.js +193 -0
  41. package/dist/featureOptions-docs.js.map +1 -0
  42. package/dist/featureOptions.d.ts +674 -0
  43. package/dist/featureOptions.js +870 -0
  44. package/dist/featureOptions.js.map +1 -0
  45. package/dist/ffmpeg/codecs.d.ts +256 -72
  46. package/dist/ffmpeg/codecs.js +477 -262
  47. package/dist/ffmpeg/codecs.js.map +1 -1
  48. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  49. package/dist/ffmpeg/dgram-util.js +38 -0
  50. package/dist/ffmpeg/dgram-util.js.map +1 -0
  51. package/dist/ffmpeg/exec.d.ts +83 -64
  52. package/dist/ffmpeg/exec.js +77 -86
  53. package/dist/ffmpeg/exec.js.map +1 -1
  54. package/dist/ffmpeg/fmp4.d.ts +49 -2
  55. package/dist/ffmpeg/fmp4.js +47 -11
  56. package/dist/ffmpeg/fmp4.js.map +1 -1
  57. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  58. package/dist/ffmpeg/hap-enums.js +92 -0
  59. package/dist/ffmpeg/hap-enums.js.map +1 -0
  60. package/dist/ffmpeg/index.d.ts +16 -9
  61. package/dist/ffmpeg/index.js +6 -0
  62. package/dist/ffmpeg/index.js.map +1 -1
  63. package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
  64. package/dist/ffmpeg/mp4-assembler.js +387 -0
  65. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  66. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  67. package/dist/ffmpeg/mp4-parser.js +130 -0
  68. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  69. package/dist/ffmpeg/options.d.ts +62 -149
  70. package/dist/ffmpeg/options.js +608 -499
  71. package/dist/ffmpeg/options.js.map +1 -1
  72. package/dist/ffmpeg/process.d.ts +142 -96
  73. package/dist/ffmpeg/process.js +406 -278
  74. package/dist/ffmpeg/process.js.map +1 -1
  75. package/dist/ffmpeg/record.d.ts +302 -188
  76. package/dist/ffmpeg/record.js +408 -567
  77. package/dist/ffmpeg/record.js.map +1 -1
  78. package/dist/ffmpeg/recording-process-double.d.ts +144 -0
  79. package/dist/ffmpeg/recording-process-double.js +168 -0
  80. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  81. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  82. package/dist/ffmpeg/rtp-parser.js +77 -0
  83. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  84. package/dist/ffmpeg/rtp.d.ts +198 -141
  85. package/dist/ffmpeg/rtp.js +473 -251
  86. package/dist/ffmpeg/rtp.js.map +1 -1
  87. package/dist/ffmpeg/settings.d.ts +5 -2
  88. package/dist/ffmpeg/settings.js +20 -5
  89. package/dist/ffmpeg/settings.js.map +1 -1
  90. package/dist/ffmpeg/stream.d.ts +57 -107
  91. package/dist/ffmpeg/stream.js +121 -150
  92. package/dist/ffmpeg/stream.js.map +1 -1
  93. package/dist/formatters.d.ts +106 -0
  94. package/dist/formatters.js +174 -0
  95. package/dist/formatters.js.map +1 -0
  96. package/dist/homebridge-enums.d.ts +30 -0
  97. package/dist/homebridge-enums.js +17 -0
  98. package/dist/homebridge-enums.js.map +1 -0
  99. package/dist/index.d.ts +12 -6
  100. package/dist/index.js +7 -2
  101. package/dist/index.js.map +1 -1
  102. package/dist/logclient/auth.d.ts +114 -0
  103. package/dist/logclient/auth.js +199 -0
  104. package/dist/logclient/auth.js.map +1 -0
  105. package/dist/logclient/cli-run.d.ts +76 -0
  106. package/dist/logclient/cli-run.js +639 -0
  107. package/dist/logclient/cli-run.js.map +1 -0
  108. package/dist/logclient/cli.d.ts +3 -0
  109. package/dist/logclient/cli.js +97 -0
  110. package/dist/logclient/cli.js.map +1 -0
  111. package/dist/logclient/client.d.ts +145 -0
  112. package/dist/logclient/client.js +600 -0
  113. package/dist/logclient/client.js.map +1 -0
  114. package/dist/logclient/config.d.ts +173 -0
  115. package/dist/logclient/config.js +199 -0
  116. package/dist/logclient/config.js.map +1 -0
  117. package/dist/logclient/endpoints.d.ts +54 -0
  118. package/dist/logclient/endpoints.js +73 -0
  119. package/dist/logclient/endpoints.js.map +1 -0
  120. package/dist/logclient/filter.d.ts +45 -0
  121. package/dist/logclient/filter.js +51 -0
  122. package/dist/logclient/filter.js.map +1 -0
  123. package/dist/logclient/frame.d.ts +93 -0
  124. package/dist/logclient/frame.js +203 -0
  125. package/dist/logclient/frame.js.map +1 -0
  126. package/dist/logclient/index.d.ts +31 -0
  127. package/dist/logclient/index.js +12 -0
  128. package/dist/logclient/index.js.map +1 -0
  129. package/dist/logclient/parser.d.ts +211 -0
  130. package/dist/logclient/parser.js +393 -0
  131. package/dist/logclient/parser.js.map +1 -0
  132. package/dist/logclient/rest.d.ts +41 -0
  133. package/dist/logclient/rest.js +111 -0
  134. package/dist/logclient/rest.js.map +1 -0
  135. package/dist/logclient/settings.d.ts +15 -0
  136. package/dist/logclient/settings.js +64 -0
  137. package/dist/logclient/settings.js.map +1 -0
  138. package/dist/logclient/socket-double.d.ts +201 -0
  139. package/dist/logclient/socket-double.js +384 -0
  140. package/dist/logclient/socket-double.js.map +1 -0
  141. package/dist/logclient/socket.d.ts +257 -0
  142. package/dist/logclient/socket.js +620 -0
  143. package/dist/logclient/socket.js.map +1 -0
  144. package/dist/logclient/stitch.d.ts +83 -0
  145. package/dist/logclient/stitch.js +146 -0
  146. package/dist/logclient/stitch.js.map +1 -0
  147. package/dist/logclient/time-expression.d.ts +42 -0
  148. package/dist/logclient/time-expression.js +181 -0
  149. package/dist/logclient/time-expression.js.map +1 -0
  150. package/dist/logclient/time-window.d.ts +38 -0
  151. package/dist/logclient/time-window.js +53 -0
  152. package/dist/logclient/time-window.js.map +1 -0
  153. package/dist/logclient/types.d.ts +107 -0
  154. package/dist/logclient/types.js +6 -0
  155. package/dist/logclient/types.js.map +1 -0
  156. package/dist/mqttClient.d.ts +287 -0
  157. package/dist/mqttClient.js +433 -0
  158. package/dist/mqttClient.js.map +1 -0
  159. package/dist/service.d.ts +64 -15
  160. package/dist/service.js +93 -66
  161. package/dist/service.js.map +1 -1
  162. package/dist/ui/featureOptions.js +870 -0
  163. package/dist/ui/featureOptions.js.map +1 -0
  164. package/dist/ui/formatters.js +174 -0
  165. package/dist/ui/formatters.js.map +1 -0
  166. package/dist/ui/pluginConfigSession.mjs +141 -0
  167. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  168. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  169. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  170. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  171. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  172. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  173. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  174. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  175. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  176. package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
  177. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  179. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  180. package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
  181. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  182. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  183. package/dist/ui/webUi-featureOptions.mjs +677 -0
  184. package/dist/ui/webUi.mjs +192 -87
  185. package/dist/util.d.ts +602 -45
  186. package/dist/util.js +783 -78
  187. package/dist/util.js.map +1 -1
  188. package/package.json +33 -15
  189. package/build/eslint-rules.mjs +0 -511
  190. package/dist/featureoptions.d.ts +0 -264
  191. package/dist/featureoptions.js +0 -480
  192. package/dist/featureoptions.js.map +0 -1
  193. package/dist/mqttclient.d.ts +0 -178
  194. package/dist/mqttclient.js +0 -310
  195. package/dist/mqttclient.js.map +0 -1
  196. package/dist/ui/featureoptions.js +0 -480
  197. package/dist/ui/featureoptions.js.map +0 -1
  198. package/dist/ui/webUi-featureoptions.mjs +0 -3765
@@ -0,0 +1,639 @@
1
+ /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * logclient/cli-run.ts: The hblog command-line logic, pure-by-injection - arg parsing, config resolution, transport orchestration, output formatting, and signals.
4
+ */
5
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
6
+ if (value !== null && value !== void 0) {
7
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
8
+ var dispose, inner;
9
+ if (async) {
10
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
11
+ dispose = value[Symbol.asyncDispose];
12
+ }
13
+ if (dispose === void 0) {
14
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
15
+ dispose = value[Symbol.dispose];
16
+ if (async) inner = dispose;
17
+ }
18
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
19
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
20
+ env.stack.push({ value: value, dispose: dispose, async: async });
21
+ }
22
+ else if (async) {
23
+ env.stack.push({ async: true });
24
+ }
25
+ return value;
26
+ };
27
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
28
+ return function (env) {
29
+ function fail(e) {
30
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
31
+ env.hasError = true;
32
+ }
33
+ var r, s = 0;
34
+ function next() {
35
+ while (r = env.stack.pop()) {
36
+ try {
37
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
38
+ if (r.dispose) {
39
+ var result = r.dispose.call(r.value);
40
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
41
+ }
42
+ else s |= 1;
43
+ }
44
+ catch (e) {
45
+ fail(e);
46
+ }
47
+ }
48
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
49
+ if (env.hasError) throw env.error;
50
+ }
51
+ return next();
52
+ };
53
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
54
+ var e = new Error(message);
55
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
56
+ });
57
+ import { formatErrorMessage, onAbort } from "../util.js";
58
+ import { loadConfigFile, resolveConfigPath, resolveConnection } from "./config.js";
59
+ import { HomebridgeLogClient } from "./client.js";
60
+ import { createLogFilter } from "./filter.js";
61
+ import { parseArgs } from "node:util";
62
+ import { parseTimeExpression } from "./time-expression.js";
63
+ import { systemClock } from "../clock.js";
64
+ // The usage banner shown for `--help` and on a usage error. Kept as a module constant so the help text and the misuse text are one source of truth and a test can assert
65
+ // against it without coupling to formatting. The flags here mirror the `parseArgs` option table below; the two must stay in lockstep.
66
+ const USAGE = [
67
+ "Usage: hblog [filters] [options]",
68
+ "",
69
+ "Tail or query a homebridge-config-ui-x log.",
70
+ "",
71
+ "Connection:",
72
+ " --host <host> The homebridge-config-ui-x host (default: localhost).",
73
+ " --port <port> The server port (default: 8581).",
74
+ " --tls Connect over TLS (https/wss).",
75
+ " --user <username> The login username.",
76
+ " --pass <password> The login password.",
77
+ " --token <token> A pre-acquired bearer token (used verbatim).",
78
+ " --otp <code> A one-time passcode for a 2FA-enabled account.",
79
+ "",
80
+ "Mode:",
81
+ " -f, --follow Live-tail the log (default).",
82
+ " -n, --lines <N> Retrieve the most recent N lines.",
83
+ " --all Retrieve the entire log (cannot be combined with -n).",
84
+ "",
85
+ "Time range:",
86
+ " --since <when> Only show lines at or after <when> (e.g. 1d, 7am, 2026-06-29, \"2026-06-29 6am\").",
87
+ " --until <when> Only show lines at or before <when>; bounds a closed past window (cannot combine with --follow).",
88
+ "",
89
+ "Filters:",
90
+ " -p, --plugin <name> Only show lines from this plugin (repeatable).",
91
+ " -g, --grep <regex> Only show lines whose message matches this regular expression.",
92
+ " -l, --level <level> Only show lines at this level: debug, error, info, success, warn (repeatable).",
93
+ "",
94
+ "Output:",
95
+ " --json Emit one JSON record per line (NDJSON).",
96
+ " --raw Emit raw lines with ANSI escapes preserved.",
97
+ " --no-color Strip ANSI escapes from the output.",
98
+ " --version Print the hblog version and exit.",
99
+ " -h, --help Print this help and exit."
100
+ ].join("\n") + "\n";
101
+ // The exit codes the CLI returns. 0 is a clean success (including --help, --version, and a clean SIGINT/SIGTERM); 1 is a connection or authentication failure; 2 is a
102
+ // usage error (bad flags, contradictory mode flags, or incomplete credentials). Named as a frozen map so the call sites read by intent rather than by magic number.
103
+ const EXIT = Object.freeze({ failure: 1, success: 0, usage: 2 });
104
+ // The ANSI escape-sequence regex used to strip color when the output is not coloring. Mirrors the parser's CSI matcher; compiled once at module scope since it runs per
105
+ // emitted line on the output hot path.
106
+ // eslint-disable-next-line no-control-regex
107
+ const ANSI_PATTERN = /\[[0-9;?]*[ -/]*[@-~]/g;
108
+ // The set of recognized log levels, used to validate `--level` values so a typo (`--level eror`) is a clear usage error rather than a filter that silently matches
109
+ // nothing.
110
+ const VALID_LEVELS = new Set(["debug", "error", "info", "success", "warn"]);
111
+ // A usage error carrying the message to print to stderr. Thrown by the parsing/mapping helpers and caught by `runHblog`, which prints the message plus usage and returns
112
+ // the usage exit code. Modeled as a typed error rather than a sentinel return so the helpers stay total over their success type.
113
+ class UsageError extends Error {
114
+ constructor(message) {
115
+ super(message);
116
+ this.name = "UsageError";
117
+ }
118
+ }
119
+ // Strip ANSI escape sequences from a line. Used when the output is not coloring (a non-TTY stdout, `NO_COLOR`, or `--no-color`); `--raw` and a TTY keep them intact.
120
+ function stripAnsi(text) {
121
+ return text.replace(ANSI_PATTERN, "");
122
+ }
123
+ // Redact a bearer token from any text the CLI is about to print. A network-level error message can embed the connect URL (which carries `token=<jwt>`), and a token can
124
+ // appear in other diagnostic shapes, so we replace both the bare token substring and any `token=...` query parameter with a placeholder. This is the chokepoint for
125
+ // credential-safe diagnostics: the hard-error stderr writes (the setup failure, a captured stdout write error - via failWithStdoutError, on either the normal-completion
126
+ // or the catch path - and the streaming catch's generic error) route through it, while the usage banner, the config-permission warning, and the level advisory never
127
+ // carry a token.
128
+ function redactToken(text, token) {
129
+ // Always scrub a `token=...` query parameter regardless of whether we hold the literal token, since a URL built from a refreshed token may carry a value we never saw.
130
+ let redacted = text.replace(/([?&]token=)[^&\s"]+/g, "$1<redacted>");
131
+ // When we hold the literal token, scrub any bare occurrence of it too (e.g., a message that interpolated the token without the URL framing).
132
+ if ((token !== null) && (token.length > 0)) {
133
+ redacted = redacted.split(token).join("<redacted>");
134
+ }
135
+ return redacted;
136
+ }
137
+ // Parse the argument vector into the strongly-typed flag shape. `parseArgs` is configured strict, so an unknown flag throws; we translate that into a `UsageError` so the
138
+ // user gets a friendly message plus the usage banner rather than a raw parser stack. Repeatable options (`plugin`, `level`) are `multiple`; the rest are single.
139
+ function parseFlags(argv) {
140
+ let values;
141
+ try {
142
+ ({ values } = parseArgs({
143
+ allowPositionals: false,
144
+ args: [...argv],
145
+ options: {
146
+ all: { type: "boolean" },
147
+ follow: { short: "f", type: "boolean" },
148
+ grep: { short: "g", type: "string" },
149
+ help: { short: "h", type: "boolean" },
150
+ host: { type: "string" },
151
+ json: { type: "boolean" },
152
+ level: { multiple: true, short: "l", type: "string" },
153
+ lines: { short: "n", type: "string" },
154
+ "no-color": { type: "boolean" },
155
+ otp: { type: "string" },
156
+ pass: { type: "string" },
157
+ plugin: { multiple: true, short: "p", type: "string" },
158
+ port: { type: "string" },
159
+ raw: { type: "boolean" },
160
+ since: { type: "string" },
161
+ tls: { type: "boolean" },
162
+ token: { type: "string" },
163
+ until: { type: "string" },
164
+ user: { type: "string" },
165
+ version: { type: "boolean" }
166
+ },
167
+ strict: true
168
+ }));
169
+ }
170
+ catch (error) {
171
+ throw new UsageError(formatErrorMessage(error) + ".");
172
+ }
173
+ // Normalize the loosely-typed `parseArgs` result into the strict `ParsedFlags` shape, defaulting booleans to `false` and repeatable options to empty arrays so the
174
+ // downstream mapping never has to branch on `undefined` for those. `tls` is left optional so its absence falls through to the file/default in `resolveConnection`.
175
+ return {
176
+ all: values.all ?? false,
177
+ follow: values.follow ?? false,
178
+ grep: values.grep,
179
+ help: values.help ?? false,
180
+ host: values.host,
181
+ json: values.json ?? false,
182
+ level: values.level ?? [],
183
+ lines: values.lines,
184
+ noColor: values["no-color"] ?? false,
185
+ otp: values.otp,
186
+ pass: values.pass,
187
+ plugin: values.plugin ?? [],
188
+ port: values.port,
189
+ raw: values.raw ?? false,
190
+ since: values.since,
191
+ tls: values.tls,
192
+ token: values.token,
193
+ until: values.until,
194
+ user: values.user,
195
+ version: values.version ?? false
196
+ };
197
+ }
198
+ // Build the connection-flag slice `resolveConnection` consumes from the parsed flags, parsing the `--port` string into a number. A non-numeric `--port` is a usage error
199
+ // (the user explicitly typed a bad value, unlike the environment's lower-precedence port which is silently ignored).
200
+ function connectionFlags(flags) {
201
+ let port;
202
+ if (flags.port !== undefined) {
203
+ const parsed = Number.parseInt(flags.port, 10);
204
+ if (!Number.isFinite(parsed) || (parsed <= 0)) {
205
+ throw new UsageError("The --port value must be a positive integer.");
206
+ }
207
+ port = parsed;
208
+ }
209
+ return { host: flags.host, otp: flags.otp, password: flags.pass, port, tls: flags.tls, token: flags.token, username: flags.user };
210
+ }
211
+ // Extract the environment slice `resolveConnection` consumes from the process environment. The `HBLOG_*` variables map to the same fields the flags carry; they sit
212
+ // between flags and the config file in precedence.
213
+ function environmentSlice(env) {
214
+ return { host: env["HBLOG_HOST"], otp: env["HBLOG_OTP"], password: env["HBLOG_PASS"], port: env["HBLOG_PORT"], token: env["HBLOG_TOKEN"], username: env["HBLOG_USER"] };
215
+ }
216
+ // Derive the credential discriminated union from the resolved connection. A token wins outright; otherwise a complete username+password pair (with an optional OTP) is a
217
+ // password login; otherwise, with no credential material at all, the noauth path is used. A half-supplied username/password pair is a usage error - the user meant to log
218
+ // in but left out half of it, and silently falling back to noauth would mask the mistake.
219
+ function deriveCredentials(connection) {
220
+ if (connection.token !== null) {
221
+ return { kind: "token", token: connection.token };
222
+ }
223
+ const hasUser = connection.username !== null;
224
+ const hasPass = connection.password !== null;
225
+ if ((connection.username !== null) && (connection.password !== null)) {
226
+ // A complete login pair. Include the `otp` key only when present so the password arm stays exactly the declared shape (the field has a home only when meaningful).
227
+ if (connection.otp !== null) {
228
+ return { kind: "password", otp: connection.otp, password: connection.password, username: connection.username };
229
+ }
230
+ return { kind: "password", password: connection.password, username: connection.username };
231
+ }
232
+ if (hasUser || hasPass) {
233
+ throw new UsageError("Both --user and --pass are required for password authentication (or supply --token, or omit both for a no-auth server).");
234
+ }
235
+ return { kind: "noauth" };
236
+ }
237
+ // Build a usage error for an unparseable `--since`/`--until` value, naming the offending flag and listing the accepted forms. The accepted-forms text lives here as one
238
+ // source of truth so every throw site for a time-range flag shares this one message.
239
+ function timeExpressionError(flag, value) {
240
+ return new UsageError("The " + flag + " value \"" + value + "\" is not a recognized time expression; use a relative age (1d, 2h30m), a clock (7am, 14:30), " +
241
+ "a date (2026-06-29), a date and time (\"2026-06-29 6am\"), or now/today/yesterday.");
242
+ }
243
+ // Resolve the `--since`/`--until` flags into an absolute epoch window against the single `now` instant. Each present flag is parsed via `parseTimeExpression`; an
244
+ // unparseable value is a usage error naming the offending flag. `--since` binds to the interval's lower edge (`.start`) and `--until` to its
245
+ // upper edge (`.end`), so a date-only `--until 2026-06-29` includes the whole named day while `--since 2026-06-29` starts at midnight. An inverted window (since after
246
+ // until) can never match a line, so it too is a usage error rather than silent empty output.
247
+ function deriveWindow(flags, now) {
248
+ let since = null;
249
+ let until = null;
250
+ if (flags.since !== undefined) {
251
+ const resolved = parseTimeExpression(flags.since, now);
252
+ if (resolved === null) {
253
+ throw timeExpressionError("--since", flags.since);
254
+ }
255
+ since = resolved.start;
256
+ }
257
+ if (flags.until !== undefined) {
258
+ const resolved = parseTimeExpression(flags.until, now);
259
+ if (resolved === null) {
260
+ throw timeExpressionError("--until", flags.until);
261
+ }
262
+ until = resolved.end;
263
+ }
264
+ if ((since !== null) && (until !== null) && (since > until)) {
265
+ throw new UsageError("The --since value must be earlier than the --until value.");
266
+ }
267
+ return { since, until };
268
+ }
269
+ // Map the mode flags and the resolved time window into a `TailRequest`. `--all` and `-n` are mutually exclusive; a time range cannot combine with `-n` (a window is a
270
+ // filter over a whole-file retrieval, not a line count); and a `--until` bound cannot combine with a live `--follow` (a closed past window never ends). A time-bounded
271
+ // query maps to the engine's `window` channel, which owns the hedged-seed retrieval and the time-bounded selection: the user's `since`/`until` pass through UNCHANGED (a
272
+ // bare `--since` keeps `until: null`, which the engine fills with the snapshot horizon for a one-shot - there is no implicit `until = now` at the CLI), and `follow`
273
+ // selects live continuation versus one-shot termination. The non-windowed {follow, quantity} combinations map to the other arms: follow alone -> live tail; follow + a
274
+ // quantity -> seeded live tail; a quantity alone -> one-shot history; nothing -> the default live tail.
275
+ function deriveRequest(flags, windowBounds) {
276
+ if (flags.all && (flags.lines !== undefined)) {
277
+ throw new UsageError("Use either -n/--lines or --all, not both.");
278
+ }
279
+ const hasWindow = (windowBounds.since !== null) || (windowBounds.until !== null);
280
+ if (hasWindow && (flags.lines !== undefined)) {
281
+ throw new UsageError("Use a time range (--since/--until) or -n/--lines, not both.");
282
+ }
283
+ if ((windowBounds.until !== null) && flags.follow) {
284
+ throw new UsageError("--until bounds a closed window and cannot combine with --follow.");
285
+ }
286
+ if (hasWindow) {
287
+ // A time-bounded query is delivered over the engine's windowed channel. The user's bounds pass through verbatim; the engine serves it from the socket seed when the
288
+ // seed covers the window and otherwise falls back to the whole-file download, and owns the `[since, until]` filtering.
289
+ return { follow: flags.follow, mode: "window", since: windowBounds.since, until: windowBounds.until };
290
+ }
291
+ let quantity;
292
+ if (flags.all) {
293
+ // `--all` asks for the entire log over the whole-file download.
294
+ quantity = "all";
295
+ }
296
+ else if (flags.lines !== undefined) {
297
+ const parsed = Number.parseInt(flags.lines, 10);
298
+ if (!Number.isFinite(parsed) || (parsed <= 0)) {
299
+ throw new UsageError("The -n/--lines value must be a positive integer.");
300
+ }
301
+ quantity = parsed;
302
+ }
303
+ // Follow is the default when no mode flag is given, so a bare `hblog` live-tails. A quantity with follow seeds the live tail from history; a quantity without follow is
304
+ // a one-shot history retrieval that ends when the file is drained.
305
+ if (flags.follow || (quantity === undefined)) {
306
+ return (quantity === undefined) ? { mode: "follow" } : { mode: "follow-history", quantity };
307
+ }
308
+ return { mode: "history", quantity };
309
+ }
310
+ // Validate and build the level allow-list from the repeatable `--level` flag, lower-casing each value and rejecting an unrecognized level as a usage error so a typo
311
+ // surfaces loudly rather than as silent empty output. Returns the typed level array, or undefined when no level filter was requested.
312
+ function deriveLevels(rawLevels) {
313
+ if (rawLevels.length === 0) {
314
+ return undefined;
315
+ }
316
+ const levels = [];
317
+ for (const raw of rawLevels) {
318
+ const level = raw.toLowerCase();
319
+ if (!VALID_LEVELS.has(level)) {
320
+ throw new UsageError("Unknown --level value \"" + raw + "\"; valid levels are debug, error, info, success, warn.");
321
+ }
322
+ levels.push(level);
323
+ }
324
+ return levels;
325
+ }
326
+ // Compile the `--grep` flag into a RegExp, translating an invalid pattern into a usage error rather than letting the raw `RegExp` SyntaxError escape. Returns undefined
327
+ // when no grep was requested.
328
+ function deriveGrep(pattern) {
329
+ if (pattern === undefined) {
330
+ return undefined;
331
+ }
332
+ try {
333
+ return new RegExp(pattern);
334
+ }
335
+ catch (error) {
336
+ throw new UsageError("The -g/--grep value is not a valid regular expression: " + formatErrorMessage(error) + ".");
337
+ }
338
+ }
339
+ // Decide whether output should carry ANSI color, by a defined precedence: `--no-color` forces stripping; `--raw` forces preservation; otherwise `NO_COLOR` strips,
340
+ // `FORCE_COLOR` preserves, and finally a TTY stdout colors while a non-TTY (a pipe or file) strips. `--no-color` outranks `--raw` because removing color is the more
341
+ // specific, safer directive when a user contradicts themselves.
342
+ function shouldColor(flags, env, stdout) {
343
+ if (flags.noColor) {
344
+ return false;
345
+ }
346
+ if (flags.raw) {
347
+ return true;
348
+ }
349
+ // `NO_COLOR` is honored when set to any non-empty value, per the de-facto NO_COLOR convention. `FORCE_COLOR` set to any non-empty value forces color on even off a TTY.
350
+ if ((env["NO_COLOR"] !== undefined) && (env["NO_COLOR"] !== "")) {
351
+ return false;
352
+ }
353
+ if ((env["FORCE_COLOR"] !== undefined) && (env["FORCE_COLOR"] !== "")) {
354
+ return true;
355
+ }
356
+ return stdout.isTTY === true;
357
+ }
358
+ // Read the package version for `--version` from the package's own `package.json`, resolved relative to this compiled module (dist/logclient/cli-run.js -> ../../). Uses
359
+ // the injected `readFile` so a test pins the version without the real package file. A read or parse failure degrades to "unknown" rather than failing the command.
360
+ async function readVersion(readFile) {
361
+ try {
362
+ const { fileURLToPath } = await import("node:url");
363
+ const path = fileURLToPath(new URL("../../package.json", import.meta.url));
364
+ const parsed = JSON.parse(await readFile(path));
365
+ if ((typeof parsed === "object") && (parsed !== null) && ("version" in parsed) && (typeof parsed.version === "string")) {
366
+ return parsed.version;
367
+ }
368
+ }
369
+ catch {
370
+ // Fall through to the unknown sentinel; a missing or malformed package.json must not turn `--version` into an error.
371
+ }
372
+ return "unknown";
373
+ }
374
+ // Format one record for output. `--json` emits the record as NDJSON; otherwise the raw line is emitted, with ANSI stripped when not coloring. Both forms append a single
375
+ // newline so the consumer reads one record per line.
376
+ function formatRecord(record, json, color) {
377
+ if (json) {
378
+ return JSON.stringify(record) + "\n";
379
+ }
380
+ return (color ? record.raw : stripAnsi(record.raw)) + "\n";
381
+ }
382
+ /**
383
+ * Run the `hblog` command-line flow and return the process exit code.
384
+ *
385
+ * Parses {@link RunHblogOptions.argv}, handles `--help`/`--version` immediately, resolves the connection across flags / environment / `~/.hblog.json` (honoring
386
+ * `HBLOG_CONFIG`), maps the result into a {@link LogClientCredentials} and a {@link TailRequest}, builds a {@link HomebridgeLogClient}, runs the selected channel,
387
+ * applies the {@link createLogFilter} criteria, and writes log data to stdout (NDJSON for `--json`, raw/stripped lines otherwise) while routing diagnostics and warnings
388
+ * to stderr. A SIGINT/SIGTERM aborts the run cleanly (exit 0); a broken pipe (`EPIPE`) on stdout also ends cleanly (exit 0); a usage error returns 2; a connection or
389
+ * authentication failure returns 1. Token redaction is applied at the hard-error stderr writes (the setup failure, a captured stdout write error on either the
390
+ * normal-completion or the catch path, and the streaming catch's generic error); the usage and advisory writes never carry a token.
391
+ *
392
+ * @param options - The injected argument vector, environment, streams, directories, and seams. See {@link RunHblogOptions}.
393
+ *
394
+ * @returns The process exit code: 0 success / clean signal / help / version, 1 connection or auth failure, 2 usage error.
395
+ *
396
+ * @category Log Client
397
+ */
398
+ export async function runHblog(options) {
399
+ const { argv, env, homedir, stderr, stdout } = options;
400
+ const readFile = options.readFile;
401
+ // Parse flags first; an unknown or malformed flag is a usage error before any I/O.
402
+ let flags;
403
+ try {
404
+ flags = parseFlags(argv);
405
+ }
406
+ catch (error) {
407
+ return reportUsage(stderr, error);
408
+ }
409
+ // `--help` and `--version` short-circuit to stdout with a clean exit, before any connection work.
410
+ if (flags.help) {
411
+ stdout.write(USAGE);
412
+ return EXIT.success;
413
+ }
414
+ if (flags.version) {
415
+ const version = await readVersion(readFile ?? defaultReadFile);
416
+ stdout.write("hblog " + version + "\n");
417
+ return EXIT.success;
418
+ }
419
+ // Resolve the connection, credentials, request, and filter criteria. Any usage error here (bad port, contradictory mode flags, half-supplied credentials, bad grep, an
420
+ // unknown level) is reported with the usage banner and the usage exit code. A config-file parse failure is a hard error too, surfaced with its actionable message.
421
+ let connection;
422
+ let credentials;
423
+ let request;
424
+ let filter;
425
+ let wantColor;
426
+ let levelFilterActive;
427
+ try {
428
+ const configPath = resolveConfigPath({ env, homedir });
429
+ const file = await loadConfigFile(configPath, { readFile: options.readFile, stat: options.stat, warn: (message) => stderr.write(message + "\n") });
430
+ connection = resolveConnection({ env: environmentSlice(env), file, flags: connectionFlags(flags) });
431
+ credentials = deriveCredentials(connection);
432
+ // Evaluate the wall-clock seam EXACTLY ONCE so both time-range bounds resolve against a single instant (no cross-flag drift), then derive the window and the request.
433
+ // The window's bounds are consumed only here (to build the request); the engine's `window` channel owns the time-bounded selection, so nothing downstream re-filters.
434
+ const now = (options.now ?? systemClock.now)();
435
+ const windowBounds = deriveWindow(flags, now);
436
+ request = deriveRequest(flags, windowBounds);
437
+ const levels = deriveLevels(flags.level);
438
+ levelFilterActive = levels !== undefined;
439
+ filter = createLogFilter({ grep: deriveGrep(flags.grep), levels, plugins: (flags.plugin.length > 0) ? flags.plugin : undefined });
440
+ wantColor = shouldColor(flags, env, stdout);
441
+ }
442
+ catch (error) {
443
+ if (error instanceof UsageError) {
444
+ return reportUsage(stderr, error);
445
+ }
446
+ // A non-usage failure during setup (a config-file parse/read error) is a hard failure with an actionable message; redact any token defensively.
447
+ stderr.write(redactToken(formatErrorMessage(error) + ".", connectionToken(flags, env)) + "\n");
448
+ return EXIT.failure;
449
+ }
450
+ // Run the live/historical tail and stream records to stdout. The token used by the connection is captured for redaction of any error or diagnostic that follows.
451
+ return streamRecords({ color: wantColor, connection, credentials, filter, flags, levelFilterActive, options, request });
452
+ }
453
+ // The default file-read seam: read the file as UTF-8 via `node:fs/promises`. Lazily imported so a caller that injects its own seam pays no Node-filesystem import cost.
454
+ async function defaultReadFile(path) {
455
+ const { readFile } = await import("node:fs/promises");
456
+ return readFile(path, "utf8");
457
+ }
458
+ // Best-effort capture of the token that will be used for the connection, for redaction of a setup-phase error before `resolveConnection` has run. We read the flag/env
459
+ // token directly (the sources known before the config file loads); a file-only token is not yet known here, but a file-only token never appears in a setup error message.
460
+ function connectionToken(flags, env) {
461
+ return flags.token ?? env["HBLOG_TOKEN"] ?? null;
462
+ }
463
+ // Report a usage error: print the message (if any) and the usage banner to stderr, then return the usage exit code. A bare `UsageError` with an empty message prints just
464
+ // the usage banner.
465
+ function reportUsage(stderr, error) {
466
+ const message = (error instanceof Error) ? error.message : String(error);
467
+ if (message.length > 0) {
468
+ stderr.write(message + "\n\n");
469
+ }
470
+ stderr.write(USAGE);
471
+ return EXIT.usage;
472
+ }
473
+ // Build the client, run the selected channel, and stream filtered records to stdout. Owns the signal wiring (SIGINT/SIGTERM abort the run cleanly), the EPIPE trap (a
474
+ // closed downstream pipe ends the run cleanly), and the level-without-color advisory. Returns the exit code: 0 on a clean end (stream exhausted, clean signal, or EPIPE),
475
+ // 1 on a connection/auth failure.
476
+ async function streamRecords(state) {
477
+ const { color, connection, credentials, filter, flags, levelFilterActive, options, request } = state;
478
+ const { stderr, stdout } = options;
479
+ const token = connection.token;
480
+ // The run controller: SIGINT/SIGTERM and an EPIPE all abort it, which tears down the client and ends iteration cleanly. A clean abort is success; a fault is reported.
481
+ const controller = new AbortController();
482
+ // Track whether the run ended because of an intentional clean teardown (a signal or a broken pipe) so the catch below classifies the resulting iteration unwind as
483
+ // success rather than a failure.
484
+ let cleanStop = false;
485
+ // A non-EPIPE stdout write error (for example ENOSPC when redirecting to a full disk) is a genuine failure rather than a clean stop; capture it here so the catch can
486
+ // surface it with an actionable message instead of the run ending silently.
487
+ let stdoutError = null;
488
+ // The signal and EPIPE wiring. Registered now and removed in the `finally` so the CLI leaves no dangling listeners (important when `runHblog` is invoked repeatedly in
489
+ // a test process).
490
+ const onSignal = () => {
491
+ cleanStop = true;
492
+ controller.abort();
493
+ };
494
+ const onPipeError = (error) => {
495
+ // A broken downstream pipe (`hblog -f | head`) is the canonical clean stop: the reader went away, so we end with success rather than an error. Any other stdout error
496
+ // (for example a full disk when redirecting to a file) is a genuine failure. Either way we abort so the run unwinds promptly; the catch distinguishes the two.
497
+ if (error.code === "EPIPE") {
498
+ cleanStop = true;
499
+ }
500
+ else {
501
+ stdoutError = error;
502
+ }
503
+ controller.abort();
504
+ };
505
+ // Surface a captured non-EPIPE stdout write error as a failure with a redacted, actionable message. Shared by the two paths that can observe it: the drain completing
506
+ // normally, and the drain unwinding into the catch when the abort interrupts it - so the failure message lives in exactly one place.
507
+ const failWithStdoutError = (writeError) => {
508
+ stderr.write(redactToken("Error: " + formatErrorMessage(writeError) + ".", token) + "\n");
509
+ return EXIT.failure;
510
+ };
511
+ const signalCleanup = registerSignalHandlers(onSignal);
512
+ stdout.on?.("error", onPipeError);
513
+ // The level-without-color advisory state. Severity is carried only by ANSI color, so when the server emits its log without color (common under hb-service/systemd) the
514
+ // parser cannot determine any level and a strict --level filter would silently reject every line. We detect that precisely: a formatted log line (one with a plugin
515
+ // prefix) that parses to a null level can only be color-stripped, because a colored line always resolves to at least "info". On the first such line we warn once and
516
+ // bypass the level dimension thereafter, so the user still sees output. A colored log never trips this - levels apply normally and no warning fires.
517
+ let colorStripped = false;
518
+ try {
519
+ const env_1 = { stack: [], error: void 0, hasError: false };
520
+ try {
521
+ // The non-level predicate (plugin/grep only) for the level-without-color bypass, compiled once. While a level filter is active and no level has appeared, a strict
522
+ // level filter would reject every record, so we fall back to this predicate and pass the record through on the level dimension.
523
+ const nonLevelFilter = levelFilterActive ? createLogFilter({ grep: deriveGrep(flags.grep), plugins: (flags.plugin.length > 0) ? flags.plugin : undefined }) : filter;
524
+ const client = __addDisposableResource(env_1, new HomebridgeLogClient({
525
+ credentials,
526
+ fetch: options.fetch,
527
+ host: connection.host,
528
+ port: connection.port,
529
+ signal: controller.signal,
530
+ socketFactory: options.socketFactory,
531
+ tls: connection.tls
532
+ }), true);
533
+ const stream = __addDisposableResource(env_1, client.tail(request, { signal: controller.signal }), true);
534
+ // Stream the selected channel's records directly. Time-bounded selection lives in the engine's `window` channel (it alone holds the merged seed/download instant the
535
+ // coverage gate and the snapshot horizon need); the CLI owns only the content filtering below.
536
+ for await (const record of stream) {
537
+ // A formatted log line (one with a plugin prefix) that parses to a null level is the unambiguous signal that the server's log is color-stripped - a colored line
538
+ // always resolves to "info" or a severity, never null. Conclude levels are unavailable on first sight, warn once, then bypass the level dimension.
539
+ if (levelFilterActive && !colorStripped && (record.plugin !== null) && (record.level === null)) {
540
+ colorStripped = true;
541
+ stderr.write("Warning: a --level filter is active but the log lines carry no color, so levels are unknown; set FORCE_COLOR=1 on the Homebridge process to " +
542
+ "enable level filtering. Passing lines through unfiltered by level.\n");
543
+ }
544
+ // While the log is color-stripped a strict level filter would reject every line, so apply only the plugin/grep predicate; otherwise the full filter (including the
545
+ // level dimension) applies. On a colored log this is always the full filter.
546
+ if (levelFilterActive && colorStripped) {
547
+ if (!nonLevelFilter(record)) {
548
+ continue;
549
+ }
550
+ }
551
+ else if (!filter(record)) {
552
+ continue;
553
+ }
554
+ // Write the formatted record, honoring backpressure. A `false` return means the writable buffer has crossed its high-water mark; we suspend until it drains (or the
555
+ // run aborts) so a bulk `--all`/`-n` download bounds its memory to that mark rather than buffering the whole history in Node's write queue. Pairing this with the
556
+ // bin's natural exit (`process.exitCode`, not `process.exit`) is what guarantees the tail reaches the consumer: a `write` returning is not the same as its bytes
557
+ // reaching the OS, and only an event-loop-drained exit flushes the final buffered chunk. A broken pipe still surfaces via the `error` listener, aborting the wait.
558
+ if (!stdout.write(formatRecord(record, flags.json, color))) {
559
+ await awaitWritable(stdout, controller.signal);
560
+ }
561
+ }
562
+ // The stream drained without throwing. A stdout write error captured during the drain is still a failure: the abort fired from the stdout `error` listener does not
563
+ // always unwind the loop before it finishes, since a fully-buffered history download drains synchronously.
564
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
565
+ if (stdoutError !== null) {
566
+ return failWithStdoutError(stdoutError);
567
+ }
568
+ return EXIT.success;
569
+ }
570
+ catch (e_1) {
571
+ env_1.error = e_1;
572
+ env_1.hasError = true;
573
+ }
574
+ finally {
575
+ const result_1 = __disposeResources(env_1);
576
+ if (result_1)
577
+ await result_1;
578
+ }
579
+ }
580
+ catch (error) {
581
+ // A non-EPIPE stdout write error is a genuine failure, so surface it ahead of the clean-stop check. Like `cleanStop` it is set asynchronously from the stdout `error`
582
+ // listener, so the compiler's linear-flow narrowing does not reflect its runtime value here.
583
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
584
+ if (stdoutError !== null) {
585
+ return failWithStdoutError(stdoutError);
586
+ }
587
+ // A clean stop (signal or EPIPE) unwinds the iteration via the aborted signal; classify that as success. Anything else is a genuine connection or authentication
588
+ // failure: surface a redacted, actionable message and return the failure code. `cleanStop` and the aborted state are both set asynchronously from signal/EPIPE
589
+ // listeners, so the compiler's linear-flow narrowing does not reflect their runtime value here.
590
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
591
+ if (cleanStop || controller.signal.aborted) {
592
+ return EXIT.success;
593
+ }
594
+ stderr.write(redactToken("Error: " + formatErrorMessage(error) + ".", token) + "\n");
595
+ return EXIT.failure;
596
+ }
597
+ finally {
598
+ // Remove the signal and pipe-error listeners on every exit path so a repeated invocation (in tests, or a long-lived host) does not accumulate handlers.
599
+ signalCleanup();
600
+ stdout.off?.("error", onPipeError);
601
+ }
602
+ }
603
+ // Register process-level SIGINT and SIGTERM handlers that invoke `onSignal`, returning a cleanup function that removes them. The cleanup is always called from the
604
+ // streaming phase's `finally`, so a repeated invocation (in tests, or a long-lived host) never accumulates handlers. Both signals abort the run cleanly so an interrupted
605
+ // tail flushes and exits 0.
606
+ function registerSignalHandlers(onSignal) {
607
+ process.on("SIGINT", onSignal);
608
+ process.on("SIGTERM", onSignal);
609
+ return () => {
610
+ process.off("SIGINT", onSignal);
611
+ process.off("SIGTERM", onSignal);
612
+ };
613
+ }
614
+ // Wait until the output stream can accept more data (its `drain` event) or the run aborts - whichever comes first. This is the seam-adapted twin of the
615
+ // `events.once(stream, "drain", { signal })` idiom the BackpressureWriter uses: the narrow `CliStream` is not a full `EventEmitter`, so we race the stream's `drain`
616
+ // against the controller's `abort` over the seam's `on`/`off` hooks by hand. Tying the wait to the signal is load-bearing, not decorative - a broken pipe (or a SIGINT)
617
+ // during backpressure aborts the controller, and without the race the run would block forever on a `drain` that a closed pipe never emits. A seam that reports
618
+ // backpressure but exposes no event hooks (a capturing test sink) cannot signal drain, so we resolve at once and let the loop proceed.
619
+ function awaitWritable(stream, signal) {
620
+ // A run that is already aborting has nothing to wait for, and a seam that exposes no event hooks (a capturing test sink) cannot signal drain - either way, resolve at
621
+ // once and let the loop proceed. Guarding the aborted case here also means `onAbort` below never fires its handler inline, so `finish` runs on a later tick when
622
+ // `registration` is fully initialized.
623
+ if (signal.aborted || !stream.on || !stream.off) {
624
+ return Promise.resolve();
625
+ }
626
+ return new Promise((resolve) => {
627
+ // The single settlement point, shared by the drain and the abort path so listener teardown lives in one place: it removes the drain listener, disposes the abort
628
+ // registration (which removes the abort listener, so a bulk download's many drain cycles never accumulate handlers on the signal), and resolves. Each removal is
629
+ // idempotent, so whichever event fires second is a safe no-op.
630
+ const finish = () => {
631
+ stream.off?.("drain", finish);
632
+ registration[Symbol.dispose]();
633
+ resolve();
634
+ };
635
+ stream.on?.("drain", finish);
636
+ const registration = onAbort(signal, finish);
637
+ });
638
+ }
639
+ //# sourceMappingURL=cli-run.js.map