homebridge-plugin-utils 1.35.0 → 2.1.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 (204) 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 +166 -0
  23. package/dist/cli/index.js +551 -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/disposable-stack.d.ts +59 -0
  32. package/dist/disposable-stack.js +155 -0
  33. package/dist/disposable-stack.js.map +1 -0
  34. package/dist/docChrome.d.ts +260 -0
  35. package/dist/docChrome.js +361 -0
  36. package/dist/docChrome.js.map +1 -0
  37. package/dist/eslint-plugin/config.d.mts +193 -0
  38. package/dist/eslint-plugin/index.d.mts +2 -0
  39. package/dist/eslint-plugin/plugin.d.mts +87 -0
  40. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  41. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  42. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  43. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  44. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  45. package/dist/featureOptions-docs.d.ts +96 -0
  46. package/dist/featureOptions-docs.js +193 -0
  47. package/dist/featureOptions-docs.js.map +1 -0
  48. package/dist/featureOptions.d.ts +674 -0
  49. package/dist/featureOptions.js +870 -0
  50. package/dist/featureOptions.js.map +1 -0
  51. package/dist/ffmpeg/codecs.d.ts +256 -72
  52. package/dist/ffmpeg/codecs.js +477 -262
  53. package/dist/ffmpeg/codecs.js.map +1 -1
  54. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  55. package/dist/ffmpeg/dgram-util.js +38 -0
  56. package/dist/ffmpeg/dgram-util.js.map +1 -0
  57. package/dist/ffmpeg/exec.d.ts +83 -64
  58. package/dist/ffmpeg/exec.js +77 -86
  59. package/dist/ffmpeg/exec.js.map +1 -1
  60. package/dist/ffmpeg/fmp4.d.ts +49 -2
  61. package/dist/ffmpeg/fmp4.js +47 -11
  62. package/dist/ffmpeg/fmp4.js.map +1 -1
  63. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  64. package/dist/ffmpeg/hap-enums.js +92 -0
  65. package/dist/ffmpeg/hap-enums.js.map +1 -0
  66. package/dist/ffmpeg/index.d.ts +16 -9
  67. package/dist/ffmpeg/index.js +6 -0
  68. package/dist/ffmpeg/index.js.map +1 -1
  69. package/dist/ffmpeg/mp4-assembler.d.ts +161 -0
  70. package/dist/ffmpeg/mp4-assembler.js +424 -0
  71. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  72. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  73. package/dist/ffmpeg/mp4-parser.js +130 -0
  74. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  75. package/dist/ffmpeg/options.d.ts +62 -149
  76. package/dist/ffmpeg/options.js +608 -499
  77. package/dist/ffmpeg/options.js.map +1 -1
  78. package/dist/ffmpeg/process.d.ts +142 -96
  79. package/dist/ffmpeg/process.js +406 -278
  80. package/dist/ffmpeg/process.js.map +1 -1
  81. package/dist/ffmpeg/record.d.ts +325 -186
  82. package/dist/ffmpeg/record.js +418 -565
  83. package/dist/ffmpeg/record.js.map +1 -1
  84. package/dist/ffmpeg/recording-process-double.d.ts +157 -0
  85. package/dist/ffmpeg/recording-process-double.js +190 -0
  86. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  87. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  88. package/dist/ffmpeg/rtp-parser.js +77 -0
  89. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  90. package/dist/ffmpeg/rtp.d.ts +198 -141
  91. package/dist/ffmpeg/rtp.js +474 -251
  92. package/dist/ffmpeg/rtp.js.map +1 -1
  93. package/dist/ffmpeg/settings.d.ts +5 -2
  94. package/dist/ffmpeg/settings.js +20 -5
  95. package/dist/ffmpeg/settings.js.map +1 -1
  96. package/dist/ffmpeg/stream.d.ts +57 -107
  97. package/dist/ffmpeg/stream.js +121 -150
  98. package/dist/ffmpeg/stream.js.map +1 -1
  99. package/dist/formatters.d.ts +106 -0
  100. package/dist/formatters.js +174 -0
  101. package/dist/formatters.js.map +1 -0
  102. package/dist/homebridge-enums.d.ts +30 -0
  103. package/dist/homebridge-enums.js +17 -0
  104. package/dist/homebridge-enums.js.map +1 -0
  105. package/dist/index.d.ts +13 -6
  106. package/dist/index.js +8 -2
  107. package/dist/index.js.map +1 -1
  108. package/dist/logclient/auth.d.ts +114 -0
  109. package/dist/logclient/auth.js +199 -0
  110. package/dist/logclient/auth.js.map +1 -0
  111. package/dist/logclient/cli-run.d.ts +76 -0
  112. package/dist/logclient/cli-run.js +639 -0
  113. package/dist/logclient/cli-run.js.map +1 -0
  114. package/dist/logclient/cli.d.ts +3 -0
  115. package/dist/logclient/cli.js +97 -0
  116. package/dist/logclient/cli.js.map +1 -0
  117. package/dist/logclient/client.d.ts +145 -0
  118. package/dist/logclient/client.js +600 -0
  119. package/dist/logclient/client.js.map +1 -0
  120. package/dist/logclient/config.d.ts +173 -0
  121. package/dist/logclient/config.js +199 -0
  122. package/dist/logclient/config.js.map +1 -0
  123. package/dist/logclient/endpoints.d.ts +54 -0
  124. package/dist/logclient/endpoints.js +73 -0
  125. package/dist/logclient/endpoints.js.map +1 -0
  126. package/dist/logclient/filter.d.ts +45 -0
  127. package/dist/logclient/filter.js +51 -0
  128. package/dist/logclient/filter.js.map +1 -0
  129. package/dist/logclient/frame.d.ts +93 -0
  130. package/dist/logclient/frame.js +203 -0
  131. package/dist/logclient/frame.js.map +1 -0
  132. package/dist/logclient/index.d.ts +31 -0
  133. package/dist/logclient/index.js +12 -0
  134. package/dist/logclient/index.js.map +1 -0
  135. package/dist/logclient/parser.d.ts +211 -0
  136. package/dist/logclient/parser.js +393 -0
  137. package/dist/logclient/parser.js.map +1 -0
  138. package/dist/logclient/rest.d.ts +41 -0
  139. package/dist/logclient/rest.js +111 -0
  140. package/dist/logclient/rest.js.map +1 -0
  141. package/dist/logclient/settings.d.ts +15 -0
  142. package/dist/logclient/settings.js +64 -0
  143. package/dist/logclient/settings.js.map +1 -0
  144. package/dist/logclient/socket-double.d.ts +201 -0
  145. package/dist/logclient/socket-double.js +384 -0
  146. package/dist/logclient/socket-double.js.map +1 -0
  147. package/dist/logclient/socket.d.ts +257 -0
  148. package/dist/logclient/socket.js +620 -0
  149. package/dist/logclient/socket.js.map +1 -0
  150. package/dist/logclient/stitch.d.ts +83 -0
  151. package/dist/logclient/stitch.js +146 -0
  152. package/dist/logclient/stitch.js.map +1 -0
  153. package/dist/logclient/time-expression.d.ts +42 -0
  154. package/dist/logclient/time-expression.js +181 -0
  155. package/dist/logclient/time-expression.js.map +1 -0
  156. package/dist/logclient/time-window.d.ts +38 -0
  157. package/dist/logclient/time-window.js +53 -0
  158. package/dist/logclient/time-window.js.map +1 -0
  159. package/dist/logclient/types.d.ts +107 -0
  160. package/dist/logclient/types.js +6 -0
  161. package/dist/logclient/types.js.map +1 -0
  162. package/dist/mqttClient.d.ts +287 -0
  163. package/dist/mqttClient.js +433 -0
  164. package/dist/mqttClient.js.map +1 -0
  165. package/dist/service.d.ts +64 -15
  166. package/dist/service.js +93 -66
  167. package/dist/service.js.map +1 -1
  168. package/dist/ui/featureOptions.js +870 -0
  169. package/dist/ui/featureOptions.js.map +1 -0
  170. package/dist/ui/formatters.js +174 -0
  171. package/dist/ui/formatters.js.map +1 -0
  172. package/dist/ui/pluginConfigSession.mjs +141 -0
  173. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  174. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  175. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  176. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  177. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  179. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  180. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  181. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  182. package/dist/ui/webUi-featureOptions/utils.mjs +213 -0
  183. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  184. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  185. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  186. package/dist/ui/webUi-featureOptions/views/nav.mjs +341 -0
  187. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  188. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  189. package/dist/ui/webUi-featureOptions.mjs +702 -0
  190. package/dist/ui/webUi.mjs +225 -88
  191. package/dist/util.d.ts +679 -45
  192. package/dist/util.js +830 -77
  193. package/dist/util.js.map +1 -1
  194. package/package.json +33 -15
  195. package/build/eslint-rules.mjs +0 -511
  196. package/dist/featureoptions.d.ts +0 -264
  197. package/dist/featureoptions.js +0 -480
  198. package/dist/featureoptions.js.map +0 -1
  199. package/dist/mqttclient.d.ts +0 -178
  200. package/dist/mqttclient.js +0 -310
  201. package/dist/mqttclient.js.map +0 -1
  202. package/dist/ui/featureoptions.js +0 -480
  203. package/dist/ui/featureoptions.js.map +0 -1
  204. package/dist/ui/webUi-featureoptions.mjs +0 -3765
@@ -1,136 +1,272 @@
1
1
  /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
2
  *
3
- * backpressure.ts: Backpressure-aware write queue for Node.js writable streams.
3
+ * backpressure.ts: Signal-driven, backpressure-aware write queue for Node.js writable streams.
4
4
  */
5
5
  /**
6
- * A backpressure-aware write queue that serializes writes to a writable stream, pausing when the stream signals backpressure and resuming on drain.
6
+ * AsyncDisposable write queue that serializes Buffer writes onto a Node {@link Writable}, respects backpressure via the stream's `drain` event, and composes into the
7
+ * library's `AbortSignal`-driven lifecycle so a parent signal can tear the writer down uniformly with every other HBPU resource class.
7
8
  *
8
- * The stream is resolved lazily via a getter function on each write, allowing the writer to be created before the stream exists and to handle stream replacement across
9
- * process restarts.
9
+ * Primary use case: feeding fMP4 segments from a livestream event source into an FFmpeg process's stdin, where the downstream may not consume as fast as the upstream
10
+ * produces.
11
+ *
12
+ * @module
13
+ */
14
+ import { HbpuAbortError, composeSignals, onAbort } from "./util.js";
15
+ import { once } from "node:events";
16
+ /**
17
+ * Thrown synchronously by {@link BackpressureWriter.write} when the pending queue is already at the configured {@link BackpressureWriterInit.highWaterMark} and
18
+ * accepting the new chunk would push it over.
19
+ *
20
+ * Separate from the "writer has aborted" and "underlying stream is dead" failure modes so callers can distinguish backpressure-overflow (back off and retry later)
21
+ * from terminal failures (give up or escalate) by type rather than by inspecting error message text.
22
+ *
23
+ * @category Utilities
24
+ */
25
+ export class BackpressureOverflowError extends Error {
26
+ name = "BackpressureOverflowError";
27
+ constructor(message = "BackpressureWriter: queue depth exceeds configured highWaterMark.") {
28
+ super(message);
29
+ }
30
+ }
31
+ /**
32
+ * Rejected by an individual {@link BackpressureWriter.write} promise when the provider returned a {@link Writable} whose `writable` flag is `false` (the stream has
33
+ * ended or been destroyed). The writer itself remains alive - a later stream replacement via the provider may revive the pipeline - so this is a per-write rejection,
34
+ * not a terminal writer error.
35
+ *
36
+ * @category Utilities
37
+ */
38
+ export class BackpressureClosedStreamError extends Error {
39
+ name = "BackpressureClosedStreamError";
40
+ constructor(message = "BackpressureWriter: underlying stream is not writable.") {
41
+ super(message);
42
+ }
43
+ }
44
+ /**
45
+ * AsyncDisposable backpressure-aware write queue for Node {@link Writable} streams.
46
+ *
47
+ * Each call to {@link BackpressureWriter.write} returns a Promise that resolves once the chunk has been written (and any triggered backpressure has drained) or rejects
48
+ * if the writer aborts mid-write. Concurrent writes serialize through an internal FIFO queue; ordering matches call order. The stream itself is resolved lazily through
49
+ * a caller-supplied provider on each drain turn, so the writer may outlive any particular stream instance - the provider is consulted per chunk, and a `null` return is
50
+ * a signal to drop the chunk (the associated write promise still resolves, treating the drop as a success from the caller's perspective).
10
51
  *
11
52
  * @example
12
53
  *
13
54
  * ```ts
14
- * // Create a writer that feeds segments to an FFmpeg process stdin.
15
- * const writer = new BackpressureWriter(() => ffmpegProcess.stdin ?? null);
55
+ * import { BackpressureWriter } from "homebridge-plugin-utils";
16
56
  *
17
- * // Enqueue segments as they arrive from a livestream source.
18
- * livestream.on("segment", (segment) => writer.write(segment));
57
+ * await using writer = new BackpressureWriter(() => ffmpegProcess.stdin ?? null, { signal: session.signal });
19
58
  *
20
- * // When the session ends, close the writer to release pending data.
21
- * writer.close();
59
+ * // Each write awaits its own flush; concurrent writes queue behind prior ones.
60
+ * await writer.write(segmentOne);
61
+ * await writer.write(segmentTwo);
22
62
  * ```
23
63
  *
24
64
  * @category Utilities
25
65
  */
26
66
  export class BackpressureWriter {
27
- drainListener;
28
- drainStream;
29
- getStream;
30
- isClosed;
31
- isWriting;
32
- onWrite;
33
- queue;
34
67
  /**
35
- * Creates a new backpressure-aware write queue.
68
+ * The composed abort signal representing this writer's lifetime. Aborts exactly once when {@link BackpressureWriter.abort} is called, when the parent signal fires,
69
+ * or when the underlying stream surfaces an error that invalidates the writer; `signal.reason` names the cause.
70
+ */
71
+ signal;
72
+ // The private AbortController whose signal is composed into `this.signal`. Owning the controller internally keeps teardown reachable from any handler - drain-wait
73
+ // cancellation, stream-error escalation, explicit abort - without giving callers a handle to the raw controller.
74
+ #controller;
75
+ // Optional queue-depth ceiling. When undefined, the queue is unbounded and the only memory discipline is whatever the caller imposes by awaiting prior writes before
76
+ // issuing new ones.
77
+ #highWaterMark;
78
+ // Caller-provided resolver for the target stream. Evaluated lazily on each drain-loop iteration so the writer can survive stream replacement across FFmpeg process
79
+ // restarts, and so callers can bind the writer to a stream that does not yet exist at construction time.
80
+ #streamProvider;
81
+ // FIFO queue of pending writes. Each entry pairs the buffer with its resolver so the drain loop can settle the caller's promise at the same point it consumes the
82
+ // chunk. The drain loop peeks `queue[0]` during the write (so queue.length reflects total pending, including the in-flight entry) and shifts the entry off only
83
+ // after it has fully flushed or failed.
84
+ #queue = [];
85
+ // The active drain-loop promise, if any. Held so `[Symbol.asyncDispose]` can await actual completion before returning, so `await using` callers are guaranteed no
86
+ // stream interaction is still in flight by the time the surrounding scope exits.
87
+ #drainTask;
88
+ // True while the drain loop is active (set at entry, cleared in the loop's `finally`). Used by `write()` to decide whether to spawn a new drain - the flag flips
89
+ // back to false the moment the loop exits, whether synchronously (queue emptied in one pass, no backpressure) or asynchronously (after a drain wait or abort) - so
90
+ // a subsequent `write()` that arrives right after an empty-queue exit correctly re-spawns the loop rather than being stranded by a stale drain-task reference.
91
+ #processing = false;
92
+ /**
93
+ * Construct a new backpressure-aware writer.
36
94
  *
37
- * @param getStream - A function that returns the current writable stream, or `null` if the stream is unavailable. Evaluated on each write attempt, allowing the
38
- * writer to be created before the stream exists or to track a stream that changes across process restarts. For a static stream, wrap it in an
39
- * arrow function: `() => stream`.
40
- * @param onWrite - Optional. A callback invoked after each segment is successfully written to the underlying stream. Useful for tracking write statistics.
95
+ * @param streamProvider - A function that returns the current writable stream, or `null` to drop incoming chunks. Evaluated lazily on each drain-loop iteration.
96
+ * @param init - Optional init options. See {@link BackpressureWriterInit}.
41
97
  *
42
98
  * @example
43
99
  *
44
100
  * ```ts
45
- * // Lazy resolution...the stream is resolved on each write.
46
- * const writer = new BackpressureWriter(() => this.ffmpegProcess?.stdin ?? null, () => segmentCount++);
47
- *
48
- * // Static stream...wrap in an arrow function.
49
- * const writer = new BackpressureWriter(() => stream);
101
+ * await using writer = new BackpressureWriter(() => this.ffmpegProcess?.stdin ?? null, { highWaterMark: 64, signal: session.signal });
50
102
  * ```
51
103
  */
52
- constructor(getStream, onWrite) {
53
- this.drainListener = null;
54
- this.drainStream = null;
55
- this.getStream = getStream;
56
- this.isClosed = false;
57
- this.isWriting = false;
58
- this.onWrite = onWrite ?? null;
59
- this.queue = [];
104
+ constructor(streamProvider, init = {}) {
105
+ this.#streamProvider = streamProvider;
106
+ this.#highWaterMark = init.highWaterMark;
107
+ this.#controller = new AbortController();
108
+ this.signal = composeSignals(init.signal, this.#controller.signal);
109
+ // Single teardown convergence point. `onAbort` registers the one-shot teardown handler AND handles the pre-aborted-signal edge case where `addEventListener`
110
+ // would otherwise skip the handler. Rejects every pending entry - including the in-flight entry when the drain loop is parked on
111
+ // `events.once(stream, "drain", { signal })`. The drain loop's catch branch may also reject the in-flight resolver under the same abort; promise resolvers are
112
+ // inert after first settlement, so the second call is a no-op. Rejecting from here unconditionally is required in the stream-error escalation path,
113
+ // where the drain loop shifts the in-flight entry before calling `this.#controller.abort(...)` - by the time the listener runs, only the queued entries remain
114
+ // in the queue, and leaving any of them unrejected would orphan the caller's promise.
115
+ onAbort(this.signal, () => this.#teardown());
60
116
  }
61
117
  /**
62
- * Enqueues data to be written to the stream. If the stream is available and not under backpressure, the data is written immediately. Otherwise, it is queued and
63
- * written when the stream signals it is ready via the drain event.
118
+ * Enqueue `chunk` for writing. Concurrent calls serialize in FIFO order via the internal queue.
119
+ *
120
+ * @param chunk - The buffer to write.
64
121
  *
65
- * @param data - The buffer to write to the stream.
122
+ * @returns A promise that resolves when the chunk has been flushed to the underlying stream (including any required drain wait), or immediately if the provider
123
+ * returned `null` at dispatch time (drop semantics). The promise rejects in the following cases:
66
124
  *
67
- * @returns Returns `true` if the data was accepted (stream is available), `false` if the stream is unavailable or the writer has been closed.
125
+ * - `this.signal.reason` - the writer aborted before or during the write.
126
+ * - {@link BackpressureOverflowError} (thrown synchronously) - `highWaterMark` is configured and the queue depth already equals or exceeds it.
127
+ * - {@link BackpressureClosedStreamError} - the provider returned a stream whose `writable` flag is `false`. The writer itself stays alive for a potential later
128
+ * stream replacement.
129
+ *
130
+ * @throws {@link BackpressureOverflowError} when `highWaterMark` is exceeded.
131
+ */
132
+ async write(chunk) {
133
+ // Pre-aborted short-circuit: a write issued after teardown has nothing live to attach to. Rejecting with the signal's reason keeps semantics uniform with the
134
+ // mid-write abort path - callers branch on `signal.reason` / `isHbpuAbortReason` in either case.
135
+ if (this.signal.aborted) {
136
+ throw this.signal.reason;
137
+ }
138
+ // Queue-overflow fail-fast. Rejecting synchronously lets upstream producers apply their own pacing rather than silently accumulating unbounded backlog. A
139
+ // dedicated {@link BackpressureOverflowError} class (not an `HbpuAbortError`) is thrown here because the writer itself is still healthy - the caller supplied too
140
+ // much work, not the system asking for teardown - and consumers who care about the distinction can `instanceof`-check rather than match on message text.
141
+ if ((this.#highWaterMark !== undefined) && (this.#queue.length >= this.#highWaterMark)) {
142
+ throw new BackpressureOverflowError();
143
+ }
144
+ const resolvers = Promise.withResolvers();
145
+ this.#queue.push({ chunk, resolvers });
146
+ // Spin up the drain loop when nothing is already processing. We key off the `#processing` flag rather than `#drainTask` because an async function's body runs
147
+ // synchronously until its first `await`: a no-backpressure drain completes before `write()` returns, leaving `#drainTask` set to a resolved-but-not-yet-cleared
148
+ // promise that would falsely gate subsequent writes. `#processing` is flipped true at the top of `#drain` and false in its `finally`, so checking it gives us a
149
+ // truthful "is a drain currently running?" answer with no chained-promise races.
150
+ if (!this.#processing) {
151
+ this.#drainTask = this.#drain();
152
+ }
153
+ return resolvers.promise;
154
+ }
155
+ /**
156
+ * Abort the writer and tear it down. Defaults to `HbpuAbortError("shutdown")` when no reason is supplied; explicit reasons pass through unchanged.
157
+ *
158
+ * Safe to call more than once: subsequent calls are no-ops because the underlying signal only aborts once. Every queued write rejects with the signal's reason; any
159
+ * in-flight drain wait rejects with the signal's reason as well, and that rejection propagates out of the in-flight `write()` promise.
160
+ *
161
+ * @param reason - Optional abort reason. Typically an {@link HbpuAbortError}; platform errors (`TimeoutError`, `AbortError`) also interoperate by convention.
68
162
  */
69
- write(data) {
70
- // If the writer has been closed or the stream is unavailable or not writable, reject the write.
71
- const stream = this.isClosed ? null : this.getStream();
72
- if (!stream?.writable) {
73
- return false;
163
+ abort(reason) {
164
+ if (this.aborted) {
165
+ return;
74
166
  }
75
- // Add the data to the queue and process it.
76
- this.queue.push(data);
77
- this.processQueue();
78
- return true;
167
+ this.#controller.abort(reason ?? new HbpuAbortError("shutdown"));
79
168
  }
80
169
  /**
81
- * Closes the writer, clearing any pending data and removing drain listeners. After closing, all subsequent writes are rejected. This should be called when the
82
- * underlying stream is being shut down or the session is ending.
170
+ * `AsyncDisposable` implementation. Aborts the writer (defaulting to `"shutdown"`) and awaits actual drain-loop completion before returning, so callers using
171
+ * `await using` are guaranteed every pending write has settled by the time the surrounding scope exits.
172
+ *
173
+ * @returns A promise that resolves once the drain loop has fully exited.
83
174
  */
84
- close() {
85
- this.isClosed = true;
86
- this.isWriting = false;
87
- this.queue.length = 0;
88
- // Remove any pending drain listener.
89
- if (this.drainListener && this.drainStream) {
90
- this.drainStream.off("drain", this.drainListener);
91
- this.drainListener = null;
92
- this.drainStream = null;
175
+ async [Symbol.asyncDispose]() {
176
+ this.abort();
177
+ if (this.#drainTask) {
178
+ // Drain failures are already observed through the individual write promises; swallow here so `await using` does not surface cleanup-side errors the caller has
179
+ // no way to react to.
180
+ await this.#drainTask.catch(() => { });
93
181
  }
94
182
  }
95
183
  /**
96
- * Returns the number of segments currently queued and waiting to be written.
184
+ * `true` once `this.signal` has aborted. Derived from the signal; no independent state.
185
+ */
186
+ get aborted() {
187
+ return this.signal.aborted;
188
+ }
189
+ /**
190
+ * Total number of entries in the pending-write queue, including the in-flight entry (if the drain loop is parked on `events.once(stream, "drain", { signal })`).
191
+ * Matches the depth that the configured `highWaterMark` is compared against, so adaptive producers watching this value see the same accounting the writer uses
192
+ * internally.
97
193
  */
98
194
  get pending() {
99
- return this.queue.length;
195
+ return this.#queue.length;
100
196
  }
101
- // Process the write queue. Dequeues segments and writes them to the stream, respecting backpressure by waiting for drain events before continuing. The synchronous
102
- // success path uses a loop rather than recursion to avoid growing the call stack when processing a burst of queued segments (e.g., initial timeshift buffer flush).
103
- processQueue() {
104
- // If we already have a write in progress, we're done...the drain listener will resume processing.
105
- if (this.isWriting) {
106
- return;
107
- }
108
- // Process as many queued segments as the stream can accept without backpressure.
109
- while (this.queue.length) {
110
- // Resolve the stream. If it's gone or no longer writable, we can't write.
111
- const stream = this.isClosed ? null : this.getStream();
112
- if (!stream?.writable) {
113
- return;
114
- }
115
- // Dequeue and write.
116
- this.isWriting = true;
117
- const segment = this.queue.shift();
118
- if (!stream.write(segment)) {
119
- // The stream signaled backpressure. Wait for drain before processing the next segment.
120
- this.drainStream = stream;
121
- this.drainListener = () => {
122
- this.drainStream = null;
123
- this.drainListener = null;
124
- this.isWriting = false;
125
- this.onWrite?.();
126
- this.processQueue();
127
- };
128
- stream.once("drain", this.drainListener);
129
- return;
197
+ // Drain loop. Iterates the queue, writing each chunk through the provider's current stream, honoring backpressure via `events.once(stream, "drain", { signal })`.
198
+ // Runs until the queue is empty or the signal aborts; every in-flight write is settled before exit (resolved on successful write, rejected via drain-abort on
199
+ // teardown). Note the peek-then-shift pattern: the entry stays at `queue[0]` while the write is in flight, so `queue.length` reflects the true pending count
200
+ // (including the in-flight entry) and `highWaterMark` checks in `write()` work uniformly whether the drain is idle or mid-wait.
201
+ async #drain() {
202
+ this.#processing = true;
203
+ try {
204
+ while ((this.#queue.length > 0) && !this.signal.aborted) {
205
+ const entry = this.#queue[0];
206
+ // `length`-guarded peek: the loop condition just checked non-empty, so we always have an entry. `noUncheckedIndexedAccess` widens the indexed access to
207
+ // `PendingWrite | undefined`; the null check below narrows for the compiler without changing runtime semantics.
208
+ if (!entry) {
209
+ break;
210
+ }
211
+ // Re-evaluate the stream on every iteration. The provider is free to return a different instance across turns (stream replacement across restarts) or `null`
212
+ // (no live stream right now - drop the chunk). Both cases are expected in HBUP's pipeline and are handled here without surfacing as errors to the caller.
213
+ const stream = this.#streamProvider();
214
+ if (!stream) {
215
+ // Drop semantics: the provider explicitly said "no stream." The write promise resolves so the caller can continue without branching on a per-write return;
216
+ // the surrounding session is responsible for deciding whether the lack of a stream is itself a fault.
217
+ this.#queue.shift();
218
+ entry.resolvers.resolve();
219
+ continue;
220
+ }
221
+ if (!stream.writable) {
222
+ // Stream exists but has ended. Reject this specific write with a {@link BackpressureClosedStreamError} and let the caller decide whether to escalate - we
223
+ // do not abort the writer here, because a later stream replacement (via the provider) may revive the pipeline.
224
+ this.#queue.shift();
225
+ entry.resolvers.reject(new BackpressureClosedStreamError());
226
+ continue;
227
+ }
228
+ try {
229
+ // `stream.write()` returns `false` when the high-water mark has been breached; the write is still queued by Node, but we must wait for `drain` before
230
+ // pushing more bytes or we risk unbounded growth of Node's internal write buffer in process memory. `events.once` with `{ signal }` ties the wait to the
231
+ // writer's lifetime so an abort during drain rejects the wait with the signal's reason rather than hanging.
232
+ if (!stream.write(entry.chunk)) {
233
+ // eslint-disable-next-line no-await-in-loop
234
+ await once(stream, "drain", { signal: this.signal });
235
+ }
236
+ this.#queue.shift();
237
+ entry.resolvers.resolve();
238
+ }
239
+ catch (error) {
240
+ // Two rejection shapes land here. (a) Our signal aborted mid-drain: `once` rejects with `signal.reason`; we surface that to the caller and exit the loop
241
+ // because teardown is already in flight. (b) The stream emitted an error via some path `events.once` exposes: the write that triggered the error cannot
242
+ // complete, so we reject this caller and abort the writer with `"failed"` so no subsequent write tries to reuse a broken stream silently.
243
+ this.#queue.shift();
244
+ if (this.aborted) {
245
+ entry.resolvers.reject(this.signal.reason);
246
+ return;
247
+ }
248
+ entry.resolvers.reject(error);
249
+ this.#controller.abort(new HbpuAbortError("failed", { cause: error }));
250
+ return;
251
+ }
130
252
  }
131
- // Write succeeded immediately. Notify the caller and continue to the next segment.
132
- this.isWriting = false;
133
- this.onWrite?.();
253
+ }
254
+ finally {
255
+ this.#processing = false;
256
+ }
257
+ }
258
+ // Teardown convergence point, fired exactly once when `this.signal` aborts. Rejects every pending entry with the signal's reason, regardless of whether the drain
259
+ // loop is currently processing. Promise resolvers are inert after first settlement, so if the drain loop's catch branch also rejects the in-flight resolver
260
+ // (e.g., when `events.once(..., { signal })` rejects on the same abort), the second call is a no-op. Draining the queue unconditionally here prevents the
261
+ // stream-error escalation path from orphaning queued resolvers - `#drain`'s catch shifts the in-flight entry and then calls `this.#controller.abort(...)`, which
262
+ // fires this listener synchronously while the remaining queued entries are still in the queue.
263
+ #teardown() {
264
+ // `signal.reason` is typed `any` in the DOM lib. The `: unknown` annotation narrows it to `unknown` for the `@typescript-eslint/no-unsafe-assignment` rule, which
265
+ // surfaces stray `any` values on assignment. `unknown` flows through `resolvers.reject()` unchanged - reject accepts any value.
266
+ const reason = this.signal.reason;
267
+ const pending = this.#queue.splice(0);
268
+ for (const entry of pending) {
269
+ entry.resolvers.reject(reason);
134
270
  }
135
271
  }
136
272
  }
@@ -1 +1 @@
1
- {"version":3,"file":"backpressure.js","sourceRoot":"","sources":["../src/backpressure.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAcH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,OAAO,kBAAkB;IAErB,aAAa,CAAuB;IACpC,WAAW,CAAqB;IACvB,SAAS,CAA2B;IAC7C,QAAQ,CAAU;IAClB,SAAS,CAAU;IACV,OAAO,CAAuB;IAC9B,KAAK,CAAW;IAEjC;;;;;;;;;;;;;;;;;OAiBG;IACH,YAAY,SAAmC,EAAE,OAAoB;QAEnE,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC;QAC1B,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;QACxB,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;QACtB,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;QACvB,IAAI,CAAC,OAAO,GAAG,OAAO,IAAI,IAAI,CAAC;QAC/B,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC;IAClB,CAAC;IAED;;;;;;;OAOG;IACI,KAAK,CAAC,IAAY;QAEvB,gGAAgG;QAChG,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;QAEvD,IAAG,CAAC,MAAM,EAAE,QAAQ,EAAE,CAAC;YAErB,OAAO,KAAK,CAAC;QACf,CAAC;QAED,4CAA4C;QAC5C,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtB,IAAI,CAAC,YAAY,EAAE,CAAC;QAEpB,OAAO,IAAI,CAAC;IACd,CAAC;IAED;;;OAGG;IACI,KAAK;QAEV,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;QACvB,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;QAEtB,qCAAqC;QACrC,IAAG,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YAE1C,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;YAClD,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC;YAC1B,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;QAC1B,CAAC;IACH,CAAC;IAED;;OAEG;IACH,IAAW,OAAO;QAEhB,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;IAC3B,CAAC;IAED,mKAAmK;IACnK,oKAAoK;IAC5J,YAAY;QAElB,kGAAkG;QAClG,IAAG,IAAI,CAAC,SAAS,EAAE,CAAC;YAElB,OAAO;QACT,CAAC;QAED,iFAAiF;QACjF,OAAM,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;YAExB,0EAA0E;YAC1E,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YAEvD,IAAG,CAAC,MAAM,EAAE,QAAQ,EAAE,CAAC;gBAErB,OAAO;YACT,CAAC;YAED,qBAAqB;YACrB,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;YAEtB,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;YAEnC,IAAG,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;gBAE1B,uFAAuF;gBACvF,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC;gBAC1B,IAAI,CAAC,aAAa,GAAG,GAAS,EAAE;oBAE9B,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;oBACxB,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC;oBAC1B,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;oBACvB,IAAI,CAAC,OAAO,EAAE,EAAE,CAAC;oBACjB,IAAI,CAAC,YAAY,EAAE,CAAC;gBACtB,CAAC,CAAC;gBAEF,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;gBAEzC,OAAO;YACT,CAAC;YAED,mFAAmF;YACnF,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;YACvB,IAAI,CAAC,OAAO,EAAE,EAAE,CAAC;QACnB,CAAC;IACH,CAAC;CACF"}
1
+ {"version":3,"file":"backpressure.js","sourceRoot":"","sources":["../src/backpressure.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The homebridge-plugin-utils CLI, exposed to consumers via the `bin` field in `package.json`. A single cohesive module: the content-hash helper, the `prepareUi`,
4
+ * `prepareDocs`, and `prepareChrome` transforms, the `runCli` dispatcher, and the entry-point execution all live here with no inter-file relative VALUE imports.
5
+ *
6
+ * That single-file shape is deliberate, not incidental. A bin is invoked through an `npm`-managed symlink in `node_modules/.bin`; if the entry imported a sibling
7
+ * module by relative path AT LOAD TIME, that import would resolve against the symlink's directory under symlink-preserving or copied-package layouts and fail. With
8
+ * every runtime edge pointing only at `node:` builtins, there is nothing to fracture - the CLI runs identically whether reached directly, through a symlink, through
9
+ * a `file:` dependency, or under `--preserve-symlinks`. The lone `import type` of the renderer's signatures is fully erased by the compiler and emits no runtime edge,
10
+ * so it carries the SSOT types without reintroducing a load-time relative dependency; when `prepareDocs` actually needs the renderer it reaches it through a computed
11
+ * dynamic import the dispatch site supplies, the same indirection the `hblog` bin uses.
12
+ *
13
+ * The module is simultaneously the executable (run via the bin) and a side-effect-free library surface (`prepareUi` / `prepareDocs` / `prepareChrome` / `runCli` /
14
+ * `USAGE`) that the test suite imports. The entry block at the bottom only executes when this module is the program entry point, detected by comparing canonicalized
15
+ * real paths - see its comment for why a raw path comparison is insufficient.
16
+ *
17
+ * @module
18
+ */
19
+ import type { parseDocChromeManifest, parseProjectEntries, renderDevBadges, renderDocIndex, renderMasthead, renderProjects } from "../docChrome.ts";
20
+ import type { renderFeatureOptionsReference, spliceMarkedRegion } from "../featureOptions-docs.ts";
21
+ /**
22
+ * Usage banner shown on no command or an unknown command. Kept as an exported constant so tests assert against its text without
23
+ * coupling to formatting details and so README documentation can reference it via import rather than duplicate.
24
+ */
25
+ export declare const USAGE: string;
26
+ /**
27
+ * Mirror HBPU's compiled browser-side webUI into a plugin's homebridge-ui/public/lib directory under a content-hashed, version-named subdirectory. The subdir name
28
+ * combines the package's semver version with a short content hash of `dist/ui/` (e.g., `2.0.0-abc1234567890def`), so the browser's HTTP cache invalidates
29
+ * structurally on any content change rather than via per-file query-string hacks: the plugin's `index.html` reads the small manifest written alongside, then
30
+ * injects a trailing-slash importmap entry that prefixes every `./lib/` import with the hashed-versioned path. Transitive imports inherit the prefix through
31
+ * relative-URL resolution, so cache-busting reaches files the importmap never names.
32
+ *
33
+ * The content hash is what makes the maintainer-iteration use case work. A semver-only subdir name would stay constant across the maintainer's
34
+ * edit/rebuild/test cycle (version doesn't bump on every save), and the browser would happily serve cached copies of the stale URLs. Hashing the tree means any
35
+ * source change produces a different subdir name, which produces different URLs, which forces fresh fetches. Same code path serves the published-release case: a
36
+ * release ships with a fixed hash, and every consumer fetching it sees the same URL space and caches aggressively within it.
37
+ *
38
+ * Operates repeatably. A re-run against unchanged source content produces the same subdir name + same contents + same manifest. Stale prior-build subdirs are
39
+ * removed in the same pass; non-version-shaped entries in the destination are left untouched so a plugin's own files (assets, sibling tooling, README copies)
40
+ * survive across runs.
41
+ *
42
+ * @param args
43
+ * @param args.dest - The plugin's destination directory (typically `homebridge-ui/public/lib`). Created if missing.
44
+ * @param args.sourceRoot - Path to HBPU's package root (the directory containing `package.json` and `dist/`). The entry block resolves this from the CLI's own
45
+ * real path; tests pass a tmpdir populated with a synthetic HBPU layout.
46
+ *
47
+ * @throws When the source has not been built (`dist/ui/` missing), the source `package.json` lacks a `version` field, or the source `dist/ui` path exists but is not a
48
+ * directory.
49
+ */
50
+ export declare function prepareUi({ dest, sourceRoot }: {
51
+ dest: string;
52
+ sourceRoot: string;
53
+ }): Promise<void>;
54
+ /**
55
+ * Regenerate a plugin's Feature Options reference by projecting its live catalog through HBPU's shared renderer and splicing the result into the plugin's doc, in
56
+ * place between the shared `FEATURE OPTIONS:BEGIN` / `END` markers. This centralizes the read/splice/atomic-write orchestration so a plugin's `build-docs` script
57
+ * only needs a single line invoking this subcommand instead of a bespoke `*-gendocs.ts` shim.
58
+ *
59
+ * Pure-by-injection on `render` and `splice`: the renderer and the splice helper are passed in rather than imported statically, so this function is unit-testable
60
+ * against the real `featureOptions-docs.ts` exports without a built `dist/`, and the CLI's single-file no-static-relative-import discipline is preserved (the dispatch
61
+ * site reaches the renderer through a computed dynamic import). The catalog is loaded by dynamic import of its absolute path resolved to a `file:` URL, since a bare
62
+ * absolute path is not a valid ESM specifier on every platform. The module's required exports are validated up front so a mis-shaped catalog fails with a
63
+ * diagnostic naming the offending module and export rather than a downstream type error inside the renderer.
64
+ *
65
+ * The same catalog module may OPTIONALLY export scope hooks - `describeCategoryScope` and `describeOptionScope` - that the renderer threads through to contribute
66
+ * plugin-private scope prose (a device-scope line under a category heading, a suffix on an option's description cell). These are the annotated-plugin extension point
67
+ * (Protect/Access); a zero-config plugin (ratgdo) exports neither and documents every option unconditionally. Each hook is validated INDEPENDENTLY: if a module exports
68
+ * one under either name it MUST be a function, else this throws a framed diagnostic naming the module and the offending export; an absent hook is simply omitted, and
69
+ * the renderer already treats an absent hook as "omit cleanly". Because the hooks arrive through the dynamic-imported catalog namespace (never a static relative
70
+ * import), they preserve the bin's symlink-safe load-time edge discipline exactly as the catalog arrays do.
71
+ *
72
+ * The write is atomic: the new contents are staged in a sibling `.tmp` file and renamed over the doc. The rename is atomic on a single filesystem, so a crash
73
+ * mid-write can never leave a half-spliced doc behind - the file is either the prior content or the complete new content, never a truncated splice.
74
+ *
75
+ * @param args
76
+ * @param args.catalogModulePath - Absolute path to the plugin's compiled catalog module exporting `featureOptionCategories` (an array) and `featureOptions` (an
77
+ * object). Resolved to a `file:` URL before the dynamic import.
78
+ * @param args.docPath - Absolute path to the doc whose marked region is replaced (typically the plugin's `docs/FeatureOptions.md`).
79
+ * @param args.render - The injected {@link renderFeatureOptionsReference} from `featureOptions-docs.ts`.
80
+ * @param args.splice - The injected {@link spliceMarkedRegion} from `featureOptions-docs.ts`.
81
+ *
82
+ * @throws When the catalog module lacks `featureOptionCategories` (or it is not an array) or `featureOptions` (or it is not a non-null object), when it exports a
83
+ * present-but-non-function `describeCategoryScope` or `describeOptionScope`, and propagates the splice's own framed errors when the doc's marker pair is absent
84
+ * or ambiguous.
85
+ */
86
+ export declare function prepareDocs({ catalogModulePath, docPath, render, splice }: {
87
+ catalogModulePath: string;
88
+ docPath: string;
89
+ render: typeof renderFeatureOptionsReference;
90
+ splice: typeof spliceMarkedRegion;
91
+ }): Promise<void>;
92
+ interface DocChromeModule {
93
+ readonly DEV_BADGES_BEGIN: string;
94
+ readonly DEV_BADGES_END: string;
95
+ readonly DOCUMENTATION_BEGIN: string;
96
+ readonly DOCUMENTATION_END: string;
97
+ readonly MASTHEAD_BEGIN: string;
98
+ readonly MASTHEAD_END: string;
99
+ readonly PROJECTS_BEGIN: string;
100
+ readonly PROJECTS_END: string;
101
+ readonly parseDocChromeManifest: typeof parseDocChromeManifest;
102
+ readonly parseProjectEntries: typeof parseProjectEntries;
103
+ readonly renderDevBadges: typeof renderDevBadges;
104
+ readonly renderDocIndex: typeof renderDocIndex;
105
+ readonly renderMasthead: typeof renderMasthead;
106
+ readonly renderProjects: typeof renderProjects;
107
+ }
108
+ /**
109
+ * Stamp a plugin's documentation-chrome regions - the masthead, the documentation index, the dashboard badges, and the project list - across its README, every content
110
+ * doc, and its webUI Support tab, from one per-plugin manifest. This is the multi-region, multi-file counterpart to {@link prepareDocs}: where that regenerates one
111
+ * feature-options region in one doc, this regenerates several named regions in many files, so a plugin's masthead and navigation cannot drift between the surfaces that
112
+ * repeat them.
113
+ *
114
+ * Pure-by-injection like {@link prepareDocs}: the `docChrome` renderers and validator arrive through the injected `chrome` namespace and the splice helper through
115
+ * `splice`, so this function is unit-testable against the real exports without a built `dist/`, and the CLI's single-file no-static-relative-import discipline is
116
+ * preserved. `fetchImpl` is injected (defaulting to the global `fetch`) so the remote project-source path is testable without network access.
117
+ *
118
+ * The manifest is loaded (a typed module or a static JSON file), validated with a field-naming diagnostic, and its optional project source resolved to inline data. The
119
+ * per-file edit plan is then built - the README carries the masthead, the documentation index, and the dashboard badges; each content doc carries the masthead (unless
120
+ * its entry opts out) and a self-omitting footer index; the webUI, when present, carries the documentation index and the project list. Every region is spliced against
121
+ * its own marker pair through the shared, ambiguity-rejecting splice primitive.
122
+ *
123
+ * The write is all-or-nothing across files in the realistic failure mode. Splicing happens entirely in memory first, so a missing or ambiguous marker in any file
124
+ * aborts the run before a single write. The writes then proceed in two phases - every file's new content is staged in a sibling `.tmp`, and only once all temps exist
125
+ * are they renamed over their targets - so a staging failure leaves every original untouched and each promotion is an atomic rename.
126
+ *
127
+ * @param args
128
+ * @param args.chrome - The injected `docChrome` module namespace (its renderers, marker constants, and validators).
129
+ * @param args.fetchImpl - The `fetch` implementation used to resolve a remote project source. Defaults to the global `fetch`; tests inject a fake.
130
+ * @param args.manifestPath - Absolute path to the plugin's manifest - a compiled module or a `.json` file.
131
+ * @param args.pluginRoot - Absolute path to the plugin root that the manifest's surface and file references resolve against.
132
+ * @param args.splice - The injected {@link spliceMarkedRegion} from `featureOptions-docs.ts`.
133
+ *
134
+ * @throws When the manifest is mis-shaped, when a resolved project source is malformed, when a target file cannot be read, or when any region's marker pair is absent or
135
+ * ambiguous - propagating the splice's own framed errors so the dispatch site frames them uniformly.
136
+ */
137
+ export declare function prepareChrome({ chrome, fetchImpl, manifestPath, pluginRoot, splice }: {
138
+ chrome: DocChromeModule;
139
+ fetchImpl?: typeof fetch;
140
+ manifestPath: string;
141
+ pluginRoot: string;
142
+ splice: typeof spliceMarkedRegion;
143
+ }): Promise<void>;
144
+ /**
145
+ * Run the CLI against a synthetic argv vector. Returns the process exit code that the entry block propagates. Pure-by-injection: takes its `cwd`, `stderr`, and
146
+ * `sourceRoot` as arguments rather than reading them from globals, so tests exercise the full dispatch path against a captured stderr, a tmpdir source root, and a
147
+ * tmpdir working directory without ever touching `process.exit` or the real filesystem outside the tmpdir scope.
148
+ *
149
+ * @param args
150
+ * @param args.argv - Positional and flag arguments (typically `process.argv.slice(2)`).
151
+ * @param args.cwd - The working directory that relative subcommand path arguments resolve against. Production passes `process.cwd()`; tests pass a tmpdir.
152
+ * @param args.sourceRoot - Path to HBPU's package root (resolved from the CLI's real path by the entry block; tests pass a tmpdir).
153
+ * @param args.stderr - Stream-like sink for usage and error output. Production passes `process.stderr`; tests pass a captured-output collector.
154
+ *
155
+ * @returns The exit code: `0` on success or on an explicit no-arg invocation showing the usage banner, `1` on misuse or subcommand failure.
156
+ */
157
+ export declare function runCli({ argv, cwd, sourceRoot, stderr }: {
158
+ argv: readonly string[];
159
+ cwd: string;
160
+ sourceRoot: string;
161
+ stderr: {
162
+ write: (chunk: string) => unknown;
163
+ };
164
+ }): Promise<number>;
165
+ export {};
166
+ //# sourceMappingURL=index.d.ts.map