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,355 @@
1
+ #!/usr/bin/env node
2
+ /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
3
+ *
4
+ * cli/index.ts: The homebridge-plugin-utils command-line interface.
5
+ */
6
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
7
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
8
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
9
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
10
+ });
11
+ }
12
+ return path;
13
+ };
14
+ /**
15
+ * 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`
16
+ * and `prepareDocs` transforms, the `runCli` dispatcher, and the entry-point execution all live here with no inter-file relative VALUE imports.
17
+ *
18
+ * 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
19
+ * 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
20
+ * every runtime edge pointing only at `node:` builtins, there is nothing to fracture - the CLI runs identically whether reached directly, through a symlink, through
21
+ * 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,
22
+ * 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
23
+ * dynamic import the dispatch site supplies, the same indirection the `hblog` bin uses.
24
+ *
25
+ * The module is simultaneously the executable (run via the bin) and a side-effect-free library surface (`prepareUi` / `prepareDocs` / `runCli` / `USAGE`) that the
26
+ * test suite imports. The entry block at the bottom only executes when this module is the program entry point, detected by comparing canonicalized real paths - see
27
+ * its comment for why a raw path comparison is insufficient.
28
+ *
29
+ * @module
30
+ */
31
+ import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
32
+ import { dirname, join, relative, resolve, sep } from "node:path";
33
+ import { fileURLToPath, pathToFileURL } from "node:url";
34
+ import { createHash } from "node:crypto";
35
+ import { parseArgs } from "node:util";
36
+ import { realpathSync } from "node:fs";
37
+ // Semver-shaped subdir names that {@link prepareUi} owns. Only entries matching this pattern are candidates for the stale-build sweep; anything else in the
38
+ // destination is left alone. Pattern matches `MAJOR.MINOR.PATCH` plus the optional pre-release (`-...`) and build-metadata (`+...`) segments semver permits, so a
39
+ // plugin author who happens to name a subdir `assets` or `i18n` is never at risk of having it removed. The hash-suffixed shape this CLI writes (e.g.,
40
+ // `2.0.0-abc1234567890def`) falls into the pre-release segment, so the same pattern catches stale released-version directories from prior tooling and stale
41
+ // hash-suffixed directories from prior runs alike.
42
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
43
+ // Truncated SHA-256 length used for the content-hash segment of the subdir name. 16 hex chars (64 bits) put the birthday-paradox collision threshold near ~2^32
44
+ // distinct builds (a chance collision between any specific pair is 2^-64), more than sufficient for cache-busting. Matches the truncation convention modern bundlers
45
+ // (Vite, Next.js, esbuild, webpack) use for the same purpose: short enough to scan visually in a path, long enough that a chance collision between two builds is
46
+ // astronomically unlikely.
47
+ const HASH_LENGTH = 16;
48
+ /**
49
+ * Usage banner shown on no command or an unknown command. Kept as an exported constant so tests assert against its text without
50
+ * coupling to formatting details and so README documentation can reference it via import rather than duplicate.
51
+ */
52
+ export const USAGE = "Usage: homebridge-plugin-utils <command> [options]\n\n" +
53
+ "Commands:\n prepare-ui <destination> Mirror HBPU's webUI into the plugin's lib directory.\n" +
54
+ " prepare-docs <catalog-module> [--doc <path>] Generate the Feature Options reference into the plugin's docs.\n";
55
+ /**
56
+ * Compute a deterministic content hash over `root`'s file tree. Walks every file in lexicographic order of relative POSIX path so two runs against the same content
57
+ * produce the same hash regardless of underlying filesystem ordering or the absolute path the tree sits at. Mixes both the relative path and the file bytes into
58
+ * the hash so renames produce a different output even when content bytes are unchanged - that is the correct cache-bust semantic, since a rename is a visible
59
+ * difference to any consumer importing the file by name.
60
+ *
61
+ * The null-byte (`\0`) delimiters between path and content prevent the synthetic boundary-blurring case where a long path concatenated with a short content could
62
+ * share its hash input with a shorter path concatenated with a longer content. SHA-256's preimage resistance covers anything more contrived, but the explicit
63
+ * delimiter is the discipline that makes the hash input unambiguous on its face.
64
+ *
65
+ * Returns the first {@link HASH_LENGTH} hex characters of the hash. Truncation is deliberate: full SHA-256 (64 hex chars) would bloat the URL path and the
66
+ * filesystem display without earning meaningful collision resistance for this use case.
67
+ *
68
+ * @param root - The directory whose tree contents are hashed.
69
+ *
70
+ * @returns The truncated hex hash, suitable for use as a path segment.
71
+ */
72
+ async function computeContentHash(root) {
73
+ // Node 22's recursive readdir plus `withFileTypes: true` gives us a flat Dirent[] for the entire tree in one syscall, eliminating the manual stack-walk that
74
+ // older Node versions required. `parentPath` is the non-deprecated way to recover each entry's directory (the `path` alias was deprecated in Node 20.12 /
75
+ // 21.4); using it here keeps the implementation aligned with current Node idioms.
76
+ const entries = await readdir(root, { recursive: true, withFileTypes: true });
77
+ const filePaths = entries
78
+ .filter((entry) => entry.isFile())
79
+ .map((entry) => join(entry.parentPath, entry.name))
80
+ .toSorted();
81
+ // Read every file's bytes in parallel, then feed them into the hasher sequentially. Parallel reads keep the I/O latency bound to the slowest single file rather
82
+ // than the sum of all files; the sequential hash update is required because the hasher is stateful and must consume bytes in the canonical sorted order to
83
+ // produce the same digest every run.
84
+ const fileBytes = await Promise.all(filePaths.map(async (path) => ({ bytes: await readFile(path), path })));
85
+ const hasher = createHash("sha256");
86
+ for (const { bytes, path } of fileBytes) {
87
+ // Normalize separators to POSIX form so a hash computed on a Windows build matches a hash computed on Linux for the same tree contents. The relative path
88
+ // anchors the hash to the tree structure (not the absolute location), so the same source tree built into `/tmp/a/dist/ui` and `/var/build/b/dist/ui` produces
89
+ // an identical hash.
90
+ const rel = relative(root, path).split(sep).join("/");
91
+ hasher.update(rel);
92
+ hasher.update("\0");
93
+ hasher.update(bytes);
94
+ hasher.update("\0");
95
+ }
96
+ return hasher.digest("hex").slice(0, HASH_LENGTH);
97
+ }
98
+ /**
99
+ * 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
100
+ * 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
101
+ * 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
102
+ * injects a trailing-slash importmap entry that prefixes every `./lib/` import with the hashed-versioned path. Transitive imports inherit the prefix through
103
+ * relative-URL resolution, so cache-busting reaches files the importmap never names.
104
+ *
105
+ * The content hash is the load-bearing piece for the maintainer-iteration use case. A semver-only subdir name would stay constant across the maintainer's
106
+ * 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
107
+ * source change produces a different subdir name, which produces different URLs, which forces fresh fetches. Same code path serves the published-release case: a
108
+ * release ships with a fixed hash, and every consumer fetching it sees the same URL space and caches aggressively within it.
109
+ *
110
+ * Operates idempotently. A re-run against unchanged source content produces the same subdir name + same contents + same manifest. Stale prior-build subdirs are
111
+ * 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)
112
+ * survive across runs.
113
+ *
114
+ * @param args
115
+ * @param args.dest - The plugin's destination directory (typically `homebridge-ui/public/lib`). Created if missing.
116
+ * @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
117
+ * real path; tests pass a tmpdir populated with a synthetic HBPU layout.
118
+ *
119
+ * @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
120
+ * directory.
121
+ */
122
+ export async function prepareUi({ dest, sourceRoot }) {
123
+ const sourceUiDir = join(sourceRoot, "dist", "ui");
124
+ const sourcePackageJson = join(sourceRoot, "package.json");
125
+ // Validate the source side first so failures name the corrective action rather than the operation we couldn't perform. The two ways the source can be wrong are
126
+ // structurally distinct: `dist/ui` missing means HBPU hasn't been built; `package.json` missing or version-less means the package is malformed. Both surface as
127
+ // typed errors with the path that diagnosed the problem.
128
+ let sourceUiStat;
129
+ try {
130
+ sourceUiStat = await stat(sourceUiDir);
131
+ }
132
+ catch {
133
+ throw new Error("HBPU has not been built: " + sourceUiDir + " is missing. Run `npm run build` in HBPU first.");
134
+ }
135
+ if (!sourceUiStat.isDirectory()) {
136
+ throw new Error("HBPU source path is not a directory: " + sourceUiDir + ".");
137
+ }
138
+ const packageManifestRaw = await readFile(sourcePackageJson, "utf8");
139
+ const packageManifest = JSON.parse(packageManifestRaw);
140
+ const version = packageManifest.version;
141
+ if (!version) {
142
+ throw new Error("HBPU package.json at " + sourcePackageJson + " has no version field.");
143
+ }
144
+ // Compute the content hash before any destination work begins so the source side is fully validated and characterized before we touch the plugin's directory.
145
+ // The subdir name combines version + hash: version stays the human-readable, operator-recognizable segment; hash supplies the change-detection that makes the
146
+ // cache invalidation structurally complete across both maintainer-iteration and published-release use cases.
147
+ const hash = await computeContentHash(sourceUiDir);
148
+ const subdir = version + "-" + hash;
149
+ // Resolve the destination to an absolute path up front so every subsequent path operation reads against the same canonical base. The destination is created via
150
+ // `mkdir({ recursive: true })` rather than relying on `cp` to lazily create it, so the readdir-based stale-build sweep below can run against a known-extant
151
+ // directory on every invocation (including the first run against a never-before-existing path).
152
+ const absDest = resolve(dest);
153
+ await mkdir(absDest, { recursive: true });
154
+ const versionedDest = join(absDest, subdir);
155
+ // Idempotent reset of the versioned subdir. Wipe first so the copy step always starts from a known-empty target; the same-content idempotency property is upheld
156
+ // by the hash being a deterministic function of the source, so re-running with unchanged content produces the same subdir name + same contents + same manifest,
157
+ // even though the implementation re-does the work. `force: true` swallows ENOENT so a first run against an empty destination doesn't surface the missing-target
158
+ // as an error.
159
+ await rm(versionedDest, { force: true, recursive: true });
160
+ await cp(sourceUiDir, versionedDest, { recursive: true });
161
+ // Destination manifest sits at the top of `dest`, NOT inside the versioned subdir. The plugin's `index.html` reads it (with `cache: "no-store"`) to discover
162
+ // which subdir to load from. Every manifest field serves a distinct purpose: `version` for human-readable identification, `hash` for change-detection, `subdir`
163
+ // for the canonical "where to load from" answer that consumers reference directly without reconstructing the join. The join convention stays here in the
164
+ // producer; if we ever change it (different delimiter, different segment order), no consumer breaks. Pretty-printed JSON because the file is human-read often
165
+ // enough to matter (debugging sessions, README screenshots, support requests).
166
+ await writeFile(join(absDest, "manifest.json"), JSON.stringify({ hash, subdir, version }, null, 2) + "\n");
167
+ // Sweep stale versioned subdirs from prior runs. The semver-shape filter is the discipline that keeps the sweep narrow: anything in the destination that doesn't
168
+ // look like a version we own (plugin's own files, sibling tools, hand-curated content) survives untouched. The current subdir is skipped here since we just
169
+ // (re)created it above; the loop targets only OLDER builds left over from prior runs (different version, different hash, or both).
170
+ const entries = await readdir(absDest, { withFileTypes: true });
171
+ await Promise.all(entries
172
+ .filter((entry) => entry.isDirectory() && VERSION_PATTERN.test(entry.name) && (entry.name !== subdir))
173
+ .map((entry) => rm(join(absDest, entry.name), { force: true, recursive: true })));
174
+ }
175
+ /**
176
+ * 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
177
+ * place between the shared `FEATURE OPTIONS:BEGIN` / `END` markers. This centralizes the read/splice/atomic-write orchestration so a plugin's `build-docs` script
178
+ * only needs a single line invoking this subcommand instead of a bespoke `*-gendocs.ts` shim.
179
+ *
180
+ * 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
181
+ * 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
182
+ * 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
183
+ * 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
184
+ * diagnostic naming the offending module and export rather than a downstream type error inside the renderer.
185
+ *
186
+ * The same catalog module may OPTIONALLY export scope hooks - `describeCategoryScope` and `describeOptionScope` - that the renderer threads through to contribute
187
+ * 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
188
+ * (Protect/Access); a zero-config plugin (ratgdo) exports neither and documents every option unconditionally. Each hook is validated INDEPENDENTLY: if a module exports
189
+ * 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
190
+ * the renderer already treats an absent hook as "omit cleanly". Because the hooks arrive through the dynamic-imported catalog namespace (never a static relative
191
+ * import), they preserve the bin's symlink-safe load-time edge discipline exactly as the catalog arrays do.
192
+ *
193
+ * 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
194
+ * 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.
195
+ *
196
+ * @param args
197
+ * @param args.catalogModulePath - Absolute path to the plugin's compiled catalog module exporting `featureOptionCategories` (an array) and `featureOptions` (an
198
+ * object). Resolved to a `file:` URL before the dynamic import.
199
+ * @param args.docPath - Absolute path to the doc whose marked region is replaced (typically the plugin's `docs/FeatureOptions.md`).
200
+ * @param args.render - The injected {@link renderFeatureOptionsReference} from `featureOptions-docs.ts`.
201
+ * @param args.splice - The injected {@link spliceMarkedRegion} from `featureOptions-docs.ts`.
202
+ *
203
+ * @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
204
+ * present-but-non-function `describeCategoryScope` or `describeOptionScope`, and propagates the splice's own framed errors when the doc's marker pair is absent
205
+ * or ambiguous.
206
+ */
207
+ export async function prepareDocs({ catalogModulePath, docPath, render, splice }) {
208
+ // Load the catalog by dynamic import. A bare absolute filesystem path is not a portable ESM specifier (Windows drive letters in particular are mis-parsed), so we
209
+ // convert it to a `file:` URL first - the same indirection the `hblog` bin uses to reach its sibling library from a single-file launcher. The namespace is typed with
210
+ // the required catalog exports plus the OPTIONAL scope hooks, each `unknown` until validated below - the same honest-until-checked shape the catalog arrays
211
+ // carry, so nothing reaches the renderer as a mis-typed value.
212
+ const catalog = await import(__rewriteRelativeImportExtension(pathToFileURL(catalogModulePath).href));
213
+ // Validate the catalog's required exports before rendering so a mis-built or wrong-module path fails with a diagnostic that names what is wrong and where,
214
+ // rather than surfacing as an opaque type error deep inside the renderer's traversal.
215
+ if (!Array.isArray(catalog.featureOptionCategories)) {
216
+ throw new Error("Catalog module " + catalogModulePath + " does not export a `featureOptionCategories` array.");
217
+ }
218
+ if ((typeof catalog.featureOptions !== "object") || (catalog.featureOptions === null)) {
219
+ throw new Error("Catalog module " + catalogModulePath + " does not export a `featureOptions` object.");
220
+ }
221
+ // Validate every OPTIONAL scope hook independently. Each is present-but-must-be-a-function or absent: a present non-function is a mis-shaped catalog and fails
222
+ // with a diagnostic naming the module and the offending export (the same framing the required exports use), while an absent hook stays `undefined` and is inert -
223
+ // the renderer treats a missing hook as "omit cleanly", so the zero-hook path (ratgdo) is unchanged. We check before casting so the cast to the renderer's hook
224
+ // parameter type only happens once the value is known to be callable, mirroring how the catalog arrays are cast only after their shape guard.
225
+ if ((catalog.describeCategoryScope !== undefined) && (typeof catalog.describeCategoryScope !== "function")) {
226
+ throw new Error("Catalog module " + catalogModulePath + " exports a non-function `describeCategoryScope`.");
227
+ }
228
+ if ((catalog.describeOptionScope !== undefined) && (typeof catalog.describeOptionScope !== "function")) {
229
+ throw new Error("Catalog module " + catalogModulePath + " exports a non-function `describeOptionScope`.");
230
+ }
231
+ // Render the live catalog into the markdown fragment. The optional scope hooks are forwarded through alongside the catalog; an `undefined` hook is inert, so a
232
+ // zero-hook catalog renders cleanly. The renderer is the single source of truth for the index, the per-category tables, and the scope prose the optional hooks
233
+ // contribute, while this function only wires the catalog and its hooks to it. Each hook is cast to the renderer's parameter type only after its function
234
+ // guard above, the same discipline the catalog arrays follow.
235
+ const reference = render({ categories: catalog.featureOptionCategories,
236
+ describeCategoryScope: catalog.describeCategoryScope,
237
+ describeOptionScope: catalog.describeOptionScope,
238
+ options: catalog.featureOptions });
239
+ // Read the existing doc and splice the rendered fragment in place between the markers, leaving the hand-written header and intro untouched. The splice throws its
240
+ // own framed errors on a missing or ambiguous marker pair; we let those propagate so the dispatch site frames them uniformly.
241
+ const source = await readFile(docPath, "utf8");
242
+ const updated = splice(source, reference);
243
+ // Atomic write: stage in a sibling temp file, then rename over the doc.
244
+ await writeFile(docPath + ".tmp", updated, "utf8");
245
+ await rename(docPath + ".tmp", docPath);
246
+ }
247
+ /**
248
+ * 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
249
+ * `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
250
+ * tmpdir working directory without ever touching `process.exit` or the real filesystem outside the tmpdir scope.
251
+ *
252
+ * @param args
253
+ * @param args.argv - Positional and flag arguments (typically `process.argv.slice(2)`).
254
+ * @param args.cwd - The working directory that relative subcommand path arguments resolve against. Production passes `process.cwd()`; tests pass a tmpdir.
255
+ * @param args.sourceRoot - Path to HBPU's package root (resolved from the CLI's real path by the entry block; tests pass a tmpdir).
256
+ * @param args.stderr - Stream-like sink for usage and error output. Production passes `process.stderr`; tests pass a captured-output collector.
257
+ *
258
+ * @returns The exit code: `0` on success or on an explicit no-arg invocation showing the usage banner, `1` on misuse or subcommand failure.
259
+ */
260
+ export async function runCli({ argv, cwd, sourceRoot, stderr }) {
261
+ // parseArgs runs in strict mode, so an unrecognized flag (e.g. a typo'd `--docs`) throws `ERR_PARSE_ARGS_UNKNOWN_OPTION` here rather than falling through to the
262
+ // usage banner. That throw is intentionally uncaught - the per-case try/catch blocks below wrap only the subcommand work - so it surfaces as a rejected `runCli`
263
+ // promise at the entry point, distinct from the default case's banner handling for an unknown positional command.
264
+ const { positionals, values } = parseArgs({ allowPositionals: true, args: [...argv], options: { doc: { type: "string" } }, strict: true });
265
+ const [command, ...rest] = positionals;
266
+ switch (command) {
267
+ case "prepare-ui": {
268
+ const [destination] = rest;
269
+ if (!destination) {
270
+ stderr.write("homebridge-plugin-utils prepare-ui: missing required destination argument.\n");
271
+ return 1;
272
+ }
273
+ try {
274
+ await prepareUi({ dest: destination, sourceRoot });
275
+ }
276
+ catch (error) {
277
+ stderr.write("homebridge-plugin-utils prepare-ui: " + (error instanceof Error ? error.message : String(error)) + "\n");
278
+ return 1;
279
+ }
280
+ return 0;
281
+ }
282
+ case "prepare-docs": {
283
+ const [catalogArg] = rest;
284
+ if (!catalogArg) {
285
+ stderr.write("homebridge-plugin-utils prepare-docs: missing required catalog-module argument.\n");
286
+ return 1;
287
+ }
288
+ // Resolve both paths against the injected working directory. The catalog argument is the plugin's compiled options module; the doc defaults to the family's
289
+ // canonical `docs/FeatureOptions.md` and is overridable through `--doc` for a plugin that ships its reference elsewhere.
290
+ const catalogModulePath = resolve(cwd, catalogArg);
291
+ const docPath = resolve(cwd, values.doc ?? "docs/FeatureOptions.md");
292
+ // Reach HBPU's own renderer through a computed dynamic import of its compiled module - never a static relative import - so the single-file bin stays
293
+ // symlink-safe (it imports only `node:` builtins at load time). A failed import here means HBPU itself has not been built, which is a distinct, actionable
294
+ // condition from a downstream render/splice failure, so we frame it separately and point at the corrective action.
295
+ const rendererPath = join(sourceRoot, "dist", "featureOptions-docs.js");
296
+ let renderer;
297
+ try {
298
+ renderer = await import(__rewriteRelativeImportExtension(pathToFileURL(rendererPath).href));
299
+ }
300
+ catch {
301
+ stderr.write("homebridge-plugin-utils prepare-docs: HBPU has not been built: " + rendererPath + " is missing. Run `npm run build` in HBPU first.\n");
302
+ return 1;
303
+ }
304
+ try {
305
+ await prepareDocs({ catalogModulePath, docPath, render: renderer.renderFeatureOptionsReference, splice: renderer.spliceMarkedRegion });
306
+ }
307
+ catch (error) {
308
+ stderr.write("homebridge-plugin-utils prepare-docs: " + (error instanceof Error ? error.message : String(error)) + "\n");
309
+ return 1;
310
+ }
311
+ return 0;
312
+ }
313
+ default: {
314
+ stderr.write(USAGE);
315
+ // A bare `homebridge-plugin-utils` invocation (no command at all) is treated as a help request and exits 0; an unknown command is a misuse and exits 1. Both
316
+ // write the usage banner so the user sees the same prompt either way - only the exit code differentiates.
317
+ return command ? 1 : 0;
318
+ }
319
+ }
320
+ }
321
+ /**
322
+ * Decide whether this module is the program entry point (run as the bin) versus imported as a library (by the test suite). Canonicalizes the real path of both the
323
+ * launch path (`process.argv[1]`) and this module's own URL before comparing them.
324
+ *
325
+ * The realpath normalization is the load-bearing detail. npm exposes a bin as a symlink in `node_modules/.bin`, so under default Node the launch path is the
326
+ * symlink while `import.meta.url` is the resolved target - a raw string comparison never matches and the CLI silently does nothing. Canonicalizing both sides
327
+ * collapses that indirection (and any `file:`-dependency, copied-package, or `--preserve-symlinks` layout) to a single real path, so the check holds however the
328
+ * launcher reached this file. We keep this explicit realpath comparison rather than deferring to `import.meta.main`: the symlink and copied-package indirection is the
329
+ * load-bearing concern the entry-point check exists to handle, and resolving it explicitly keeps that handling visible at the call site.
330
+ *
331
+ * @returns `true` when invoked as the program entry, `false` when imported or when the launch path cannot be resolved.
332
+ */
333
+ function isEntryPoint() {
334
+ const entryPath = process.argv[1];
335
+ if (!entryPath) {
336
+ return false;
337
+ }
338
+ try {
339
+ return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url));
340
+ }
341
+ catch {
342
+ // A realpath throws only when a path does not resolve on disk - not a state a genuine entry-point invocation reaches, so treat it as "not the entry."
343
+ return false;
344
+ }
345
+ }
346
+ // Execute the CLI when this module is the program entry point. When imported by the test suite instead, `isEntryPoint()` is false and the module exposes
347
+ // `prepareUi` / `prepareDocs` / `runCli` / `USAGE` as a side-effect-free library surface.
348
+ if (isEntryPoint()) {
349
+ // Resolve HBPU's package root from this file's real location. The compiled CLI sits at `dist/cli/index.js`; walking two segments up from its real directory
350
+ // reaches the package root regardless of how the package is installed (a plugin's `node_modules`, a workspace symlink, a global install) or which Node
351
+ // symlink-resolution mode is in effect - the realpath has already collapsed any indirection.
352
+ const sourceRoot = resolve(dirname(realpathSync(fileURLToPath(import.meta.url))), "..", "..");
353
+ process.exit(await runCli({ argv: process.argv.slice(2), cwd: process.cwd(), sourceRoot, stderr: process.stderr }));
354
+ }
355
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * A reusable, controllable {@link Clock} test double.
3
+ *
4
+ * The {@link Clock} seam in `clock.ts` exists so a consuming plugin's time-dependent code can be driven without real wall-clock waits. This module ships the fake that
5
+ * cashes that in: a {@link TestClock} over a virtual timeline a test advances explicitly. `now()` returns the virtual time; `delay()` registers a pending wait that
6
+ * resolves only when {@link TestClock.advance} crosses its deadline, or rejects when its signal aborts - matching `node:timers/promises` `setTimeout`'s `AbortError`
7
+ * shape. No real timers and no wall-clock are used, so a consumer's pacing/timeout/duration path runs deterministically and instantly under test.
8
+ *
9
+ * The double builds on the library's own primitives rather than hand-rolling them: {@link onAbort} wires the abort listener and yields the `Disposable` that detaches
10
+ * it, and `Promise.withResolvers` captures each pending wait's deferred. The abort listener is detached on EITHER resolution path (deadline-crossed or aborted), so no
11
+ * listener leaks onto a long-lived signal across many short waits.
12
+ *
13
+ * @module
14
+ */
15
+ import type { Clock } from "./clock.ts";
16
+ /**
17
+ * A controllable {@link Clock} double over virtual time. `now()` returns the current virtual time; `delay()` registers a pending wait that resolves only when
18
+ * {@link TestClock.advance} crosses its deadline (in ascending-deadline order), or rejects with an `AbortError` (matching `node:timers/promises` `setTimeout` - `name`
19
+ * `"AbortError"`, `code` `"ABORT_ERR"`, NOT the signal's reason) when its signal aborts. No real timers or wall-clock are used.
20
+ *
21
+ * The virtual time is a RELATIVE timeline seeded at `start` (default `0`), NOT real epoch milliseconds. A consumer that compares `now()` against an absolute real-epoch
22
+ * constant would diverge; consumers must only compare `now()` values to each other (deriving elapsed intervals from differences), which is the only use a
23
+ * `Date.now()`-style read serves in the consuming pacing path - all its time reads come from the one injected clock.
24
+ *
25
+ * @example
26
+ *
27
+ * ```ts
28
+ * import { TestClock } from "homebridge-plugin-utils";
29
+ *
30
+ * const clock = new TestClock();
31
+ *
32
+ * const waited = clock.delay(100);
33
+ *
34
+ * // Nothing resolves until virtual time crosses the deadline.
35
+ * clock.advance(100);
36
+ *
37
+ * await waited;
38
+ * ```
39
+ *
40
+ * @see Clock
41
+ *
42
+ * @category Testing
43
+ */
44
+ export declare class TestClock implements Clock {
45
+ #private;
46
+ /**
47
+ * Construct a clock seeded at `start` (default `0`). The seed is the initial value `now()` returns; `advance` moves it forward (or back, for a negative delta).
48
+ *
49
+ * @param start - The initial virtual time, in the consumer's relative timeline. Defaults to `0`.
50
+ */
51
+ constructor(start?: number);
52
+ /**
53
+ * Advance virtual time by `ms` and resolve every delay whose deadline the new time has reached. The delta is applied regardless of sign, so a negative `ms` moves time
54
+ * backward; `advance(0)` moves time nowhere but STILL flushes any already-due entry (a `delay(0)` or a `delay` with a non-positive `ms`), so a zero or negative delay
55
+ * is never a lost wakeup.
56
+ *
57
+ * Due entries resolve in ASCENDING deadline order; entries that share a deadline keep their FIFO registration order, because the snapshot is taken before any removal
58
+ * and the numeric sort is stable - matching how `setTimeout` fires equal-deadline timers in scheduling order. Each due entry is removed by identity and has its abort
59
+ * listener detached before it resolves, so the resolve path leaks no listener and the iteration is immune to the index shifts a forward in-place splice would cause.
60
+ *
61
+ * @param ms - The amount of virtual time to advance, in milliseconds. May be zero or negative.
62
+ */
63
+ advance(ms: number): void;
64
+ /**
65
+ * Register a delay that resolves when virtual time reaches `this.now() + ms`, or rejects with an `AbortError` (matching `node:timers/promises`) if `init.signal` aborts
66
+ * first. A non-positive `ms` yields a deadline at or before the current time, which the very next {@link TestClock.advance} (including `advance(0)`) flushes.
67
+ *
68
+ * A pre-aborted signal rejects on the executor's microtask exactly as `systemClock` does (NOT a synchronous throw): {@link onAbort} fires the handler inline, which
69
+ * removes the just-registered entry and rejects, so the entry never lingers in `pending`.
70
+ *
71
+ * @param ms - The delay, in milliseconds. May be zero or negative (flushed by the next `advance`).
72
+ * @param init - Optional init options. A supplied `signal` rejects the wait with an `AbortError` when it aborts.
73
+ *
74
+ * @returns A promise that resolves when the deadline is crossed, or rejects with an `AbortError` if the signal aborts first.
75
+ */
76
+ delay(ms: number, init?: {
77
+ signal?: AbortSignal;
78
+ }): Promise<void>;
79
+ /**
80
+ * Return the current virtual time. Compare these values to each other to derive elapsed intervals - they are a relative timeline, not real epoch milliseconds.
81
+ *
82
+ * @returns The current virtual time.
83
+ */
84
+ now(): number;
85
+ /**
86
+ * The number of registered delays that have neither resolved nor rejected. A test reads this to assert a consumer registered its waits and later cleared them (no
87
+ * leak).
88
+ *
89
+ * @returns The count of unsettled delays.
90
+ */
91
+ get pending(): number;
92
+ }
93
+ //# sourceMappingURL=clock-double.d.ts.map
@@ -0,0 +1,141 @@
1
+ /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * clock-double.ts: A reusable, controllable Clock test double - virtual time a test advances explicitly - for the injectable Clock seam in clock.ts.
4
+ */
5
+ import { onAbort } from "./util.js";
6
+ /**
7
+ * Construct the rejection a {@link TestClock} `delay` produces when its signal aborts, matching `node:timers/promises` `setTimeout` exactly: a plain `Error` whose `name`
8
+ * is `"AbortError"` and whose `code` is the STRING `"ABORT_ERR"`. The real primitive's rejection is a dedicated internal class (not a `DOMException`, whose `code` is the
9
+ * numeric `20`, and there is no constructable `AbortError` global), so the double cannot match the constructor or prototype identity - it matches the observable `name`
10
+ * and `code` a consumer discriminates on, which is the contract that matters.
11
+ *
12
+ * @returns The `AbortError`-shaped rejection.
13
+ */
14
+ function abortError() {
15
+ const error = new Error("The operation was aborted");
16
+ error.name = "AbortError";
17
+ // `code` is not a standard `Error` field, so assign it through an indexed widening rather than declaring a one-off subclass. The STRING value is what the real
18
+ // `node:timers/promises` rejection carries and what a consumer's `error.code === "ABORT_ERR"` check reads.
19
+ error.code = "ABORT_ERR";
20
+ return error;
21
+ }
22
+ /**
23
+ * A controllable {@link Clock} double over virtual time. `now()` returns the current virtual time; `delay()` registers a pending wait that resolves only when
24
+ * {@link TestClock.advance} crosses its deadline (in ascending-deadline order), or rejects with an `AbortError` (matching `node:timers/promises` `setTimeout` - `name`
25
+ * `"AbortError"`, `code` `"ABORT_ERR"`, NOT the signal's reason) when its signal aborts. No real timers or wall-clock are used.
26
+ *
27
+ * The virtual time is a RELATIVE timeline seeded at `start` (default `0`), NOT real epoch milliseconds. A consumer that compares `now()` against an absolute real-epoch
28
+ * constant would diverge; consumers must only compare `now()` values to each other (deriving elapsed intervals from differences), which is the only use a
29
+ * `Date.now()`-style read serves in the consuming pacing path - all its time reads come from the one injected clock.
30
+ *
31
+ * @example
32
+ *
33
+ * ```ts
34
+ * import { TestClock } from "homebridge-plugin-utils";
35
+ *
36
+ * const clock = new TestClock();
37
+ *
38
+ * const waited = clock.delay(100);
39
+ *
40
+ * // Nothing resolves until virtual time crosses the deadline.
41
+ * clock.advance(100);
42
+ *
43
+ * await waited;
44
+ * ```
45
+ *
46
+ * @see Clock
47
+ *
48
+ * @category Testing
49
+ */
50
+ export class TestClock {
51
+ // The current virtual time. Seeded by the constructor and moved only by `advance`.
52
+ #now;
53
+ // The registered, not-yet-settled delays. An entry leaves this list exactly once - either when `advance` crosses its deadline or when its signal aborts - via
54
+ // `#remove`, which splices by identity so a mixed resolve-and-abort sequence never strands or mis-removes an entry.
55
+ #pending = [];
56
+ /**
57
+ * Construct a clock seeded at `start` (default `0`). The seed is the initial value `now()` returns; `advance` moves it forward (or back, for a negative delta).
58
+ *
59
+ * @param start - The initial virtual time, in the consumer's relative timeline. Defaults to `0`.
60
+ */
61
+ constructor(start = 0) {
62
+ this.#now = start;
63
+ }
64
+ /**
65
+ * Advance virtual time by `ms` and resolve every delay whose deadline the new time has reached. The delta is applied regardless of sign, so a negative `ms` moves time
66
+ * backward; `advance(0)` moves time nowhere but STILL flushes any already-due entry (a `delay(0)` or a `delay` with a non-positive `ms`), so a zero or negative delay
67
+ * is never a lost wakeup.
68
+ *
69
+ * Due entries resolve in ASCENDING deadline order; entries that share a deadline keep their FIFO registration order, because the snapshot is taken before any removal
70
+ * and the numeric sort is stable - matching how `setTimeout` fires equal-deadline timers in scheduling order. Each due entry is removed by identity and has its abort
71
+ * listener detached before it resolves, so the resolve path leaks no listener and the iteration is immune to the index shifts a forward in-place splice would cause.
72
+ *
73
+ * @param ms - The amount of virtual time to advance, in milliseconds. May be zero or negative.
74
+ */
75
+ advance(ms) {
76
+ this.#now += ms;
77
+ // Snapshot the due entries BEFORE mutating `#pending`, then sort them into deadline order. Filtering off a live array while removing from it would shift indices and
78
+ // strand entries; the snapshot first decouples the iteration from the removal. The sort is a stable numeric comparator, so equal deadlines preserve FIFO order.
79
+ const due = this.#pending.filter((entry) => entry.deadline <= this.#now).sort((a, b) => a.deadline - b.deadline);
80
+ for (const entry of due) {
81
+ // Remove by identity first so a re-entrant observer sees the correct `pending` count, then detach the abort listener (present only when this delay had a signal) so
82
+ // the resolve path leaves no listener on a long-lived signal, then settle the caller's promise.
83
+ this.#remove(entry);
84
+ entry.dispose?.[Symbol.dispose]();
85
+ entry.resolve();
86
+ }
87
+ }
88
+ /**
89
+ * Register a delay that resolves when virtual time reaches `this.now() + ms`, or rejects with an `AbortError` (matching `node:timers/promises`) if `init.signal` aborts
90
+ * first. A non-positive `ms` yields a deadline at or before the current time, which the very next {@link TestClock.advance} (including `advance(0)`) flushes.
91
+ *
92
+ * A pre-aborted signal rejects on the executor's microtask exactly as `systemClock` does (NOT a synchronous throw): {@link onAbort} fires the handler inline, which
93
+ * removes the just-registered entry and rejects, so the entry never lingers in `pending`.
94
+ *
95
+ * @param ms - The delay, in milliseconds. May be zero or negative (flushed by the next `advance`).
96
+ * @param init - Optional init options. A supplied `signal` rejects the wait with an `AbortError` when it aborts.
97
+ *
98
+ * @returns A promise that resolves when the deadline is crossed, or rejects with an `AbortError` if the signal aborts first.
99
+ */
100
+ delay(ms, init) {
101
+ const { promise, reject, resolve } = Promise.withResolvers();
102
+ const entry = { deadline: this.#now + ms, resolve };
103
+ // Register the entry FIRST so a pre-aborted signal's inline `onAbort` handler (below) can find and remove it. `onAbort` runs the handler synchronously when the
104
+ // signal is already aborted, so for a pre-aborted signal the entry is pushed and then immediately removed-and-rejected within this call - settling on the executor's
105
+ // microtask, identical to `systemClock`. When no signal is supplied, `dispose` stays undefined and the entry only ever leaves via `advance`.
106
+ this.#pending.push(entry);
107
+ if (init?.signal !== undefined) {
108
+ entry.dispose = onAbort(init.signal, () => {
109
+ this.#remove(entry);
110
+ reject(abortError());
111
+ });
112
+ }
113
+ return promise;
114
+ }
115
+ /**
116
+ * Return the current virtual time. Compare these values to each other to derive elapsed intervals - they are a relative timeline, not real epoch milliseconds.
117
+ *
118
+ * @returns The current virtual time.
119
+ */
120
+ now() {
121
+ return this.#now;
122
+ }
123
+ /**
124
+ * The number of registered delays that have neither resolved nor rejected. A test reads this to assert a consumer registered its waits and later cleared them (no
125
+ * leak).
126
+ *
127
+ * @returns The count of unsettled delays.
128
+ */
129
+ get pending() {
130
+ return this.#pending.length;
131
+ }
132
+ // Remove `entry` from `#pending` by identity. Idempotent: a guarded `indexOf` + `splice` makes a second removal (e.g. an abort that races a resolve) a safe no-op and
133
+ // never removes the wrong entry, so `#pending` stays consistent across any resolve-and-abort interleaving.
134
+ #remove(entry) {
135
+ const index = this.#pending.indexOf(entry);
136
+ if (index !== -1) {
137
+ this.#pending.splice(index, 1);
138
+ }
139
+ }
140
+ }
141
+ //# sourceMappingURL=clock-double.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clock-double.js","sourceRoot":"","sources":["../src/clock-double.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAiBH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAcpC;;;;;;;GAOG;AACH,SAAS,UAAU;IAEjB,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;IAErD,KAAK,CAAC,IAAI,GAAG,YAAY,CAAC;IAE1B,+JAA+J;IAC/J,2GAA2G;IAC1G,KAAkC,CAAC,IAAI,GAAG,WAAW,CAAC;IAEvD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,OAAO,SAAS;IAEpB,mFAAmF;IACnF,IAAI,CAAS;IAEb,8JAA8J;IAC9J,oHAAoH;IAC3G,QAAQ,GAAiB,EAAE,CAAC;IAErC;;;;OAIG;IACH,YAAmB,KAAK,GAAG,CAAC;QAE1B,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC;IACpB,CAAC;IAED;;;;;;;;;;OAUG;IACI,OAAO,CAAC,EAAU;QAEvB,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC;QAEhB,qKAAqK;QACrK,gKAAgK;QAChK,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QAEjH,KAAI,MAAM,KAAK,IAAI,GAAG,EAAE,CAAC;YAEvB,oKAAoK;YACpK,gGAAgG;YAChG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YACpB,KAAK,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;YAClC,KAAK,CAAC,OAAO,EAAE,CAAC;QAClB,CAAC;IACH,CAAC;IAED;;;;;;;;;;;OAWG;IACI,KAAK,CAAC,EAAU,EAAE,IAA+B;QAEtD,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,GAA+B,OAAO,CAAC,aAAa,EAAE,CAAC;QACzF,MAAM,KAAK,GAAe,EAAE,QAAQ,EAAE,IAAI,CAAC,IAAI,GAAG,EAAE,EAAE,OAAO,EAAE,CAAC;QAEhE,gKAAgK;QAChK,qKAAqK;QACrK,6IAA6I;QAC7I,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAE1B,IAAG,IAAI,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;YAE9B,KAAK,CAAC,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE;gBAExC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;gBACpB,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC;YACvB,CAAC,CAAC,CAAC;QACL,CAAC;QAED,OAAO,OAAO,CAAC;IACjB,CAAC;IAED;;;;OAIG;IACI,GAAG;QAER,OAAO,IAAI,CAAC,IAAI,CAAC;IACnB,CAAC;IAED;;;;;OAKG;IACH,IAAW,OAAO;QAEhB,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC;IAC9B,CAAC;IAED,sKAAsK;IACtK,2GAA2G;IAC3G,OAAO,CAAC,KAAiB;QAEvB,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAE3C,IAAG,KAAK,KAAK,CAAC,CAAC,EAAE,CAAC;YAEhB,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QACjC,CAAC;IACH,CAAC;CACF"}