src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-07-26 05:38:33 -0500
committerLuke Hoersten <[email protected]>2026-07-26 11:44:49 -0500
commit0af1cf3b1e2b471f2bc06abb16520035efff252e (patch)
tree4f1f5a2db4a7798ceb0e09cc30e8aa2b18b5d824
Implement mattertimesync: one-shot Matter time synchronization CLIv0.1.0
A standalone CLI Matter controller that joins Matter devices' existing setups as a secondary administrator (multi-admin) and sets their clocks via the standard Time Synchronization cluster. Built on matter.js 0.17.6 using the CommissioningController API. One-shot runs from a systemd timer, no daemon: being a secondary controller is fabric membership, not a running process, so each invocation loads the persisted keys, discovers the device via operational DNS-SD, opens a fresh CASE session, does its work, and exits. - TypeScript scaffold: strict tsc, oxlint, prettier, vitest (51 tests: Matter epoch conversion, config validation, IANA timezone offsets and exact DST transition search, atomic state writes, pairing-code parsing, clock-delta reporting) - Validated JSON configuration with bigint-safe values; unknown fields rejected loudly - Persistent controller fabric with the same identity across restarts. The fabric doubles as the device registry: every node commissioned onto it is kept in sync, and membership changes only through commission and decommission, so no separate device list can drift - On-network multi-admin commissioning from a manual or QR pairing code; any number of devices, one per-device pairing code each. A device already on the fabric rejects re-commissioning via fabric conflict - Commands: nodes, inspect (human/JSON), status, fabrics (reads the Operational Credentials fabric table, marks our own entry by fabric ID plus root public key, flags likely-stale orphans, and removes them with --remove), and decommission (device drops our fabric while staying paired to its primary ecosystem). --node selects a device where relevant; optional while only one is commissioned - sync delta reporting: reads the device utcTime before writing and reports device time before, time written, and the delta with direction, e.g. "device clock was 1m 23s behind". Handles unset clocks after power loss and wrong-epoch garbage in exact bigint microseconds. The Time Synchronization write commands themselves still await real-device capability inspection - systemd oneshot service (dedicated non-root user, hardening) plus hourly timer with randomized delay - Deployment as a single esbuild bundle (mattertimesync.mjs, ~4.5 MB): all runtime deps are pure JavaScript, so the target needs only Node 20+, with no npm, registry access, or build toolchain. The Bun sqlite driver stays external behind its runtime guard so the bundle runs under plain Node. npm pack remains an alternative install path
-rw-r--r--.gitignore10
-rw-r--r--.prettierignore3
-rw-r--r--.prettierrc.json8
-rw-r--r--LICENSE21
-rw-r--r--README.md287
-rw-r--r--config.example.json5
-rw-r--r--data/.gitkeep0
-rw-r--r--mattertimesync.service15
-rw-r--r--mattertimesync.timer10
-rw-r--r--package-lock.json2721
-rw-r--r--package.json39
-rw-r--r--src/cli.ts306
-rw-r--r--src/commissioning.ts101
-rw-r--r--src/config.ts99
-rw-r--r--src/controller.ts96
-rw-r--r--src/device.ts72
-rw-r--r--src/fabrics.ts153
-rw-r--r--src/inspection.ts272
-rw-r--r--src/logging.ts71
-rw-r--r--src/state.ts90
-rw-r--r--src/sync.ts120
-rw-r--r--src/timezone.ts106
-rw-r--r--test/commissioning.test.ts33
-rw-r--r--test/config.test.ts73
-rw-r--r--test/fabrics.test.ts46
-rw-r--r--test/state.test.ts103
-rw-r--r--test/sync.test.ts111
-rw-r--r--test/timezone.test.ts66
-rw-r--r--tsconfig.json21
29 files changed, 5058 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..37c2350
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+dist/
+
+# Persistent Matter fabric state and credentials must never be committed.
+data/*
+!data/.gitkeep
+
+*.log
+*.tgz
+mattertimesync.mjs
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..3e0aed8
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+package-lock.json
+dist/
+data/
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..e242695
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,8 @@
+{
+ "printWidth": 110,
+ "tabWidth": 2,
+ "semi": true,
+ "singleQuote": false,
+ "trailingComma": "all",
+ "arrowParens": "avoid"
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d43b2af
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Luke Hoersten
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c00969a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,287 @@
+# mattertimesync
+
+A small CLI Matter controller that sets the clocks of Matter devices via the standard Time
+Synchronization cluster.
+
+Some Matter devices with clock displays (air-quality monitors, thermostats, sensor hubs) lose their
+clock after a power outage, and not every ecosystem restores it reliably. This tool joins each device's
+existing Matter setup as an _additional_ controller (Matter multi-admin) and pushes the correct time to
+every device commissioned onto its fabric. Run it periodically from a systemd timer; the primary
+ecosystem (e.g. Apple Home) keeps working unchanged.
+
+There is no daemon: each invocation connects, syncs, and exits.
+
+The main repository is <https://src.nth.io/luke/mattertimesync/>; the
+[GitHub repository](https://github.com/lukehoersten/mattertimesync) is a backup mirror.
+
+## How it works
+
+- The device stays commissioned to its primary ecosystem and attached to its existing Thread or Wi-Fi
+ network.
+- A Raspberry Pi (or any Linux host) runs this tool as a second Matter administrator on its own fabric.
+- For Thread devices, the host needs no Thread radio, no OpenThread Border Router, no Thread
+ credentials, and no BLE. It reaches the device over IPv6 through the existing Thread Border Routers
+ (e.g. HomePod, Apple TV).
+- The host's own clock is NTP-synchronized; the tool refuses to push time from an unsynchronized clock.
+
+Requirements on the host:
+
+- Node.js 20 or newer (current LTS recommended)
+- working IPv6 and multicast DNS on the network shared with the device's border router
+- system time synchronized via NTP (`systemd-timesyncd` or equivalent)
+
+## Design
+
+Principles the implementation holds to:
+
+- **One-shot CLI, not a daemon.** Being a secondary Matter controller is fabric membership, not a running
+ process. During commissioning the device permanently stores this controller's fabric entry (root
+ certificate and operational credential) in its own flash, alongside the primary ecosystem's; that
+ relationship persists with no process running. Each invocation loads the stored keys, discovers the
+ device, opens a fresh CASE session, does its work, and exits. A daemon's only real advantage would be
+ reactivity: a held subscription notices a device reboot within seconds. The one-shot design bounds clock
+ staleness to the timer interval instead, and in exchange drops all long-lived-connection machinery
+ (reconnect detection, backoff, debouncing, in-process scheduling). For a clock display after a rare power
+ outage, "wrong for at most an hour" is an acceptable trade, and the interval can be tightened since each
+ run costs only seconds. If second-level reactivity is ever wanted, a `watch` command holding a
+ subscription is an additive feature on the same modules, not a redesign.
+- **Capability-driven, never vendor-specific.** Any Matter device exposing the standard Time
+ Synchronization cluster (0x38) works. Sync behavior is driven by a live inspection of the device's
+ actual features, never by assumptions about a particular product; optional features a device lacks are
+ skipped with the limitation logged (UTC-only devices get UTC only), and a missing cluster is a clear
+ compatibility error.
+- **The fabric is created exactly once.** All Matter identity and fabric data persists in `storagePath`
+ and is never recreated on launch. Reconnection uses Matter operational discovery from the saved node
+ identity, never a stored IP address, so border-router changes, device reboots, and mDNS churn are
+ tolerated.
+- **Fail safely.** The device is never recommissioned automatically, and time is never pushed from a host
+ clock that is not NTP-synchronized. Transient failures exit non-zero and the next timer tick retries.
+- **Scheduling belongs to systemd**, not the application: the timer unit owns the interval, and device
+ membership lives in controller storage, so the configuration stays three fields.
+- **Matter time is bigint end to end.** Matter UTC time is microseconds since 2000-01-01T00:00:00Z (not
+ the Unix epoch); the full timestamp never passes through a JavaScript `number`. Time zones are IANA
+ names with offsets and DST transitions derived from the host's time-zone database, never fixed offsets.
+- **Intentionally small.** No GUI, database, cloud service, Home Assistant integration, MQTT, REST API,
+ metrics, Thread Border Router functionality, BLE commissioning, or firmware updates. Multiple devices
+ are supported, but only as the same sync applied to each commissioned node.
+
+## Project status
+
+Implemented: project scaffold, configuration, persistent controller fabric, on-network multi-admin
+commissioning, and full endpoint/cluster inspection.
+
+The Time Synchronization write commands (the `sync` command) are intentionally not implemented yet:
+they will be written against a real device's inspected capabilities, not against assumptions. The next
+milestone is a successful commissioning plus a complete cluster dump (`inspect --json`) from real
+hardware.
+
+## Build
+
+```bash
+git clone <repository>
+cd mattertimesync
+npm install
+npm run build
+```
+
+Run tests and lint:
+
+```bash
+npm test
+npm run lint
+```
+
+## Packaging for deployment
+
+All runtime dependencies are pure JavaScript (no native modules), so the whole tool bundles into one
+self-contained file. The target machine needs only `node` (20 or newer): no npm, no registry access, no
+git checkout, no build.
+
+```bash
+npm run bundle # produces mattertimesync.mjs (~4.5 MB)
+scp mattertimesync.mjs pi:
+ssh pi sudo install -D -m 0755 mattertimesync.mjs /opt/mattertimesync/mattertimesync.mjs
+```
+
+Run it as `node /opt/mattertimesync/mattertimesync.mjs ...` (the systemd unit does exactly this).
+Upgrades replace the one file; Matter state under `/var/lib/mattertimesync` is untouched.
+
+Alternative, if you prefer a `mattertimesync` command on PATH and don't mind npm on the target:
+`npm pack` produces a small tarball whose `npm install -g <tarball>` pulls dependencies from the
+registry (adjust the unit's `ExecStart` accordingly).
+
+## Configuration
+
+The CLI reads `/etc/mattertimesync/config.json` by default; override with `--config <path>`.
+See `config.example.json`:
+
+```json
+{
+ "storagePath": "/var/lib/mattertimesync",
+ "timezone": "America/Chicago",
+ "logLevel": "info"
+}
+```
+
+| Field | Default | Meaning |
+| ------------- | ----------------- | --------------------------------------------------------------- |
+| `storagePath` | (required) | Directory for persistent Matter fabric state. Contains secrets. |
+| `timezone` | `America/Chicago` | IANA time-zone name. Never a fixed UTC offset; DST is derived. |
+| `logLevel` | `info` | `debug`, `info`, `warn`, or `error`. |
+
+Scheduling lives in the systemd timer, and device membership lives in controller storage; neither is in
+the configuration. The set of devices kept in sync is exactly the set commissioned onto this
+controller's fabric, changed only by `commission` and `decommission`.
+
+The storage directory holds the controller's private keys and operational certificates. Keep it mode
+`0700`, owned by the service user, and out of Git. Deleting it destroys this controller's fabric
+identity; the stale fabric would then need to be removed from the device before commissioning again.
+
+## Commissioning
+
+1. In the device's primary ecosystem, open its pairing mode (Apple Home: device settings, _Turn On
+ Pairing Mode_). The ecosystem shows a temporary setup code.
+2. Within the pairing window, run (as the service user, so the timer job can reuse the same storage):
+
+```bash
+sudo -u mattertimesync \
+ node /opt/mattertimesync/mattertimesync.mjs \
+ --config /etc/mattertimesync/config.json \
+ commission --code 12345678901
+```
+
+Omit `--code` to be prompted interactively. The code is used once, never logged, and never stored.
+
+Commissioning discovers the device via `_matterc._udp` on the IP network, joins it to this controller's
+fabric, prints a full endpoint and cluster inspection, and records the node ID. The controller fabric is
+created once and reused for every device and every subsequent start.
+
+To sync several devices, repeat the process per device: Matter multi-admin pairing is per-device, so
+each device's pairing mode is opened individually in the primary ecosystem, yielding one code per
+device. A device already on this fabric rejects re-commissioning by itself (fabric conflict), so
+duplicates cannot occur.
+
+## CLI
+
+```bash
+mattertimesync commission [--code <pairing-code>]
+mattertimesync nodes
+mattertimesync inspect [--node <id>] [--json]
+mattertimesync sync [--node <id>] # not yet implemented (pending real-device inspection)
+mattertimesync status
+mattertimesync fabrics [--node <id>] [--remove <index>]
+mattertimesync decommission --node <id>
+```
+
+`--node <id>` selects which device a command targets; it may be omitted while only one device is
+commissioned. `sync` targets all commissioned devices by default.
+
+- `nodes` lists every commissioned device with its cached vendor/product name and per-device last
+ connection and sync results.
+- `inspect` connects to the selected node and prints vendor/product information, every endpoint with its
+ server and client clusters, and the Time Synchronization cluster's feature map, supported commands,
+ and attribute values. `--json` emits the same data machine-readable (node IDs and timestamps as
+ strings, safe for 64-bit values).
+- `sync` will connect to each targeted device, verify the host clock is NTP-synchronized, set UTC time,
+ and configure the time zone and DST transitions when the device supports them, then exit. For each
+ device it reports the correction made: the device's clock before the sync (or "unset" after a power
+ loss), the time written, and the delta, e.g. `device clock was 1m 23s behind`.
+- `status` prints controller state, configured time zone with the current UTC offset and next DST
+ transition, host NTP status, and per-device last connection/sync results.
+- `fabrics` reads the selected device's Operational Credentials fabric table and prints every
+ commissioned controller (label, vendor, fabric and node IDs, slot usage), marks this controller's own
+ entry, and flags likely stale entries: ones carrying our label but belonging to an old identity whose
+ storage was deleted. `--remove <index>` deletes a stale entry from the device (our own entry is
+ refused; use `decommission` for that).
+- `decommission` gracefully removes this controller's fabric from the selected device (the primary
+ ecosystem is untouched) and drops its state entry. Delete `storagePath` only after every device has
+ been decommissioned.
+
+## Install with a systemd timer
+
+Install the bundled file first (see "Packaging for deployment"), then create the service account and
+directories:
+
+```bash
+sudo useradd \
+ --system \
+ --home /var/lib/mattertimesync \
+ --shell /usr/sbin/nologin \
+ mattertimesync
+
+sudo mkdir -p /etc/mattertimesync
+sudo mkdir -p /var/lib/mattertimesync
+
+sudo chown -R mattertimesync:mattertimesync /var/lib/mattertimesync
+sudo chmod 700 /var/lib/mattertimesync
+```
+
+Place the configuration at `/etc/mattertimesync/config.json` and install the units (the service runs
+the bundled file at `/opt/mattertimesync/mattertimesync.mjs`):
+
+```bash
+sudo cp mattertimesync.service mattertimesync.timer /etc/systemd/system/
+sudo systemctl daemon-reload
+```
+
+Commission first (see above), then enable the timer:
+
+```bash
+sudo systemctl enable --now mattertimesync.timer
+```
+
+The timer runs a sync 2 minutes after boot and hourly thereafter (see `mattertimesync.timer` to adjust).
+Run a sync on demand with `sudo systemctl start mattertimesync.service`.
+
+## Backup and restore
+
+Back up:
+
+```text
+/var/lib/mattertimesync
+/etc/mattertimesync/config.json
+```
+
+Restoring both paths preserves the controller fabric credentials, the device's node ID, the controller
+identity, and service state, so no recommissioning is needed. Keep backups as restricted as the
+originals; the storage directory contains private keys.
+
+## Removing this controller from the device
+
+Run `mattertimesync decommission` to undo commissioning: the device drops this controller's fabric while
+staying paired to its primary ecosystem, and deleting `/var/lib/mattertimesync` becomes safe.
+
+Order matters. Deleting the storage directory first destroys the keys, leaving a stale fabric entry
+occupying one of the device's limited fabric slots (spec minimum is 5). Orphans accumulate if storage is
+deleted and the device recommissioned repeatedly (e.g. during testing). To find and clean them, run
+`mattertimesync fabrics`: stale entries carry our label but not our current identity, and
+`fabrics --remove <index>` deletes them using the live controller's admin rights. Alternatively, remove
+them in Apple Home (device settings, _Connected Services_) or, as a last resort, factory-reset the
+device and re-pair it.
+
+## Troubleshooting
+
+```bash
+ip -6 addr
+ip -6 route
+avahi-browse -art
+timedatectl status
+timedatectl show -p NTPSynchronized
+journalctl -u mattertimesync.service -f
+systemctl list-timers mattertimesync.timer
+```
+
+**Commissionable device not found**: confirm the pairing window is still open (it expires), that the
+host and the border router are on networks that permit mDNS/multicast, that IPv6 is enabled, and that
+no firewall or container bridge blocks Matter UDP traffic.
+
+**Commissioning succeeds but operational connection fails**: check IPv6 routing to the device's
+network, `_matter._tcp` DNS-SD discovery, that the persisted storage path is readable by the user
+running the CLI, and that the border routers are reachable.
+
+**Clock is off by one hour**: a fixed offset was used instead of IANA rules, or DST configuration is
+missing or rejected. This tool always derives offsets from the IANA database.
+
+**Clock shows an absurd future date**: the Unix epoch was used instead of the Matter epoch
+(2000-01-01T00:00:00Z), or seconds were sent where microseconds were expected. This tool converts with
+bigint arithmetic against the Matter epoch only.
diff --git a/config.example.json b/config.example.json
new file mode 100644
index 0000000..2700ecb
--- /dev/null
+++ b/config.example.json
@@ -0,0 +1,5 @@
+{
+ "storagePath": "/var/lib/mattertimesync",
+ "timezone": "America/Chicago",
+ "logLevel": "info"
+}
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/data/.gitkeep
diff --git a/mattertimesync.service b/mattertimesync.service
new file mode 100644
index 0000000..fa69907
--- /dev/null
+++ b/mattertimesync.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=Matter device clock synchronization
+Wants=network-online.target
+After=network-online.target time-sync.target
+
+[Service]
+Type=oneshot
+User=mattertimesync
+Group=mattertimesync
+ExecStart=/usr/bin/node /opt/mattertimesync/mattertimesync.mjs --config /etc/mattertimesync/config.json sync
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/mattertimesync
diff --git a/mattertimesync.timer b/mattertimesync.timer
new file mode 100644
index 0000000..4d7b64a
--- /dev/null
+++ b/mattertimesync.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Periodic Matter device clock synchronization
+
+[Timer]
+OnBootSec=2min
+OnUnitActiveSec=1h
+RandomizedDelaySec=5min
+
+[Install]
+WantedBy=timers.target
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f638612
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2721 @@
+{
+ "name": "mattertimesync",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mattertimesync",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@matter/main": "^0.17.6",
+ "@project-chip/matter.js": "^0.17.6"
+ },
+ "bin": {
+ "mattertimesync": "dist/cli.js"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.1",
+ "esbuild": "^0.28.1",
+ "oxlint": "^1.0.0",
+ "prettier": "^3.6.0",
+ "typescript": "^7.0.2",
+ "vitest": "^4.1.10"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@matter/general": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.17.6.tgz",
+ "integrity": "sha512-PiSly47lwR+31sMWZTxT9eVgibfC2PB3FflTwxhVDzektrNNYT4wh9+5abTNhQmszs/VnXixbyo64HSz+LQS8g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@noble/curves": "^2.2.0"
+ }
+ },
+ "node_modules/@matter/main": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.17.6.tgz",
+ "integrity": "sha512-RBXVLxJGEd3IUZ63QrHPUpb7o1amErvs2MoK3bJpnUXYNqXAuN2IHLTYZ04j4yrdMHJlyGSDxdIYr5rN6zXqSA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6",
+ "@matter/node": "0.17.6",
+ "@matter/protocol": "0.17.6",
+ "@matter/types": "0.17.6"
+ },
+ "optionalDependencies": {
+ "@matter/nodejs": "0.17.6"
+ }
+ },
+ "node_modules/@matter/model": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.17.6.tgz",
+ "integrity": "sha512-ECf6r0zTq6FS6IA4ov8qtSu36iyO1oxEJT06RFYX/SVDOyfhgzRjQDSIsVtW4UwTMht8kSVnDJE9EAO0qY37YA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6"
+ }
+ },
+ "node_modules/@matter/node": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.17.6.tgz",
+ "integrity": "sha512-qtkh3/hz+73Wh7kY+Jz9HogrnpqjIMreYaY//VEMb9Jl/tY1A+3RFa7PDIY51LK2m+gWy3boXu/3kdYEvn00ig==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6",
+ "@matter/protocol": "0.17.6",
+ "@matter/types": "0.17.6"
+ }
+ },
+ "node_modules/@matter/nodejs": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.17.6.tgz",
+ "integrity": "sha512-tvn2wLMgjd1YVKr/kAkKq50WU+8mUeoDVCPIyO6aXJMsy0GTOc8/vC3Lu3ZIUD1IwMiOgn67xnqBVozpp+ZjTw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/node": "0.17.6",
+ "@matter/protocol": "0.17.6",
+ "@matter/types": "0.17.6"
+ },
+ "engines": {
+ "node": ">=20.19.0 <22.0.0 || >=22.13.0"
+ }
+ },
+ "node_modules/@matter/protocol": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.17.6.tgz",
+ "integrity": "sha512-p3oc8v2RKKbV4RkkjKemDpzheNHFMQJnbxpij2yKpxsXYIXpYnwtexnvLxDpZCJPsd9Jv3igSxxURBEUexHmBA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6",
+ "@matter/types": "0.17.6"
+ }
+ },
+ "node_modules/@matter/types": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.17.6.tgz",
+ "integrity": "sha512-zYCWx+om6hQR6s6CHniWJMaOsqzTNDKcxUoU0QI8ZgF39SCtIeIb+qOzNigoAtKKZzhVdo9B5IsbEbYY4PhO3g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
+ "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.2.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz",
+ "integrity": "sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.75.0.tgz",
+ "integrity": "sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz",
+ "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.75.0.tgz",
+ "integrity": "sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.75.0.tgz",
+ "integrity": "sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.75.0.tgz",
+ "integrity": "sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.75.0.tgz",
+ "integrity": "sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.75.0.tgz",
+ "integrity": "sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.75.0.tgz",
+ "integrity": "sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.75.0.tgz",
+ "integrity": "sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.75.0.tgz",
+ "integrity": "sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.75.0.tgz",
+ "integrity": "sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.75.0.tgz",
+ "integrity": "sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.75.0.tgz",
+ "integrity": "sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.75.0.tgz",
+ "integrity": "sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.75.0.tgz",
+ "integrity": "sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.75.0.tgz",
+ "integrity": "sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.75.0.tgz",
+ "integrity": "sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.75.0.tgz",
+ "integrity": "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@project-chip/matter.js": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@project-chip/matter.js/-/matter.js-0.17.6.tgz",
+ "integrity": "sha512-E/FvTvY3G8cPTbpMJDgYdUY8oUHkWN47dXJ6aRjmiDrLFToRb40rrW53p0g6wOOvMnWE9nhVLbJxbE9kOpJIyg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.17.6",
+ "@matter/model": "0.17.6",
+ "@matter/node": "0.17.6",
+ "@matter/protocol": "0.17.6",
+ "@matter/types": "0.17.6"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.1.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
+ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz",
+ "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.75.0",
+ "@oxlint/binding-android-arm64": "1.75.0",
+ "@oxlint/binding-darwin-arm64": "1.75.0",
+ "@oxlint/binding-darwin-x64": "1.75.0",
+ "@oxlint/binding-freebsd-x64": "1.75.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.75.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.75.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.75.0",
+ "@oxlint/binding-linux-arm64-musl": "1.75.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.75.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.75.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.75.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.75.0",
+ "@oxlint/binding-linux-x64-gnu": "1.75.0",
+ "@oxlint/binding-linux-x64-musl": "1.75.0",
+ "@oxlint/binding-openharmony-arm64": "1.75.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.75.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.75.0",
+ "@oxlint/binding-win32-x64-msvc": "1.75.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=7.0.2001",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.23",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.17",
+ "rolldown": "~1.1.5",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a60f08e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "mattertimesync",
+ "version": "0.1.0",
+ "description": "CLI Matter controller that synchronizes the clocks of Matter devices via the standard Time Synchronization cluster",
+ "type": "module",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "bin": {
+ "mattertimesync": "dist/cli.js"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "bundle": "npm run clean && npm run build && esbuild dist/cli.js --bundle --platform=node --target=node20 --format=esm --external:bun:sqlite --external:*/BunSqlite.js --banner:js=\"import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);\" --outfile=mattertimesync.mjs",
+ "prepack": "npm run clean && npm run build",
+ "clean": "rm -rf dist",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "lint": "oxlint src test",
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"*.json\" \"*.md\"",
+ "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"*.json\" \"*.md\""
+ },
+ "dependencies": {
+ "@matter/main": "^0.17.6",
+ "@project-chip/matter.js": "^0.17.6"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.1",
+ "esbuild": "^0.28.1",
+ "oxlint": "^1.0.0",
+ "prettier": "^3.6.0",
+ "typescript": "^7.0.2",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..382c40b
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,306 @@
+#!/usr/bin/env node
+import { existsSync } from "node:fs";
+import { createInterface } from "node:readline";
+import { parseArgs } from "node:util";
+import { ConfigError, DEFAULT_CONFIG_PATH, loadConfig, parseNodeId, type Config } from "./config.js";
+import { commissionedNodeIds, resolveSingleNode, startController } from "./controller.js";
+import { commissionDevice } from "./commissioning.js";
+import { connectDevice } from "./device.js";
+import { formatFabricTable, readFabricTable, removeFabricByIndex } from "./fabrics.js";
+import { formatInspection, inspectionToJson, inspectNode } from "./inspection.js";
+import { Log, describeError, setLogLevel } from "./logging.js";
+import { currentMatterUtcMicroseconds, isHostClockSynchronized } from "./sync.js";
+import { nodeState, readServiceState, removeNodeState, serviceStatePath, updateNodeState } from "./state.js";
+import { formatUtcOffset, nextOffsetTransition, utcOffsetSeconds } from "./timezone.js";
+
+const log = new Log("cli");
+
+const USAGE = `Usage: mattertimesync [--config <path>] <command> [options]
+
+Commands:
+ commission [--code <pairing-code>] Commission a device as an additional Matter admin
+ nodes List commissioned devices and their sync state
+ inspect [--node <id>] [--json] Dump a device's endpoints, clusters, time capabilities
+ sync [--node <id>] Synchronize device clocks (not yet implemented)
+ status Show controller and per-device state
+ fabrics [--node <id>] [--remove <n>] List a device's fabric table; remove a stale entry
+ decommission --node <id> Remove this controller from a device
+
+The controller fabric is the device registry: every commissioned node is kept
+in sync. --node may be omitted while only one device is commissioned.
+Run "sync" periodically (e.g. from a systemd timer) to keep clocks correct.
+
+Options:
+ --config <path> Configuration file (default: ${DEFAULT_CONFIG_PATH})
+ --help Show this help
+`;
+
+async function main(): Promise<number> {
+ const { values, positionals } = parseArgs({
+ options: {
+ config: { type: "string" },
+ code: { type: "string" },
+ json: { type: "boolean", default: false },
+ node: { type: "string" },
+ remove: { type: "string" },
+ help: { type: "boolean", default: false },
+ },
+ allowPositionals: true,
+ });
+
+ if (values.help || positionals.length === 0) {
+ process.stdout.write(USAGE);
+ return values.help ? 0 : 2;
+ }
+
+ const command = positionals[0]!;
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
+
+ let config: Config;
+ let requestedNode: bigint | undefined;
+ try {
+ config = loadConfig(configPath);
+ requestedNode = values.node === undefined ? undefined : parseNodeId(values.node);
+ } catch (cause) {
+ if (cause instanceof ConfigError) {
+ process.stderr.write(`Configuration error: ${cause.message}\n`);
+ return 1;
+ }
+ throw cause;
+ }
+ setLogLevel(config.logLevel);
+
+ switch (command) {
+ case "commission":
+ return await runCommission(config, values.code);
+ case "nodes":
+ return await runNodes(config);
+ case "inspect":
+ return await runInspect(config, requestedNode, values.json ?? false);
+ case "sync":
+ return runNotImplemented("sync");
+ case "status":
+ return await runStatus(config);
+ case "fabrics":
+ return await runFabrics(config, requestedNode, values.remove);
+ case "decommission":
+ return await runDecommission(config, requestedNode);
+ default:
+ process.stderr.write(`Unknown command "${command}"\n\n${USAGE}`);
+ return 2;
+ }
+}
+
+async function runCommission(config: Config, codeArgument: string | undefined): Promise<number> {
+ const pairingCode = codeArgument ?? (await promptForPairingCode());
+ const controller = await startController(config);
+ try {
+ const result = await commissionDevice(controller, config, pairingCode);
+
+ const node = await connectDevice(controller, result.nodeId);
+ updateNodeState(serviceStatePath(config.storagePath), result.nodeId, {
+ lastSuccessfulConnection: new Date().toISOString(),
+ });
+ const report = await inspectNode(node);
+ process.stdout.write(formatInspection(report) + "\n\n");
+
+ process.stdout.write(
+ [
+ "Commissioning summary",
+ ` Node ID: ${result.nodeId}`,
+ ` Fabric ID: ${controller.fabric.fabricId}`,
+ ` Total devices: ${commissionedNodeIds(controller).length}`,
+ ` Time Sync: ${
+ report.timeSynchronization
+ ? `endpoint ${report.timeSynchronization.endpointId}`
+ : "cluster not found"
+ }`,
+ "",
+ "The device remains paired with its primary ecosystem; this controller",
+ "was added as an additional Matter administrator. Time synchronization",
+ "commands will be implemented from this inspection data (plan Phase 5).",
+ "",
+ ].join("\n"),
+ );
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runNodes(config: Config): Promise<number> {
+ const controller = await startController(config);
+ try {
+ const details = controller.getCommissionedNodesDetails();
+ if (details.length === 0) {
+ process.stdout.write('No devices commissioned. Run "commission" to add one.\n');
+ return 0;
+ }
+
+ const state = readServiceState(serviceStatePath(config.storagePath));
+ process.stdout.write(`${details.length} commissioned device${details.length === 1 ? "" : "s"}:\n`);
+ for (const detail of details) {
+ const nodeId = BigInt(detail.nodeId);
+ const info = detail.deviceData.basicInformation ?? {};
+ const name = [info["vendorName"], info["productName"]].filter(Boolean).join(" ");
+ const perNode = nodeState(state, nodeId);
+ process.stdout.write(
+ [
+ ` node ${nodeId}: ${name || "(no cached device info)"}`,
+ ` last connection: ${perNode.lastSuccessfulConnection ?? "never"}`,
+ ` last sync: ${perNode.lastSuccessfulSync ?? "never"}`,
+ ...(perNode.lastError !== null ? [` last error: ${perNode.lastError}`] : []),
+ ].join("\n") + "\n",
+ );
+ }
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runInspect(config: Config, requestedNode: bigint | undefined, json: boolean): Promise<number> {
+ const controller = await startController(config);
+ try {
+ const nodeId = resolveSingleNode(controller, requestedNode);
+ const node = await connectDevice(controller, nodeId);
+ updateNodeState(serviceStatePath(config.storagePath), nodeId, {
+ lastSuccessfulConnection: new Date().toISOString(),
+ });
+ const report = await inspectNode(node);
+ process.stdout.write((json ? inspectionToJson(report) : formatInspection(report)) + "\n");
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+function runNotImplemented(command: string): number {
+ process.stderr.write(
+ `"${command}" is not implemented yet. Per the project plan, the Time Synchronization\n` +
+ `write commands are implemented only after inspecting the real device.\n` +
+ `Run "commission" and then "inspect --json" against the hardware first.\n`,
+ );
+ return 3;
+}
+
+async function runStatus(config: Config): Promise<number> {
+ const statePath = serviceStatePath(config.storagePath);
+ const state = readServiceState(statePath);
+ const storageExists = existsSync(config.storagePath);
+ const ntpSynchronized = await isHostClockSynchronized();
+ const offsetSeconds = utcOffsetSeconds(config.timezone);
+ const transition = nextOffsetTransition(config.timezone);
+
+ const nodeIds = Object.keys(state.nodes);
+ const lines = [
+ `Configuration: loaded (${config.timezone})`,
+ `Controller storage: ${storageExists ? `present at ${config.storagePath}` : "NOT INITIALIZED"}`,
+ `Commissioned nodes: ${nodeIds.length > 0 ? nodeIds.join(", ") : "none recorded"}`,
+ `Host NTP synced: ${ntpSynchronized ? "yes" : "no (or not determinable on this host)"}`,
+ `Current UTC offset: ${formatUtcOffset(offsetSeconds)}`,
+ `Next DST transition: ${
+ transition
+ ? `${transition.at.toISOString()} (${formatUtcOffset(transition.offsetBeforeSeconds)} -> ${formatUtcOffset(
+ transition.offsetAfterSeconds,
+ )})`
+ : "none within 400 days"
+ }`,
+ `Matter time now: ${currentMatterUtcMicroseconds()} us since 2000-01-01T00:00:00Z`,
+ ];
+ for (const [nodeId, perNode] of Object.entries(state.nodes)) {
+ lines.push(`Node ${nodeId}:`);
+ lines.push(` Last connection: ${perNode.lastSuccessfulConnection ?? "never"}`);
+ lines.push(` Last successful sync: ${perNode.lastSuccessfulSync ?? "never"}`);
+ lines.push(` Last attempted sync: ${perNode.lastAttemptedSync ?? "never"}`);
+ lines.push(` Most recent error: ${perNode.lastError ?? "none"}`);
+ }
+ process.stdout.write(lines.join("\n") + "\n");
+ return 0;
+}
+
+async function runFabrics(
+ config: Config,
+ requestedNode: bigint | undefined,
+ removeArgument: string | undefined,
+): Promise<number> {
+ let removeIndex: number | undefined;
+ if (removeArgument !== undefined) {
+ removeIndex = Number(removeArgument);
+ if (!Number.isInteger(removeIndex) || removeIndex < 1) {
+ process.stderr.write(`--remove requires a positive fabric index (got "${removeArgument}")\n`);
+ return 2;
+ }
+ }
+
+ const controller = await startController(config);
+ try {
+ const nodeId = resolveSingleNode(controller, requestedNode);
+ const node = await connectDevice(controller, nodeId);
+
+ if (removeIndex !== undefined) {
+ await removeFabricByIndex(controller, node, removeIndex);
+ }
+
+ const table = await readFabricTable(controller, node);
+ process.stdout.write(formatFabricTable(nodeId, table) + "\n");
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runDecommission(config: Config, requestedNode: bigint | undefined): Promise<number> {
+ const controller = await startController(config);
+ try {
+ const nodeId = resolveSingleNode(controller, requestedNode);
+ const node = await connectDevice(controller, nodeId);
+
+ log.info(`Decommissioning: removing this controller's fabric from node ${nodeId}`);
+ await node.decommission();
+
+ removeNodeState(serviceStatePath(config.storagePath), nodeId);
+ const remaining = commissionedNodeIds(controller);
+ process.stdout.write(
+ [
+ `Device ${nodeId} removed this controller's fabric; its primary ecosystem is untouched.`,
+ remaining.length > 0
+ ? `${remaining.length} device${remaining.length === 1 ? "" : "s"} remain commissioned: ${remaining.join(", ")}.`
+ : `No devices remain commissioned. Local controller identity remains in ` +
+ `${config.storagePath}; deleting that directory is now safe, or keep it to reuse ` +
+ `the same identity for future commissioning.`,
+ "",
+ ].join("\n"),
+ );
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function promptForPairingCode(): Promise<string> {
+ if (!process.stdin.isTTY) {
+ throw new Error("No pairing code given. Pass --code <pairing-code> when not running interactively.");
+ }
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
+ try {
+ const answer = await new Promise<string>(resolve =>
+ readline.question("Matter pairing code (from the primary ecosystem's pairing mode): ", resolve),
+ );
+ return answer.trim();
+ } finally {
+ readline.close();
+ }
+}
+
+main()
+ .then(code => {
+ process.exitCode = code;
+ })
+ .catch(cause => {
+ log.error("Fatal error", cause);
+ if (!(cause instanceof Error)) {
+ process.stderr.write(describeError(cause) + "\n");
+ }
+ process.exitCode = 1;
+ });
diff --git a/src/commissioning.ts b/src/commissioning.ts
new file mode 100644
index 0000000..b4edf9b
--- /dev/null
+++ b/src/commissioning.ts
@@ -0,0 +1,101 @@
+import { ManualPairingCodeCodec, QrPairingCodeCodec } from "@matter/main/types";
+import { GeneralCommissioning } from "@matter/main/clusters/general-commissioning";
+import type { CommissioningController, NodeCommissioningOptions } from "@project-chip/matter.js";
+import type { Config } from "./config.js";
+import { Log } from "./logging.js";
+import { serviceStatePath, updateNodeState } from "./state.js";
+
+const log = new Log("commissioning");
+
+export interface CommissioningResult {
+ nodeId: bigint;
+}
+
+interface ParsedPairingCode {
+ passcode: number;
+ shortDiscriminator?: number;
+ longDiscriminator?: number;
+}
+
+/**
+ * Parses an Apple-generated Matter multi-admin pairing code. Accepts the
+ * 11/21-digit manual code (with or without hyphens) or an "MT:" QR payload.
+ * The code is used only in memory and never logged or stored.
+ */
+export function parsePairingCode(code: string): ParsedPairingCode {
+ const trimmed = code.trim();
+ if (trimmed.length === 0) {
+ throw new Error("Pairing code is empty");
+ }
+
+ if (trimmed.toUpperCase().startsWith("MT:")) {
+ const [payload] = QrPairingCodeCodec.decode(trimmed);
+ if (payload === undefined) {
+ throw new Error("QR pairing code contains no payload");
+ }
+ return { passcode: payload.passcode, longDiscriminator: payload.discriminator };
+ }
+
+ const digits = trimmed.replace(/[-\s]/g, "");
+ if (!/^\d{11}$|^\d{21}$/.test(digits)) {
+ throw new Error("Manual pairing code must be 11 or 21 digits");
+ }
+ const decoded = ManualPairingCodeCodec.decode(digits);
+ return {
+ passcode: decoded.passcode,
+ shortDiscriminator: decoded.shortDiscriminator,
+ longDiscriminator: decoded.discriminator,
+ };
+}
+
+/**
+ * Commissions a device as an additional Matter administrator using on-network
+ * (IP) discovery only. The device stays joined to its existing primary fabric
+ * (e.g. Apple Home); no Thread credentials are involved.
+ *
+ * Any number of devices may be commissioned, one pairing code each; a device
+ * that is already on this fabric rejects the attempt itself (fabric
+ * conflict), so duplicates cannot occur.
+ */
+export async function commissionDevice(
+ controller: CommissioningController,
+ config: Config,
+ pairingCode: string,
+): Promise<CommissioningResult> {
+ const existing = controller.getCommissionedNodes();
+ if (existing.length > 0) {
+ log.info(
+ `${existing.length} node${existing.length === 1 ? "" : "s"} already commissioned; ` +
+ `adding another device to the same fabric.`,
+ );
+ }
+
+ const parsed = parsePairingCode(pairingCode);
+ const identifierData =
+ parsed.longDiscriminator !== undefined
+ ? { longDiscriminator: parsed.longDiscriminator }
+ : parsed.shortDiscriminator !== undefined
+ ? { shortDiscriminator: parsed.shortDiscriminator }
+ : {};
+
+ log.info("Discovering commissionable device on the IP network (_matterc._udp)...");
+
+ const options: NodeCommissioningOptions = {
+ commissioning: {
+ regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
+ regulatoryCountryCode: "XX",
+ },
+ discovery: {
+ identifierData,
+ discoveryCapabilities: { onIpNetwork: true },
+ },
+ passcode: parsed.passcode,
+ };
+
+ const nodeId = await controller.commissionNode(options, { connectNodeAfterCommissioning: false });
+ const nodeIdBigint = BigInt(nodeId);
+
+ updateNodeState(serviceStatePath(config.storagePath), nodeIdBigint, {});
+ log.info(`Commissioning complete: node ${nodeIdBigint}`);
+ return { nodeId: nodeIdBigint };
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..14dbd4f
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,99 @@
+import { readFileSync } from "node:fs";
+import { isLogLevel, type LogLevel } from "./logging.js";
+import { isValidTimeZone } from "./timezone.js";
+
+export const DEFAULT_CONFIG_PATH = "/etc/mattertimesync/config.json";
+
+export interface Config {
+ /** Directory holding persistent Matter fabric state and service state. Contains secrets. */
+ storagePath: string;
+ /** IANA time-zone name, e.g. "America/Chicago". Never a fixed UTC offset. */
+ timezone: string;
+ logLevel: LogLevel;
+}
+
+export class ConfigError extends Error {}
+
+const DEFAULTS = {
+ timezone: "America/Chicago",
+ logLevel: "info" as LogLevel,
+};
+
+export function loadConfig(path: string): Config {
+ let raw: string;
+ try {
+ raw = readFileSync(path, "utf8");
+ } catch (cause) {
+ throw new ConfigError(
+ `Cannot read configuration file ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
+ );
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (cause) {
+ throw new ConfigError(
+ `Configuration file ${path} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
+ );
+ }
+
+ return validateConfig(parsed, path);
+}
+
+export function validateConfig(parsed: unknown, source = "configuration"): Config {
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ throw new ConfigError(`${source}: top-level value must be a JSON object`);
+ }
+ const record = parsed as Record<string, unknown>;
+
+ // Device membership lives in controller storage, not the configuration, so
+ // there is deliberately no nodeId field; commands take --node instead.
+ const known = new Set(["storagePath", "timezone", "logLevel"]);
+ for (const key of Object.keys(record)) {
+ if (!known.has(key)) {
+ throw new ConfigError(`${source}: unknown field "${key}"`);
+ }
+ }
+
+ const storagePath = record["storagePath"];
+ if (typeof storagePath !== "string" || storagePath.length === 0) {
+ throw new ConfigError(`${source}: "storagePath" is required and must be a non-empty string`);
+ }
+
+ const timezone = record["timezone"] === undefined ? DEFAULTS.timezone : record["timezone"];
+ if (typeof timezone !== "string" || !isValidTimeZone(timezone)) {
+ throw new ConfigError(
+ `${source}: "timezone" must be a valid IANA time-zone name (got ${JSON.stringify(timezone)})`,
+ );
+ }
+
+ const logLevel = record["logLevel"] === undefined ? DEFAULTS.logLevel : record["logLevel"];
+ if (!isLogLevel(logLevel)) {
+ throw new ConfigError(`${source}: "logLevel" must be one of debug, info, warn, error`);
+ }
+
+ return { storagePath, timezone, logLevel };
+}
+
+/**
+ * Parses a node ID given on the command line (--node). Node IDs are 64-bit
+ * values; they are carried as decimal strings so they never pass through a
+ * lossy JavaScript number.
+ */
+export function parseNodeId(value: unknown, source = "--node"): bigint {
+ if (typeof value === "string" && /^\d+$/.test(value)) {
+ return toNodeId(BigInt(value), source);
+ }
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
+ return toNodeId(BigInt(value), source);
+ }
+ throw new ConfigError(`${source}: node ID must be a decimal string; got ${JSON.stringify(value)}`);
+}
+
+function toNodeId(value: bigint, source: string): bigint {
+ if (value < 0n || value >= 1n << 64n) {
+ throw new ConfigError(`${source}: node ID must fit in an unsigned 64-bit integer`);
+ }
+ return value;
+}
diff --git a/src/controller.ts b/src/controller.ts
new file mode 100644
index 0000000..b4d27f3
--- /dev/null
+++ b/src/controller.ts
@@ -0,0 +1,96 @@
+import { mkdirSync } from "node:fs";
+import { Environment, StorageService } from "@matter/main";
+import { CommissioningController } from "@project-chip/matter.js";
+import type { Config } from "./config.js";
+import { Log, type LogLevel } from "./logging.js";
+
+const log = new Log("controller");
+
+/**
+ * Fabric label shown to other Matter administrators (e.g. in Apple Home's
+ * connected-services list). Maximum 32 characters.
+ */
+const FABRIC_LABEL = "mattertimesync";
+
+/** Storage namespace under the storage path; also the controller's unique environment id. */
+const CONTROLLER_ID = "mattertimesync";
+
+/**
+ * matter.js library log level derived from the service log level. At service
+ * level "info" the library is kept at "warn" so protocol internals (discovery,
+ * session management) only appear when debugging.
+ */
+function matterLogLevel(serviceLevel: LogLevel): string {
+ return serviceLevel === "debug" ? "debug" : serviceLevel === "info" ? "warn" : serviceLevel;
+}
+
+/**
+ * Creates and starts the Matter controller with persistent storage rooted at
+ * the configured storage path. The fabric identity (root certificate, keys,
+ * node allocation state) persists across restarts; a fabric is only generated
+ * on the very first start.
+ */
+export async function startController(config: Config): Promise<CommissioningController> {
+ mkdirSync(config.storagePath, { recursive: true, mode: 0o700 });
+
+ const environment = Environment.default;
+ environment.vars.set("storage.path", config.storagePath);
+ environment.vars.set("log.level", matterLogLevel(config.logLevel));
+
+ const storage = environment.get(StorageService);
+ log.debug(`Matter storage location: ${storage.location}`);
+
+ const controller = new CommissioningController({
+ environment: { environment, id: CONTROLLER_ID },
+ autoConnect: false,
+ adminFabricLabel: FABRIC_LABEL,
+ });
+
+ await controller.start();
+ const commissioned = controller.getCommissionedNodes();
+ log.info(
+ `Controller started (fabric ${controller.fabric.fabricId}, ${commissioned.length} commissioned node${
+ commissioned.length === 1 ? "" : "s"
+ })`,
+ );
+ return controller;
+}
+
+/** All commissioned node IDs in controller storage. */
+export function commissionedNodeIds(controller: CommissioningController): bigint[] {
+ return controller.getCommissionedNodes().map(id => BigInt(id));
+}
+
+/**
+ * Resolves the set of nodes a command operates on: the explicitly requested
+ * one, or every commissioned node.
+ */
+export function resolveTargetNodes(controller: CommissioningController, requested?: bigint): bigint[] {
+ const commissioned = commissionedNodeIds(controller);
+ if (commissioned.length === 0) {
+ throw new Error('No device is commissioned yet. Run "commission" first.');
+ }
+ if (requested === undefined) return commissioned;
+ if (!commissioned.includes(requested)) {
+ throw new Error(
+ `Node ${requested} is not commissioned on this controller ` +
+ `(known nodes: ${commissioned.join(", ")}). Run "nodes" to list them.`,
+ );
+ }
+ return [requested];
+}
+
+/**
+ * Resolves exactly one node for commands that cannot meaningfully operate on
+ * several (inspect, fabrics, decommission). --node is required once more than
+ * one device is commissioned.
+ */
+export function resolveSingleNode(controller: CommissioningController, requested?: bigint): bigint {
+ const targets = resolveTargetNodes(controller, requested);
+ if (targets.length > 1) {
+ throw new Error(
+ `Multiple nodes are commissioned (${targets.join(", ")}); pass --node <id> to choose one.`,
+ );
+ }
+ return targets[0]!;
+}
diff --git a/src/device.ts b/src/device.ts
new file mode 100644
index 0000000..1c0d0ea
--- /dev/null
+++ b/src/device.ts
@@ -0,0 +1,72 @@
+import { NodeId } from "@matter/main";
+import type { CommissioningController } from "@project-chip/matter.js";
+import { NodeStates, type PairedNode } from "@project-chip/matter.js/device";
+import { Log } from "./logging.js";
+
+const log = new Log("device");
+
+export function nodeStateName(state: NodeStates): string {
+ switch (state) {
+ case NodeStates.Connected:
+ return "connected";
+ case NodeStates.Disconnected:
+ return "disconnected";
+ case NodeStates.Reconnecting:
+ return "reconnecting";
+ case NodeStates.WaitingForDeviceDiscovery:
+ return "waiting for device discovery";
+ }
+}
+
+/**
+ * Connects to the commissioned node using operational discovery (DNS-SD by
+ * node identity, never a stored IP address) and waits until the node is fully
+ * initialized from the device.
+ *
+ * The returned PairedNode keeps reconnecting in the background; observe
+ * `events.stateChanged` for connectivity transitions.
+ */
+export async function connectDevice(
+ controller: CommissioningController,
+ nodeId: bigint,
+ timeoutMs = 60_000,
+): Promise<PairedNode> {
+ const node = await controller.getNode(NodeId(nodeId));
+
+ node.events.stateChanged.on(state => {
+ log.debug(`Node ${nodeId} state: ${nodeStateName(state)}`);
+ });
+
+ if (!node.initialized) {
+ log.info(`Connecting to Matter node ${nodeId}...`);
+ node.connect();
+ await waitForInitialization(node, nodeId, timeoutMs);
+ } else if (!node.isConnected) {
+ node.connect();
+ await waitForInitialization(node, nodeId, timeoutMs);
+ }
+
+ log.info(`Connected to Matter node ${nodeId}`);
+ return node;
+}
+
+async function waitForInitialization(node: PairedNode, nodeId: bigint, timeoutMs: number): Promise<void> {
+ let timer: NodeJS.Timeout | undefined;
+ const timeout = new Promise<never>((_, reject) => {
+ timer = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for node ${nodeId}. ` +
+ `The device may be offline or unreachable over IPv6.`,
+ ),
+ ),
+ timeoutMs,
+ );
+ });
+ try {
+ await Promise.race([node.events.initializedFromRemote, timeout]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
diff --git a/src/fabrics.ts b/src/fabrics.ts
new file mode 100644
index 0000000..2c4c90e
--- /dev/null
+++ b/src/fabrics.ts
@@ -0,0 +1,153 @@
+import { FabricIndex } from "@matter/main";
+import { OperationalCredentials } from "@matter/main/clusters/operational-credentials";
+import type { CommissioningController } from "@project-chip/matter.js";
+import type { PairedNode } from "@project-chip/matter.js/device";
+import { Log } from "./logging.js";
+
+const log = new Log("fabrics");
+
+/**
+ * Fabric-table management on the device's Operational Credentials cluster.
+ *
+ * Every commissioning writes a fabric entry into the device's limited fabric
+ * table. Entries never expire; identities whose local storage was deleted
+ * leave orphans behind. These helpers make the table visible and allow an
+ * admin (us) to remove stale entries without a factory reset.
+ */
+
+export interface FabricEntry {
+ fabricIndex: number;
+ fabricId: bigint;
+ nodeId: bigint;
+ vendorId: number;
+ label: string;
+ /** True when the entry belongs to this controller's current identity. */
+ isOurs: boolean;
+}
+
+export interface FabricTable {
+ supportedFabrics: number;
+ commissionedFabrics: number;
+ entries: FabricEntry[];
+}
+
+const KNOWN_VENDORS: Record<number, string> = {
+ 0x1349: "Apple",
+ 0x6006: "Google",
+ 0x10e1: "SmartThings",
+ 0x117c: "IKEA",
+};
+
+function vendorName(vendorId: number): string {
+ const known = KNOWN_VENDORS[vendorId];
+ if (known !== undefined) return known;
+ if (vendorId >= 0xfff1 && vendorId <= 0xfff4) return "test vendor";
+ return "unknown vendor";
+}
+
+export async function readFabricTable(
+ controller: CommissioningController,
+ node: PairedNode,
+): Promise<FabricTable> {
+ const client = node.getRootClusterClient(OperationalCredentials.Complete);
+ if (client === undefined) {
+ throw new Error("Operational Credentials cluster not found on the device's root endpoint");
+ }
+
+ // Read from the device with fabric filtering off, so entries from all
+ // fabrics are returned, not just our own.
+ const fabrics = await client.attributes.fabrics.get(true, false);
+ const supportedFabrics = (await client.attributes.supportedFabrics.get(true)) ?? 0;
+ const commissionedFabrics = (await client.attributes.commissionedFabrics.get(true)) ?? 0;
+ if (fabrics === undefined) {
+ throw new Error("Device returned no fabric list");
+ }
+
+ const ourFabric = controller.fabric;
+ const entries = fabrics.map(fabric => ({
+ fabricIndex: Number(fabric.fabricIndex),
+ fabricId: BigInt(fabric.fabricId),
+ nodeId: BigInt(fabric.nodeId),
+ vendorId: Number(fabric.vendorId),
+ label: fabric.label,
+ isOurs: ourFabric.matchesFabricIdAndRootPublicKey(fabric.fabricId, fabric.rootPublicKey),
+ }));
+
+ return { supportedFabrics, commissionedFabrics, entries };
+}
+
+export function formatFabricTable(nodeId: bigint, table: FabricTable): string {
+ const lines: string[] = [];
+ lines.push(
+ `Fabric table on node ${nodeId} (${table.commissionedFabrics} of ${table.supportedFabrics} slots used):`,
+ );
+ for (const entry of table.entries) {
+ const vendor = `0x${entry.vendorId.toString(16).padStart(4, "0")} (${vendorName(entry.vendorId)})`;
+ const marker = entry.isOurs ? " [this controller]" : "";
+ lines.push(
+ ` index ${entry.fabricIndex}: label ${JSON.stringify(entry.label)} vendor ${vendor} ` +
+ `fabricId ${entry.fabricId} nodeId ${entry.nodeId}${marker}`,
+ );
+ }
+ const strays = findLikelyStrays(table);
+ if (strays.length > 0) {
+ lines.push("");
+ lines.push(
+ `Likely stale entries (our label, but not our current identity): ` +
+ `${strays.map(entry => `index ${entry.fabricIndex}`).join(", ")}`,
+ );
+ lines.push(`Remove one with: mattertimesync fabrics --remove <index>`);
+ }
+ return lines.join("\n");
+}
+
+/**
+ * Entries carrying our fabric label but not our current identity: orphans
+ * from a commissioning whose local storage was deleted or replaced.
+ */
+export function findLikelyStrays(table: FabricTable): FabricEntry[] {
+ const ourLabel = table.entries.find(entry => entry.isOurs)?.label;
+ return table.entries.filter(entry => !entry.isOurs && ourLabel !== undefined && entry.label === ourLabel);
+}
+
+/**
+ * Removes another fabric's entry from the device. Refuses to remove our own
+ * entry; `decommission` is the correct path for that, because it also cleans
+ * up local controller state.
+ */
+export async function removeFabricByIndex(
+ controller: CommissioningController,
+ node: PairedNode,
+ fabricIndex: number,
+): Promise<void> {
+ const table = await readFabricTable(controller, node);
+ const entry = table.entries.find(candidate => candidate.fabricIndex === fabricIndex);
+ if (entry === undefined) {
+ throw new Error(
+ `No fabric with index ${fabricIndex} on the device ` +
+ `(present: ${table.entries.map(candidate => candidate.fabricIndex).join(", ")})`,
+ );
+ }
+ if (entry.isOurs) {
+ throw new Error(
+ `Fabric index ${fabricIndex} is this controller's own entry. ` +
+ `Use "decommission" instead, so local state is cleaned up too.`,
+ );
+ }
+
+ const client = node.getRootClusterClient(OperationalCredentials.Complete);
+ if (client === undefined) {
+ throw new Error("Operational Credentials cluster not found on the device's root endpoint");
+ }
+
+ log.info(`Removing fabric index ${fabricIndex} (label ${JSON.stringify(entry.label)}) from the device`);
+ const response = await client.commands.removeFabric({ fabricIndex: FabricIndex(fabricIndex) });
+ if (response.statusCode !== OperationalCredentials.NodeOperationalCertStatus.Ok) {
+ throw new Error(
+ `Device rejected RemoveFabric for index ${fabricIndex}: ` +
+ `status ${OperationalCredentials.NodeOperationalCertStatus[response.statusCode] ?? response.statusCode}` +
+ `${response.debugText ? ` (${response.debugText})` : ""}`,
+ );
+ }
+ log.info(`Fabric index ${fabricIndex} removed`);
+}
diff --git a/src/inspection.ts b/src/inspection.ts
new file mode 100644
index 0000000..b022b60
--- /dev/null
+++ b/src/inspection.ts
@@ -0,0 +1,272 @@
+import { ClusterId } from "@matter/main";
+import { SupportedAttributeClient } from "@project-chip/matter.js/cluster";
+import type { Endpoint, PairedNode } from "@project-chip/matter.js/device";
+import { Log } from "./logging.js";
+
+const log = new Log("inspection");
+
+const TIME_SYNC_CLUSTER_ID = ClusterId(0x38);
+/** Standard cluster IDs are <= 0x7fff; anything above is manufacturer-specific. */
+const LAST_STANDARD_CLUSTER_ID = 0x7fff;
+
+export interface ClusterSummary {
+ id: string;
+ name: string;
+ revision: number;
+ isUnknown: boolean;
+ vendorSpecific: boolean;
+ supportedFeatures: Record<string, boolean>;
+}
+
+export interface EndpointSummary {
+ endpointId: number;
+ name: string;
+ deviceTypes: { name: string; code: string; revision: number }[];
+ serverClusters: ClusterSummary[];
+ /** Client-side cluster IDs from the endpoint's descriptor. */
+ clientClusters: string[];
+}
+
+export interface TimeSyncAttribute {
+ id: string;
+ name: string;
+ value: unknown;
+}
+
+export interface TimeSyncSummary {
+ endpointId: number;
+ clusterRevision: number;
+ supportedFeatures: Record<string, boolean>;
+ featureMap: unknown;
+ supportedCommands: Record<string, boolean>;
+ attributes: TimeSyncAttribute[];
+}
+
+export interface InspectionReport {
+ nodeId: string;
+ basicInformation: {
+ vendorName: string;
+ vendorId: number;
+ productName: string;
+ productId: number;
+ hardwareVersion: string;
+ softwareVersion: string;
+ serialNumber?: string;
+ uniqueId?: string;
+ } | null;
+ endpoints: EndpointSummary[];
+ timeSynchronization: TimeSyncSummary | null;
+ vendorSpecificClusters: { endpointId: number; clusterId: string; name: string }[];
+}
+
+/** Commands whose availability drives the capability-based sync stages. */
+const TIME_SYNC_COMMANDS = [
+ "setUtcTime",
+ "setTrustedTimeSource",
+ "setTimeZone",
+ "setDstOffset",
+ "setDefaultNtp",
+] as const;
+
+export async function inspectNode(node: PairedNode): Promise<InspectionReport> {
+ const endpoints = collectEndpoints(node);
+
+ const endpointSummaries = endpoints.map(endpoint => summarizeEndpoint(endpoint));
+
+ const info = node.basicInformation;
+ const basicInformation = info
+ ? {
+ vendorName: info.vendorName,
+ vendorId: Number(info.vendorId),
+ productName: info.productName,
+ productId: info.productId,
+ hardwareVersion: info.hardwareVersionString,
+ softwareVersion: info.softwareVersionString,
+ serialNumber: info.serialNumber,
+ uniqueId: info.uniqueId,
+ }
+ : null;
+
+ const timeSynchronization = await summarizeTimeSync(endpoints);
+ if (timeSynchronization === null) {
+ log.warn("Time Synchronization cluster not found on any endpoint");
+ } else {
+ log.info(`Time Synchronization cluster found on endpoint ${timeSynchronization.endpointId}`);
+ }
+
+ const vendorSpecificClusters = endpointSummaries.flatMap(endpoint =>
+ endpoint.serverClusters
+ .filter(cluster => cluster.vendorSpecific)
+ .map(cluster => ({ endpointId: endpoint.endpointId, clusterId: cluster.id, name: cluster.name })),
+ );
+
+ return {
+ nodeId: BigInt(node.nodeId).toString(),
+ basicInformation,
+ endpoints: endpointSummaries,
+ timeSynchronization,
+ vendorSpecificClusters,
+ };
+}
+
+function collectEndpoints(node: PairedNode): Endpoint[] {
+ const seen = new Map<number, Endpoint>();
+ const root = node.getRootEndpoint();
+ if (root?.number !== undefined) seen.set(root.number, root);
+ for (const [number, endpoint] of node.parts) {
+ if (!seen.has(number)) seen.set(number, endpoint);
+ }
+ return [...seen.entries()].sort(([a], [b]) => a - b).map(([, endpoint]) => endpoint);
+}
+
+function summarizeEndpoint(endpoint: Endpoint): EndpointSummary {
+ const serverClusters = endpoint.getAllClusterClients().map(client => ({
+ id: formatClusterId(client.id),
+ name: client.name,
+ revision: client.revision,
+ isUnknown: client.isUnknown,
+ vendorSpecific: client.id > LAST_STANDARD_CLUSTER_ID,
+ supportedFeatures: Object.fromEntries(
+ Object.entries(client.supportedFeatures ?? {}).filter(([, enabled]) => typeof enabled === "boolean"),
+ ) as Record<string, boolean>,
+ }));
+
+ let clientClusters: string[] = [];
+ try {
+ clientClusters = (endpoint.state.descriptor.clientList ?? []).map(id => formatClusterId(id));
+ } catch {
+ // Descriptor state unavailable; leave the client list empty.
+ }
+
+ return {
+ endpointId: endpoint.number ?? -1,
+ name: endpoint.name,
+ deviceTypes: endpoint.getDeviceTypes().map(type => ({
+ name: type.name,
+ code: `0x${type.code.toString(16).padStart(4, "0")}`,
+ revision: type.revision,
+ })),
+ serverClusters,
+ clientClusters,
+ };
+}
+
+async function summarizeTimeSync(endpoints: Endpoint[]): Promise<TimeSyncSummary | null> {
+ for (const endpoint of endpoints) {
+ const client = endpoint.getClusterClientById(TIME_SYNC_CLUSTER_ID);
+ if (client === undefined) continue;
+
+ const attributes: TimeSyncAttribute[] = [];
+ for (const [name, attribute] of Object.entries(client.attributes)) {
+ if (!(attribute instanceof SupportedAttributeClient)) continue;
+ let value: unknown;
+ try {
+ value = attribute.getLocal();
+ if (value === undefined) value = await attribute.get();
+ } catch (cause) {
+ value = `<read failed: ${cause instanceof Error ? cause.message : String(cause)}>`;
+ }
+ attributes.push({ id: `0x${attribute.id.toString(16)}`, name, value });
+ }
+
+ const supportedCommands = Object.fromEntries(
+ TIME_SYNC_COMMANDS.map(command => [command, client.isCommandSupportedByName(command)]),
+ );
+
+ const featureMap = attributes.find(attribute => attribute.name === "featureMap")?.value ?? null;
+
+ return {
+ endpointId: endpoint.number ?? -1,
+ clusterRevision: client.revision,
+ supportedFeatures: Object.fromEntries(
+ Object.entries(client.supportedFeatures ?? {}).filter(([, enabled]) => typeof enabled === "boolean"),
+ ) as Record<string, boolean>,
+ featureMap,
+ supportedCommands,
+ attributes,
+ };
+ }
+ return null;
+}
+
+function formatClusterId(id: number): string {
+ return `0x${Number(id).toString(16).padStart(4, "0")}`;
+}
+
+/** JSON output with bigints rendered as decimal strings. */
+export function inspectionToJson(report: InspectionReport): string {
+ return JSON.stringify(report, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2);
+}
+
+export function formatInspection(report: InspectionReport): string {
+ const lines: string[] = [];
+ lines.push(`Node ${report.nodeId}`);
+
+ if (report.basicInformation) {
+ const info = report.basicInformation;
+ lines.push(` Vendor: ${info.vendorName} (0x${info.vendorId.toString(16).padStart(4, "0")})`);
+ lines.push(` Product: ${info.productName} (0x${info.productId.toString(16).padStart(4, "0")})`);
+ lines.push(` Hardware: ${info.hardwareVersion} Software: ${info.softwareVersion}`);
+ if (info.serialNumber) lines.push(` Serial: ${info.serialNumber}`);
+ }
+
+ for (const endpoint of report.endpoints) {
+ lines.push("");
+ const types = endpoint.deviceTypes.map(t => `${t.name} (${t.code}, rev ${t.revision})`).join(", ");
+ lines.push(`Endpoint ${endpoint.endpointId}: ${types}`);
+ if (endpoint.serverClusters.length > 0) {
+ lines.push(" Server clusters:");
+ for (const cluster of endpoint.serverClusters) {
+ const features = Object.entries(cluster.supportedFeatures)
+ .filter(([, enabled]) => enabled)
+ .map(([name]) => name);
+ const notes = [
+ `rev ${cluster.revision}`,
+ ...(features.length > 0 ? [`features: ${features.join(", ")}`] : []),
+ ...(cluster.vendorSpecific ? ["vendor-specific"] : []),
+ ...(cluster.isUnknown ? ["unknown to matter.js"] : []),
+ ];
+ lines.push(` ${cluster.id} ${cluster.name} (${notes.join("; ")})`);
+ }
+ }
+ if (endpoint.clientClusters.length > 0) {
+ lines.push(` Client clusters: ${endpoint.clientClusters.join(", ")}`);
+ }
+ }
+
+ lines.push("");
+ if (report.timeSynchronization) {
+ const ts = report.timeSynchronization;
+ lines.push(`Time Synchronization cluster (endpoint ${ts.endpointId}, revision ${ts.clusterRevision})`);
+ const features = Object.entries(ts.supportedFeatures)
+ .filter(([, enabled]) => enabled)
+ .map(([name]) => name);
+ lines.push(` Features: ${features.length > 0 ? features.join(", ") : "(none)"}`);
+ lines.push(" Commands:");
+ for (const [command, supported] of Object.entries(ts.supportedCommands)) {
+ lines.push(` ${command}: ${supported ? "supported" : "not supported"}`);
+ }
+ lines.push(" Attributes:");
+ for (const attribute of ts.attributes) {
+ lines.push(` ${attribute.name} (${attribute.id}): ${renderValue(attribute.value)}`);
+ }
+ } else {
+ lines.push("Time Synchronization cluster: NOT FOUND");
+ lines.push(" This device cannot be synchronized through the standard Matter mechanism.");
+ }
+
+ if (report.vendorSpecificClusters.length > 0) {
+ lines.push("");
+ lines.push("Vendor-specific clusters:");
+ for (const cluster of report.vendorSpecificClusters) {
+ lines.push(` endpoint ${cluster.endpointId}: ${cluster.clusterId} ${cluster.name}`);
+ }
+ }
+
+ return lines.join("\n");
+}
+
+function renderValue(value: unknown): string {
+ if (value === undefined) return "<not read>";
+ return JSON.stringify(value, (_key, inner) => (typeof inner === "bigint" ? inner.toString() : inner));
+}
diff --git a/src/logging.ts b/src/logging.ts
new file mode 100644
index 0000000..c8c75d7
--- /dev/null
+++ b/src/logging.ts
@@ -0,0 +1,71 @@
+/**
+ * Structured, level-filtered logging for the service's own messages.
+ *
+ * matter.js library logging is configured separately (see controller.ts); this
+ * logger covers service-level events in the format:
+ *
+ * 2026-07-26T20:00:00.100Z INFO controller Connected to Matter node 1
+ */
+
+export const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
+export type LogLevel = (typeof LOG_LEVELS)[number];
+
+export function isLogLevel(value: unknown): value is LogLevel {
+ return typeof value === "string" && (LOG_LEVELS as readonly string[]).includes(value);
+}
+
+const LEVEL_RANK: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
+const LEVEL_LABEL: Record<LogLevel, string> = {
+ debug: "DEBUG",
+ info: "INFO ",
+ warn: "WARN ",
+ error: "ERROR",
+};
+
+let globalLevel: LogLevel = "info";
+
+export function setLogLevel(level: LogLevel): void {
+ globalLevel = level;
+}
+
+export function getLogLevel(): LogLevel {
+ return globalLevel;
+}
+
+function write(level: LogLevel, module: string, message: string): void {
+ if (LEVEL_RANK[level] < LEVEL_RANK[globalLevel]) return;
+ const line = `${new Date().toISOString()} ${LEVEL_LABEL[level]} ${module} ${message}`;
+ if (level === "error" || level === "warn") {
+ process.stderr.write(line + "\n");
+ } else {
+ process.stdout.write(line + "\n");
+ }
+}
+
+/** A logger bound to a module name (e.g. "controller", "sync"). */
+export class Log {
+ constructor(private readonly module: string) {}
+
+ debug(message: string): void {
+ write("debug", this.module, message);
+ }
+
+ info(message: string): void {
+ write("info", this.module, message);
+ }
+
+ warn(message: string): void {
+ write("warn", this.module, message);
+ }
+
+ error(message: string, cause?: unknown): void {
+ write("error", this.module, cause === undefined ? message : `${message}: ${describeError(cause)}`);
+ }
+}
+
+export function describeError(cause: unknown): string {
+ if (cause instanceof Error) {
+ return cause.message;
+ }
+ return String(cause);
+}
diff --git a/src/state.ts b/src/state.ts
new file mode 100644
index 0000000..c5621fe
--- /dev/null
+++ b/src/state.ts
@@ -0,0 +1,90 @@
+import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+/**
+ * Small service-state file kept next to the Matter fabric storage, with one
+ * entry per commissioned node (keyed by node ID as a decimal string). Written
+ * atomically (temp file + rename) so a power loss mid-write never corrupts it.
+ */
+export interface NodeState {
+ lastSuccessfulConnection: string | null;
+ lastSuccessfulSync: string | null;
+ lastAttemptedSync: string | null;
+ lastError: string | null;
+}
+
+export interface ServiceState {
+ nodes: Record<string, NodeState>;
+}
+
+export const EMPTY_NODE_STATE: NodeState = {
+ lastSuccessfulConnection: null,
+ lastSuccessfulSync: null,
+ lastAttemptedSync: null,
+ lastError: null,
+};
+
+export function serviceStatePath(storagePath: string): string {
+ return join(storagePath, "service-state.json");
+}
+
+export function readServiceState(path: string): ServiceState {
+ let raw: string;
+ try {
+ raw = readFileSync(path, "utf8");
+ } catch {
+ return { nodes: {} };
+ }
+ try {
+ const parsed = JSON.parse(raw) as Partial<ServiceState>;
+ const nodes: Record<string, NodeState> = {};
+ if (typeof parsed.nodes === "object" && parsed.nodes !== null) {
+ for (const [nodeId, value] of Object.entries(parsed.nodes)) {
+ if (!/^\d+$/.test(nodeId) || typeof value !== "object" || value === null) continue;
+ const entry = value as Partial<NodeState>;
+ nodes[nodeId] = {
+ lastSuccessfulConnection: stringOrNull(entry.lastSuccessfulConnection),
+ lastSuccessfulSync: stringOrNull(entry.lastSuccessfulSync),
+ lastAttemptedSync: stringOrNull(entry.lastAttemptedSync),
+ lastError: stringOrNull(entry.lastError),
+ };
+ }
+ }
+ return { nodes };
+ } catch {
+ // A corrupt state file is not fatal; it only holds status metadata.
+ return { nodes: {} };
+ }
+}
+
+export function writeServiceState(path: string, state: ServiceState): void {
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
+ const temp = `${path}.tmp`;
+ writeFileSync(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
+ renameSync(temp, path);
+}
+
+export function nodeState(state: ServiceState, nodeId: bigint): NodeState {
+ return state.nodes[nodeId.toString()] ?? { ...EMPTY_NODE_STATE };
+}
+
+/** Merges a patch into one node's entry and persists the result. */
+export function updateNodeState(path: string, nodeId: bigint, patch: Partial<NodeState>): ServiceState {
+ const state = readServiceState(path);
+ const key = nodeId.toString();
+ state.nodes[key] = { ...(state.nodes[key] ?? EMPTY_NODE_STATE), ...patch };
+ writeServiceState(path, state);
+ return state;
+}
+
+/** Drops one node's entry (after decommissioning) and persists the result. */
+export function removeNodeState(path: string, nodeId: bigint): ServiceState {
+ const state = readServiceState(path);
+ delete state.nodes[nodeId.toString()];
+ writeServiceState(path, state);
+ return state;
+}
+
+function stringOrNull(value: unknown): string | null {
+ return typeof value === "string" ? value : null;
+}
diff --git a/src/sync.ts b/src/sync.ts
new file mode 100644
index 0000000..ec3577c
--- /dev/null
+++ b/src/sync.ts
@@ -0,0 +1,120 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+/**
+ * Matter epoch conversion.
+ *
+ * Matter UTC time is microseconds since 2000-01-01T00:00:00Z (the "Matter
+ * epoch"), not the Unix epoch. All arithmetic stays in bigint; the full
+ * microsecond timestamp must never pass through a JavaScript number.
+ *
+ * The Time Synchronization write commands (SetUtcTime, SetTimeZone,
+ * SetDstOffset) are intentionally NOT implemented yet: per the project plan
+ * they are Phase 5/6 work that must be driven by the real device's inspected
+ * capabilities. Run `inspect` against the hardware first.
+ */
+
+export const MATTER_EPOCH_UNIX_SECONDS = 946_684_800n;
+
+/** Converts a Unix-epoch timestamp in milliseconds to Matter-epoch microseconds. */
+export function unixMillisToMatterMicros(unixMilliseconds: bigint): bigint {
+ return unixMilliseconds * 1_000n - MATTER_EPOCH_UNIX_SECONDS * 1_000_000n;
+}
+
+/** Converts Matter-epoch microseconds back to Unix-epoch milliseconds (truncating). */
+export function matterMicrosToUnixMillis(matterMicroseconds: bigint): bigint {
+ return (matterMicroseconds + MATTER_EPOCH_UNIX_SECONDS * 1_000_000n) / 1_000n;
+}
+
+/** Current time as Matter-epoch microseconds. */
+export function currentMatterUtcMicroseconds(now: () => number = Date.now): bigint {
+ return unixMillisToMatterMicros(BigInt(now()));
+}
+
+/** Renders Matter-epoch microseconds as an ISO-8601 UTC instant (millisecond precision). */
+export function formatMatterMicros(matterMicroseconds: bigint): string {
+ return new Date(Number(matterMicrosToUnixMillis(matterMicroseconds))).toISOString();
+}
+
+/**
+ * Comparison of the device's reported clock against the time we are about to
+ * set, for the sync report ("was X, set to Y, delta Z").
+ */
+export interface ClockComparison {
+ /** Device time before sync as ISO-8601, or null if its clock was unset. */
+ deviceTime: string | null;
+ /** The time being written, as ISO-8601. */
+ hostTime: string;
+ /** deviceTime - hostTime in microseconds; null when the device clock was unset. */
+ deltaMicroseconds: bigint | null;
+ /** Human-readable summary, e.g. "device clock was 3.2s behind". */
+ description: string;
+}
+
+/**
+ * Compares the device's utcTime attribute (null when the device lost its
+ * clock, e.g. after a power outage) against the host time being written.
+ */
+export function compareDeviceClock(deviceMicros: bigint | null, hostMicros: bigint): ClockComparison {
+ const hostTime = formatMatterMicros(hostMicros);
+ if (deviceMicros === null) {
+ return {
+ deviceTime: null,
+ hostTime,
+ deltaMicroseconds: null,
+ description: "device clock was unset",
+ };
+ }
+ const delta = deviceMicros - hostMicros;
+ const magnitude = delta < 0n ? -delta : delta;
+ const description =
+ magnitude < 1_000_000n
+ ? `device clock was within 1s of host time (${formatDurationMicros(magnitude)} ${
+ delta < 0n ? "behind" : "ahead"
+ })`
+ : `device clock was ${formatDurationMicros(magnitude)} ${delta < 0n ? "behind" : "ahead"}`;
+ return { deviceTime: formatMatterMicros(deviceMicros), hostTime, deltaMicroseconds: delta, description };
+}
+
+/** Formats a non-negative duration in microseconds as a compact human string. */
+export function formatDurationMicros(microseconds: bigint): string {
+ if (microseconds < 0n) throw new Error("duration must be non-negative");
+ if (microseconds < 1_000n) return `${microseconds}us`;
+ if (microseconds < 1_000_000n) return `${microseconds / 1_000n}ms`;
+
+ const totalSeconds = microseconds / 1_000_000n;
+ if (totalSeconds < 60n) {
+ const tenths = (microseconds % 1_000_000n) / 100_000n;
+ return tenths === 0n ? `${totalSeconds}s` : `${totalSeconds}.${tenths}s`;
+ }
+ const days = totalSeconds / 86_400n;
+ const hours = (totalSeconds % 86_400n) / 3_600n;
+ const minutes = (totalSeconds % 3_600n) / 60n;
+ const seconds = totalSeconds % 60n;
+ const parts: string[] = [];
+ if (days > 0n) parts.push(`${days}d`);
+ if (hours > 0n) parts.push(`${hours}h`);
+ if (minutes > 0n) parts.push(`${minutes}m`);
+ if (seconds > 0n && days === 0n) parts.push(`${seconds}s`);
+ return parts.join(" ");
+}
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Whether the host clock is NTP-synchronized, via systemd-timesyncd. The
+ * device must never be set from an unsynchronized clock.
+ *
+ * Returns false on hosts without timedatectl (e.g. during development on
+ * macOS) so callers fail safe; pass `assumeSynchronized` explicitly in tests.
+ */
+export async function isHostClockSynchronized(): Promise<boolean> {
+ try {
+ const { stdout } = await execFileAsync("timedatectl", ["show", "-p", "NTPSynchronized", "--value"], {
+ timeout: 5000,
+ });
+ return stdout.trim() === "yes";
+ } catch {
+ return false;
+ }
+}
diff --git a/src/timezone.ts b/src/timezone.ts
new file mode 100644
index 0000000..f4240f4
--- /dev/null
+++ b/src/timezone.ts
@@ -0,0 +1,106 @@
+/**
+ * IANA time-zone helpers.
+ *
+ * Phase 1-4 scope: validation and offset introspection used by config
+ * validation and status output. Matter TimeZone/DSTOffset structure
+ * generation (Phase 6) will build on these helpers once the real device's
+ * Time Synchronization capabilities have been inspected.
+ */
+
+/** Returns true when the host's ICU data recognizes the IANA time-zone name. */
+export function isValidTimeZone(timezone: string): boolean {
+ if (typeof timezone !== "string" || timezone.length === 0) return false;
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Current UTC offset of `timezone` at `date`, in seconds.
+ *
+ * Derived from ICU rules, never from a hard-coded offset, so daylight-saving
+ * transitions are honored automatically.
+ */
+export function utcOffsetSeconds(timezone: string, date: Date = new Date()): number {
+ const formatter = new Intl.DateTimeFormat("en-US", {
+ timeZone: timezone,
+ timeZoneName: "longOffset",
+ });
+ const offsetPart = formatter.formatToParts(date).find(part => part.type === "timeZoneName")?.value;
+ if (offsetPart === undefined) {
+ throw new Error(`Unable to determine UTC offset for time zone ${timezone}`);
+ }
+ // Formats look like "GMT-05:00", "GMT+05:30", or plain "GMT" for UTC itself.
+ const match = /^GMT(?:([+-])(\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(offsetPart);
+ if (!match) {
+ throw new Error(`Unrecognized UTC offset format "${offsetPart}" for time zone ${timezone}`);
+ }
+ if (match[1] === undefined) return 0;
+ const sign = match[1] === "-" ? -1 : 1;
+ const hours = Number(match[2]);
+ const minutes = Number(match[3]);
+ const seconds = match[4] === undefined ? 0 : Number(match[4]);
+ return sign * (hours * 3600 + minutes * 60 + seconds);
+}
+
+/** Formats an offset in seconds as "UTC-05:00" style text for logs and status output. */
+export function formatUtcOffset(offsetSeconds: number): string {
+ const sign = offsetSeconds < 0 ? "-" : "+";
+ const absolute = Math.abs(offsetSeconds);
+ const hours = String(Math.floor(absolute / 3600)).padStart(2, "0");
+ const minutes = String(Math.floor((absolute % 3600) / 60)).padStart(2, "0");
+ return `UTC${sign}${hours}:${minutes}`;
+}
+
+/**
+ * Finds the next instant at which the zone's UTC offset changes, scanning up
+ * to `horizonDays` ahead. Returns null when no transition occurs in the
+ * window (e.g. fixed-offset zones).
+ *
+ * Uses day-granularity scan plus binary search, so it is exact to the second
+ * without iterating minute-by-minute.
+ */
+export function nextOffsetTransition(
+ timezone: string,
+ from: Date = new Date(),
+ horizonDays = 400,
+): { at: Date; offsetBeforeSeconds: number; offsetAfterSeconds: number } | null {
+ const startOffset = utcOffsetSeconds(timezone, from);
+ const dayMs = 24 * 60 * 60 * 1000;
+ // Probe on whole-second boundaries so the binary search converges on the
+ // exact transition instant (transitions occur at whole seconds).
+ const startMs = Math.ceil(from.getTime() / 1000) * 1000;
+
+ let previous = startMs;
+ let changedAtOrBefore: number | null = null;
+ for (let day = 1; day <= horizonDays; day++) {
+ const probe = startMs + day * dayMs;
+ if (utcOffsetSeconds(timezone, new Date(probe)) !== startOffset) {
+ changedAtOrBefore = probe;
+ break;
+ }
+ previous = probe;
+ }
+ if (changedAtOrBefore === null) return null;
+
+ // Binary search the exact transition instant between the last unchanged
+ // probe and the first changed probe.
+ let low = previous;
+ let high = changedAtOrBefore;
+ while (high - low > 1000) {
+ const middle = low + Math.floor((high - low) / 2 / 1000) * 1000;
+ if (utcOffsetSeconds(timezone, new Date(middle)) === startOffset) {
+ low = middle;
+ } else {
+ high = middle;
+ }
+ }
+ return {
+ at: new Date(high),
+ offsetBeforeSeconds: startOffset,
+ offsetAfterSeconds: utcOffsetSeconds(timezone, new Date(high)),
+ };
+}
diff --git a/test/commissioning.test.ts b/test/commissioning.test.ts
new file mode 100644
index 0000000..676a5e1
--- /dev/null
+++ b/test/commissioning.test.ts
@@ -0,0 +1,33 @@
+import { ManualPairingCodeCodec } from "@matter/main/types";
+import { describe, expect, it } from "vitest";
+import { parsePairingCode } from "../src/commissioning.js";
+
+describe("parsePairingCode", () => {
+ const validCode = ManualPairingCodeCodec.encode({ discriminator: 3840, passcode: 20202021 });
+
+ it("parses a valid 11-digit manual code", () => {
+ const parsed = parsePairingCode(validCode);
+ expect(parsed.passcode).toBe(20202021);
+ expect(parsed.shortDiscriminator).toBe(3840 >> 8);
+ });
+
+ it("accepts hyphens and whitespace", () => {
+ const withHyphens = `${validCode.slice(0, 4)}-${validCode.slice(4, 7)}-${validCode.slice(7)}`;
+ expect(parsePairingCode(` ${withHyphens} `).passcode).toBe(20202021);
+ });
+
+ it("rejects empty input", () => {
+ expect(() => parsePairingCode("")).toThrow(/empty/);
+ expect(() => parsePairingCode(" ")).toThrow(/empty/);
+ });
+
+ it("rejects codes of the wrong length", () => {
+ expect(() => parsePairingCode("12345")).toThrow(/11 or 21 digits/);
+ expect(() => parsePairingCode("123456789012")).toThrow(/11 or 21 digits/);
+ });
+
+ it("rejects codes with an invalid checksum", () => {
+ const corrupted = validCode.slice(0, -1) + ((Number(validCode.at(-1)) + 1) % 10).toString();
+ expect(() => parsePairingCode(corrupted)).toThrow();
+ });
+});
diff --git a/test/config.test.ts b/test/config.test.ts
new file mode 100644
index 0000000..1caecd8
--- /dev/null
+++ b/test/config.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { ConfigError, parseNodeId, validateConfig } from "../src/config.js";
+
+const minimal = { storagePath: "/var/lib/mattertimesync" };
+
+describe("validateConfig", () => {
+ it("accepts a minimal configuration and applies defaults", () => {
+ const config = validateConfig(minimal);
+ expect(config.storagePath).toBe(minimal.storagePath);
+ expect(config.timezone).toBe("America/Chicago");
+ expect(config.logLevel).toBe("info");
+ });
+
+ it("accepts a full configuration", () => {
+ const config = validateConfig({
+ ...minimal,
+ timezone: "Europe/Berlin",
+ logLevel: "debug",
+ });
+ expect(config.timezone).toBe("Europe/Berlin");
+ expect(config.logLevel).toBe("debug");
+ });
+
+ it("rejects non-object documents", () => {
+ expect(() => validateConfig([])).toThrow(ConfigError);
+ expect(() => validateConfig("x")).toThrow(ConfigError);
+ expect(() => validateConfig(null)).toThrow(ConfigError);
+ });
+
+ it("requires storagePath", () => {
+ expect(() => validateConfig({})).toThrow(/storagePath/);
+ expect(() => validateConfig({ storagePath: "" })).toThrow(/storagePath/);
+ });
+
+ it("rejects unknown fields", () => {
+ expect(() => validateConfig({ ...minimal, unexpected: 1 })).toThrow(/unknown field "unexpected"/);
+ // Fields from abandoned designs are rejected, not ignored.
+ expect(() => validateConfig({ ...minimal, syncIntervalHours: 24 })).toThrow(/unknown field/);
+ expect(() => validateConfig({ ...minimal, syncOnReconnect: true })).toThrow(/unknown field/);
+ expect(() => validateConfig({ ...minimal, nodeId: "1" })).toThrow(/unknown field/);
+ });
+
+ it("rejects invalid time zones", () => {
+ expect(() => validateConfig({ ...minimal, timezone: "Central Time" })).toThrow(/timezone/);
+ expect(() => validateConfig({ ...minimal, timezone: "" })).toThrow(/timezone/);
+ expect(() => validateConfig({ ...minimal, timezone: 5 })).toThrow(/timezone/);
+ });
+
+ it("rejects invalid log levels", () => {
+ expect(() => validateConfig({ ...minimal, logLevel: "verbose" })).toThrow(/logLevel/);
+ });
+});
+
+describe("parseNodeId", () => {
+ it("parses decimal strings without precision loss", () => {
+ expect(parseNodeId("1")).toBe(1n);
+ expect(parseNodeId("9007199254740993")).toBe(9007199254740993n);
+ expect(parseNodeId("18446744073709551615")).toBe(18446744073709551615n);
+ });
+
+ it("accepts small safe integers", () => {
+ expect(parseNodeId(42)).toBe(42n);
+ });
+
+ it("rejects unsafe or invalid values", () => {
+ expect(() => parseNodeId("18446744073709551616")).toThrow(ConfigError); // 2^64
+ expect(() => parseNodeId("-1")).toThrow(ConfigError);
+ expect(() => parseNodeId(1.5)).toThrow(ConfigError);
+ expect(() => parseNodeId("0x10")).toThrow(ConfigError);
+ expect(() => parseNodeId(Number.MAX_SAFE_INTEGER + 2)).toThrow(ConfigError);
+ expect(() => parseNodeId(null)).toThrow(ConfigError);
+ });
+});
diff --git a/test/fabrics.test.ts b/test/fabrics.test.ts
new file mode 100644
index 0000000..b3cd18e
--- /dev/null
+++ b/test/fabrics.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { findLikelyStrays, formatFabricTable, type FabricTable } from "../src/fabrics.js";
+
+const table: FabricTable = {
+ supportedFabrics: 5,
+ commissionedFabrics: 4,
+ entries: [
+ { fabricIndex: 1, fabricId: 100n, nodeId: 10n, vendorId: 0x1349, label: "Apple Home", isOurs: false },
+ { fabricIndex: 2, fabricId: 200n, nodeId: 20n, vendorId: 0xfff1, label: "mattertimesync", isOurs: true },
+ { fabricIndex: 3, fabricId: 300n, nodeId: 30n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false },
+ { fabricIndex: 4, fabricId: 400n, nodeId: 40n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false },
+ ],
+};
+
+describe("findLikelyStrays", () => {
+ it("flags entries with our label that are not our identity", () => {
+ expect(findLikelyStrays(table).map(entry => entry.fabricIndex)).toEqual([3, 4]);
+ });
+
+ it("never flags other ecosystems' entries", () => {
+ const strays = findLikelyStrays(table);
+ expect(strays.some(entry => entry.label === "Apple Home")).toBe(false);
+ });
+
+ it("flags nothing when our identity is not in the table", () => {
+ const withoutOurs: FabricTable = {
+ ...table,
+ entries: table.entries.map(entry => ({ ...entry, isOurs: false })),
+ };
+ expect(findLikelyStrays(withoutOurs)).toEqual([]);
+ });
+});
+
+describe("formatFabricTable", () => {
+ const output = formatFabricTable(1n, table);
+
+ it("shows slot usage and marks our entry", () => {
+ expect(output).toContain("4 of 5 slots used");
+ expect(output).toContain("[this controller]");
+ });
+
+ it("names known vendors and lists stale indices", () => {
+ expect(output).toContain("(Apple)");
+ expect(output).toContain("index 3, index 4");
+ });
+});
diff --git a/test/state.test.ts b/test/state.test.ts
new file mode 100644
index 0000000..a80fcfd
--- /dev/null
+++ b/test/state.test.ts
@@ -0,0 +1,103 @@
+import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ EMPTY_NODE_STATE,
+ nodeState,
+ readServiceState,
+ removeNodeState,
+ serviceStatePath,
+ updateNodeState,
+ writeServiceState,
+} from "../src/state.js";
+
+let dir: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "mattertimesync-state-"));
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("service state", () => {
+ it("returns empty state when the file does not exist", () => {
+ expect(readServiceState(join(dir, "missing.json"))).toEqual({ nodes: {} });
+ });
+
+ it("round-trips per-node state through disk", () => {
+ const path = serviceStatePath(dir);
+ const state = {
+ nodes: {
+ "12345678901234567890": {
+ lastSuccessfulConnection: "2026-07-26T20:00:00Z",
+ lastSuccessfulSync: "2026-07-26T20:00:02Z",
+ lastAttemptedSync: "2026-07-26T20:00:01Z",
+ lastError: null,
+ },
+ "2": { ...EMPTY_NODE_STATE, lastError: "unreachable" },
+ },
+ };
+ writeServiceState(path, state);
+ expect(readServiceState(path)).toEqual(state);
+ });
+
+ it("leaves no temporary file behind after writing", () => {
+ const path = serviceStatePath(dir);
+ writeServiceState(path, { nodes: {} });
+ expect(readdirSync(dir)).toEqual(["service-state.json"]);
+ });
+
+ it("returns empty state for a corrupt file instead of throwing", () => {
+ const path = join(dir, "corrupt.json");
+ writeFileSync(path, "{ this is not json");
+ expect(readServiceState(path)).toEqual({ nodes: {} });
+ });
+
+ it("drops malformed node keys and wrongly typed fields", () => {
+ const path = join(dir, "typed.json");
+ writeFileSync(
+ path,
+ JSON.stringify({
+ nodes: {
+ "not-a-node-id": { lastError: "x" },
+ "7": { lastError: ["array"], lastSuccessfulSync: "2026-07-26T20:00:02Z" },
+ },
+ }),
+ );
+ expect(readServiceState(path)).toEqual({
+ nodes: { "7": { ...EMPTY_NODE_STATE, lastSuccessfulSync: "2026-07-26T20:00:02Z" } },
+ });
+ });
+
+ it("merges patches into one node without touching others", () => {
+ const path = serviceStatePath(dir);
+ updateNodeState(path, 1n, { lastError: "boom" });
+ updateNodeState(path, 2n, { lastSuccessfulSync: "2026-07-26T20:00:02Z" });
+ updateNodeState(path, 1n, { lastAttemptedSync: "2026-07-26T20:00:01Z" });
+
+ const state = readServiceState(path);
+ expect(nodeState(state, 1n)).toEqual({
+ ...EMPTY_NODE_STATE,
+ lastError: "boom",
+ lastAttemptedSync: "2026-07-26T20:00:01Z",
+ });
+ expect(nodeState(state, 2n)).toEqual({
+ ...EMPTY_NODE_STATE,
+ lastSuccessfulSync: "2026-07-26T20:00:02Z",
+ });
+ });
+
+ it("removes a node's entry", () => {
+ const path = serviceStatePath(dir);
+ updateNodeState(path, 1n, { lastError: "boom" });
+ updateNodeState(path, 2n, {});
+ removeNodeState(path, 1n);
+
+ const state = readServiceState(path);
+ expect(Object.keys(state.nodes)).toEqual(["2"]);
+ expect(nodeState(state, 1n)).toEqual(EMPTY_NODE_STATE);
+ });
+});
diff --git a/test/sync.test.ts b/test/sync.test.ts
new file mode 100644
index 0000000..0bc5dc9
--- /dev/null
+++ b/test/sync.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest";
+import {
+ MATTER_EPOCH_UNIX_SECONDS,
+ compareDeviceClock,
+ currentMatterUtcMicroseconds,
+ formatDurationMicros,
+ formatMatterMicros,
+ matterMicrosToUnixMillis,
+ unixMillisToMatterMicros,
+} from "../src/sync.js";
+
+describe("Matter epoch conversion", () => {
+ it("maps 2000-01-01T00:00:00Z to 0 microseconds", () => {
+ const unixMs = BigInt(Date.parse("2000-01-01T00:00:00Z"));
+ expect(unixMillisToMatterMicros(unixMs)).toBe(0n);
+ });
+
+ it("maps 2000-01-01T00:00:01Z to 1,000,000 microseconds", () => {
+ const unixMs = BigInt(Date.parse("2000-01-01T00:00:01Z"));
+ expect(unixMillisToMatterMicros(unixMs)).toBe(1_000_000n);
+ });
+
+ it("keeps the Unix epoch conversion exact", () => {
+ expect(unixMillisToMatterMicros(0n)).toBe(-MATTER_EPOCH_UNIX_SECONDS * 1_000_000n);
+ expect(unixMillisToMatterMicros(0n)).toBe(-946_684_800_000_000n);
+ });
+
+ it("keeps current dates exact through bigint round-trips", () => {
+ const unixMs = BigInt(Date.parse("2026-07-26T20:00:00.123Z"));
+ const micros = unixMillisToMatterMicros(unixMs);
+ expect(micros).toBe((unixMs - 946_684_800_000n) * 1_000n);
+ expect(matterMicrosToUnixMillis(micros)).toBe(unixMs);
+ });
+
+ it("stays exact beyond JavaScript number precision", () => {
+ // Microsecond timestamps pass Number.MAX_SAFE_INTEGER around year 2285;
+ // conversion must not degrade there, proving no number arithmetic occurs.
+ const unixMs = BigInt(Date.parse("2300-01-01T00:00:00.001Z"));
+ const micros = unixMillisToMatterMicros(unixMs);
+ expect(micros > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true);
+ expect(matterMicrosToUnixMillis(micros)).toBe(unixMs);
+ });
+
+ it("computes the current time via the injected clock", () => {
+ const nowMs = Date.parse("2026-07-26T20:00:00Z");
+ const micros = currentMatterUtcMicroseconds(() => nowMs);
+ expect(micros).toBe(unixMillisToMatterMicros(BigInt(nowMs)));
+ });
+
+ it("formats Matter microseconds as an ISO instant", () => {
+ expect(formatMatterMicros(0n)).toBe("2000-01-01T00:00:00.000Z");
+ expect(formatMatterMicros(1_000_000n)).toBe("2000-01-01T00:00:01.000Z");
+ });
+});
+
+describe("formatDurationMicros", () => {
+ it("scales units with magnitude", () => {
+ expect(formatDurationMicros(0n)).toBe("0us");
+ expect(formatDurationMicros(999n)).toBe("999us");
+ expect(formatDurationMicros(412_000n)).toBe("412ms");
+ expect(formatDurationMicros(3_200_000n)).toBe("3.2s");
+ expect(formatDurationMicros(59_000_000n)).toBe("59s");
+ expect(formatDurationMicros(125_000_000n)).toBe("2m 5s");
+ expect(formatDurationMicros(3_840_000_000n)).toBe("1h 4m");
+ // 90,061 seconds = 1d 1h 1m 1s; seconds are dropped once days appear.
+ expect(formatDurationMicros(90_061_000_000n)).toBe("1d 1h 1m");
+ });
+
+ it("rejects negative durations", () => {
+ expect(() => formatDurationMicros(-1n)).toThrow();
+ });
+});
+
+describe("compareDeviceClock", () => {
+ const host = unixMillisToMatterMicros(BigInt(Date.parse("2026-07-26T20:00:00.000Z")));
+
+ it("reports an unset device clock", () => {
+ const comparison = compareDeviceClock(null, host);
+ expect(comparison.deviceTime).toBeNull();
+ expect(comparison.deltaMicroseconds).toBeNull();
+ expect(comparison.hostTime).toBe("2026-07-26T20:00:00.000Z");
+ expect(comparison.description).toBe("device clock was unset");
+ });
+
+ it("reports a device clock behind the host", () => {
+ const comparison = compareDeviceClock(host - 83_000_000n, host);
+ expect(comparison.deviceTime).toBe("2026-07-26T19:58:37.000Z");
+ expect(comparison.deltaMicroseconds).toBe(-83_000_000n);
+ expect(comparison.description).toBe("device clock was 1m 23s behind");
+ });
+
+ it("reports a device clock ahead of the host", () => {
+ const comparison = compareDeviceClock(host + 5_500_000n, host);
+ expect(comparison.deltaMicroseconds).toBe(5_500_000n);
+ expect(comparison.description).toBe("device clock was 5.5s ahead");
+ });
+
+ it("treats sub-second deltas as in sync but still reports them", () => {
+ const comparison = compareDeviceClock(host - 412_000n, host);
+ expect(comparison.description).toBe("device clock was within 1s of host time (412ms behind)");
+ });
+
+ it("handles a device clock wildly in the future (wrong epoch bug)", () => {
+ // A device set with Unix-epoch seconds where Matter-epoch was expected
+ // shows up decades ahead; the delta must stay exact.
+ const wrong = host + 946_684_800n * 1_000_000n;
+ const comparison = compareDeviceClock(wrong, host);
+ expect(comparison.deltaMicroseconds).toBe(946_684_800_000_000n);
+ expect(comparison.description).toContain("ahead");
+ });
+});
diff --git a/test/timezone.test.ts b/test/timezone.test.ts
new file mode 100644
index 0000000..f6c2230
--- /dev/null
+++ b/test/timezone.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { formatUtcOffset, isValidTimeZone, nextOffsetTransition, utcOffsetSeconds } from "../src/timezone.js";
+
+describe("isValidTimeZone", () => {
+ it("accepts IANA names", () => {
+ expect(isValidTimeZone("America/Chicago")).toBe(true);
+ expect(isValidTimeZone("Europe/Berlin")).toBe(true);
+ expect(isValidTimeZone("UTC")).toBe(true);
+ });
+
+ it("rejects invalid names", () => {
+ expect(isValidTimeZone("Central Time")).toBe(false);
+ expect(isValidTimeZone("")).toBe(false);
+ expect(isValidTimeZone("America/Springfield")).toBe(false);
+ });
+});
+
+describe("utcOffsetSeconds", () => {
+ it("returns CST offset in January", () => {
+ expect(utcOffsetSeconds("America/Chicago", new Date("2026-01-15T12:00:00Z"))).toBe(-6 * 3600);
+ });
+
+ it("returns CDT offset in July", () => {
+ expect(utcOffsetSeconds("America/Chicago", new Date("2026-07-15T12:00:00Z"))).toBe(-5 * 3600);
+ });
+
+ it("returns 0 for UTC", () => {
+ expect(utcOffsetSeconds("UTC", new Date("2026-07-15T12:00:00Z"))).toBe(0);
+ });
+
+ it("handles half-hour offsets", () => {
+ expect(utcOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T12:00:00Z"))).toBe(5.5 * 3600);
+ });
+});
+
+describe("formatUtcOffset", () => {
+ it("formats negative, positive, and zero offsets", () => {
+ expect(formatUtcOffset(-6 * 3600)).toBe("UTC-06:00");
+ expect(formatUtcOffset(5.5 * 3600)).toBe("UTC+05:30");
+ expect(formatUtcOffset(0)).toBe("UTC+00:00");
+ });
+});
+
+describe("nextOffsetTransition", () => {
+ it("finds the exact spring-forward instant for Chicago", () => {
+ const transition = nextOffsetTransition("America/Chicago", new Date("2026-01-15T00:00:00Z"));
+ expect(transition).not.toBeNull();
+ // 2026-03-08 02:00 CST (UTC-6) -> 03:00 CDT (UTC-5): 08:00:00 UTC.
+ expect(transition!.at.toISOString()).toBe("2026-03-08T08:00:00.000Z");
+ expect(transition!.offsetBeforeSeconds).toBe(-6 * 3600);
+ expect(transition!.offsetAfterSeconds).toBe(-5 * 3600);
+ });
+
+ it("finds the exact fall-back instant for Chicago", () => {
+ const transition = nextOffsetTransition("America/Chicago", new Date("2026-07-15T00:00:00Z"));
+ expect(transition).not.toBeNull();
+ // 2026-11-01 02:00 CDT (UTC-5) -> 01:00 CST (UTC-6): 07:00:00 UTC.
+ expect(transition!.at.toISOString()).toBe("2026-11-01T07:00:00.000Z");
+ expect(transition!.offsetBeforeSeconds).toBe(-5 * 3600);
+ expect(transition!.offsetAfterSeconds).toBe(-6 * 3600);
+ });
+
+ it("returns null for fixed-offset zones", () => {
+ expect(nextOffsetTransition("UTC", new Date("2026-01-15T00:00:00Z"))).toBeNull();
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c68f719
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022"],
+ "types": ["node"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noFallthroughCasesInSwitch": true,
+ "forceConsistentCasingInFileNames": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "declaration": false
+ },
+ "include": ["src/**/*.ts"]
+}