diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index f849990..65be934 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,25 +2,35 @@ name: Run Unit Tests
on:
push:
- branches: [master]
pull_request:
branches: [master]
+permissions:
+ contents: read
+
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: macos-latest
strategy:
matrix:
- node-version: [18.x]
+ deno-version: [2.7]
steps:
- - uses: actions/checkout@v4
- - name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v3
+ - uses: actions/checkout@v6
+ - name: Use Deno ${{ matrix.deno-version }}
+ uses: denoland/setup-deno@v2
with:
- node-version: ${{ matrix.node-version }}
- - run: npm ci --ignore-scripts
- - run: npm run linter
- - run: npm test
- - run: npm run build
+ deno-version: ${{ matrix.deno-version }}
+ - name: Install Dependencies
+ run: deno install
+ - name: Format Check
+ run: deno task fmt:check
+ - name: Lint All
+ run: deno task lint:all
+ - name: Test All
+ run: deno task test:all
+ - name: Build App
+ run: deno task build:app
+ - name: Build Widgets
+ run: deno task build:widgets
diff --git a/.gitignore b/.gitignore
index 080486e..2519dc6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,7 +33,8 @@ bower_components
# Editors
.idea
*.iml
-.vscode
+*.env
+.vscode/launch.json
# OS metadata
.DS_Store
@@ -41,6 +42,7 @@ Thumbs.db
# Ignore built ts files
dist
+_dist
build
.build
.serverless
@@ -51,7 +53,19 @@ yarn.lock
# ignore local files
*.LOCAL.*
*.local.*
-.tago-lock.kickstarter.lock
+*.lock
.tagoio
-.tago-lock.tagoio init.lock
-tagoconfig
+.superpowers
+
+# vite
+.vite
+.tago-lock.dev.lock
+
+# E2E (Playwright)
+e2e/test-results/
+e2e/playwright-report/
+e2e/playwright/.auth/
+
+# Local tooling
+.claude/
+.tmp-test-screenshots/
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index ad58848..0000000
--- a/.npmrc
+++ /dev/null
@@ -1 +0,0 @@
-save-prefix=""
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..e98bbed
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,7 @@
+{
+ "recommendations": [
+ "editorconfig.editorconfig",
+ "denoland.vscode-deno",
+ "bradlc.vscode-tailwindcss"
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..7eb7fd8
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,27 @@
+{
+ // Deno LSP across the workspace (root deno.json lists all members)
+ "deno.enable": true,
+ "deno.lint": true,
+ "deno.codeLens.test": true,
+ "deno.codeLens.references": true,
+ // e2e/ is a Node project (Playwright + npm); let the built-in TS server handle it.
+ "deno.disablePaths": ["e2e"],
+
+ // deno fmt as the default formatter — stays in sync with CI's lint/fmt checks
+ "editor.formatOnSave": true,
+ "[typescript]": { "editor.defaultFormatter": "denoland.vscode-deno" },
+ "[typescriptreact]": { "editor.defaultFormatter": "denoland.vscode-deno" },
+ "[javascript]": { "editor.defaultFormatter": "denoland.vscode-deno" },
+ "[json]": { "editor.defaultFormatter": "denoland.vscode-deno" },
+ "[jsonc]": { "editor.defaultFormatter": "denoland.vscode-deno" },
+
+ // Tailwind v4 is CSS-first — point IntelliSense at each widget's styles.css.
+ // Add a new key per widget as you create them.
+ "tailwindCSS.experimental.configFile": {
+ "widgets/line-chart/styles.css": "widgets/line-chart/**"
+ },
+ "cSpell.words": [
+ "CHECKIN",
+ "sendgrid"
+ ]
+}
diff --git a/README.MD b/README.MD
index b24a227..4ac5447 100644
--- a/README.MD
+++ b/README.MD
@@ -1,159 +1,97 @@
-
-
-
-
-
-# Kickstarter Application for TagoIO
-This is a starter application to be used at TagoIO. It has main required features for an regular application to run, with a functionality to facilitate setting up new sensors within the application. You can use the scripts provided here to learn or to develop your own solution with it's foundations.
-
-# Components
-The application contains the following features:
-* Two levels: Organization and Groups.
-* Three access levels: Super Administrator, Organization Administrator and Guest User.
-* Organization plans for managing SMS, email and data retetion.
-* Setup alerts.
-* Generate scheduled reports.
-* Navigation between dashboards and Run Buttons enabled.
-
-### Easy Installation
-You can quickly install the application in your account by using the Import tool.
-* Firstly, make sure your RUN is activated. Visit this [link](https://admin.tago.io/run/), click on "Start now" and then save the change.
-* Go to your [profile settings](https://admin.tago.io/account/).
-* Generate an Token in your profile, make sure to set **expire time** to never.
-* Copy the Token.
-* Access the Import tool by [clicking here](https://import-application.tago.run/dashboards/info/63e698562df1360009606d71?anonymousToken=00000000-5bbc-b03b-667d-7a002e56664b).
-* Keep all entities selected, paste your token in the account token field.
-* Add you account token to a new secret named: ACCOUNT_TOKEN, it is needed to allow Analysis to work.
-* Press Import Application and you should receive notification in your account when it's done.
-* To access the application using TagoRUN. Make sure to create a user using your TagoIO Developer account, adding the tag key **access** with the tag value **admin**.
-
-### Easy Update
-To easy update your application when new version is out, you can just repeat the previous step.
-If you had made changes to the application, you can choose only the entities you want to update, like only analysis for example.
-
-### Step-by-Step installation
-You only want to follow this installation method, instead of the easy one, if you plan to have deep understading of how all TagoIO resources works and are used. I can't think of any other reason to go through this method.
-
-* Generate an account-token in your profile settings.
-* Create a Custom HTTPs device, to be used as a internal storage for the application. Make sure you have the tag key **device_type** with tag value **settings** on it.
-* Get Analysis and Dashboard templates from [Analysis documentation](https://github.com/tago-io/analysis-kickstarter/blob/master/src/analysis/README.md).
-* Create actions as described in the header documentation on each analysis file at [Analysis folder](https://github.com/tago-io/analysis-kickstarter/tree/master/src/analysis).
-* Create policies for your Run Users (work in progress)
-* Create the Run Buttons (work in progress)
-* Follow the **Code Setup and Installation** instructions below, to make sure scripts are up-to-date.
-* You're done!
-
-### Code Setup and Installation
-Using this repository, you will be able to change and update the analysis in your account. This step is not required unless you plan to make changes in the code.
-* Install [Node.JS](https://help.tago.io/portal/en/kb/articles/464-node-js).
-* Download the repository.
-* Open your terminal and access the folder of the repository.
-* Run `npm install`.
-* Generate a account-profile token at TagoIO **My Settings** -> **Your Profile** -> **Tokens**. Make sure to generate a token with `Expire at` set to never.
-* Open the `build.ts` and replace `Your-Account-Profile-Token` by a token of your profile.
-* Go back to your terminal and run the template with `npm run start build`.
-* It should take a few minutes for the script to build and import all the analysis and dashboards to your account.
-
-### Code Update
-* Make sure you have the `build.ts` with your account profile token.
-* Update the repository with last version.
-* Go back to your terminal and run the template with `npm run start build`.
-* It should take a few minutes for the script to build and import all the analysis and dashboards to your account.
-
-### How to use the application
-The documentation on how to use the application is avaiable to download in the following link [analysis-kickstarter/docs/Kickstarter-Guide](https://github.com/tago-io/analysis-kickstarter/blob/master/docs/Kickstarter%20-%20Guide.pdf).
-
-### How to learn from this code
-You will notice three folders in the `src/` folder.
-* **analysis**: contains each analysis that must be present in your account.
-* **lib**: useful list of functions commonly used between the scripts.
-* **services**: services folders that are used in the analysis. Each service is related
-
-For additional informations on how to setup TagoIO with this code, and all the TagoIO resources used, check the Kickstarter Guide documentation at [analysis-kickstarter/docs/Kickstarter-Guide](https://github.com/tago-io/analysis-kickstarter/blob/master/docs/Kickstarter%20-%20Guide.pdf).
-
-### User Permissions Diagram
-
-
-```mermaid
-graph LR
-subgraph TagoIO KickStarter User Permission Diagram V1.0.0
- subgraph Application
- subgraph Entities
- Organization(Organization)
- Groups(Groups)
- Sensors(Sensors)
- end
- subgraph Services
- Plan(Plan System)
- Alerts(Alerts)
- Reports(Reports)
- Indicators(System Indicators)
- IndoorView(Indoor & Outdoor Plan)
- end
- end
- style A fill:black,stroke:red,stroke-width:2px,color:#fff,stroke-dasharray: 5 5
- A((Administrator))
-
-
- A -- Can Invite --> O
- A -- Can Manage --> Organization
- A -- Can Manage --> Groups
- A -- Can Manage --> Sensors
- A -- Can Manage --> Plan
- A -- Can Manage --> Alerts
- A -- Can Manage --> Reports
- A -- Can Manage --> Indicators
- A -- Can Manage --> IndoorView
-
- linkStyle 0 stroke-width:2px,fill:none,stroke:red;
- linkStyle 1 stroke-width:2px,fill:none,stroke:red;
- linkStyle 2 stroke-width:2px,fill:none,stroke:red;
- linkStyle 3 stroke-width:2px,fill:none,stroke:red;
- linkStyle 4 stroke-width:2px,fill:none,stroke:red;
- linkStyle 5 stroke-width:2px,fill:none,stroke:red;
- linkStyle 6 stroke-width:2px,fill:none,stroke:red;
- linkStyle 7 stroke-width:2px,fill:none,stroke:red;
- linkStyle 8 stroke-width:2px,fill:none,stroke:red;
-
-
- style O fill:black,stroke-width:2px,color:#fff,stroke-dasharray: 5 5
- O((Organization Admin))
- O -- Can Invite --> G
- O -- Can Manage *1 --> Groups
- O -- Can Manage *1 --> Sensors
- O -- Can Manage *1 --> Alerts
- O -- Can Manage *1 --> Reports
- O -- Can Monitor *1 --> Indicators
- O -- Can Manage *1 --> IndoorView
-
- linkStyle 9 stroke-width:2px,fill:none,stroke:white;
- linkStyle 10 stroke-width:2px,fill:none,stroke:white;
- linkStyle 11 stroke-width:2px,fill:none,stroke:white;
- linkStyle 12 stroke-width:2px,fill:none,stroke:white;
- linkStyle 13 stroke-width:2px,fill:none,stroke:white;
- linkStyle 14 stroke-width:2px,fill:none,stroke:white;
- linkStyle 15 stroke-width:2px,fill:none,stroke:white;
-
- style G fill:black,stroke:green,stroke-width:2px,color:#fff,stroke-dasharray: 5 5
- G((Guest))
- G -- Can View Only *1 --> Groups
- G -- Can View Only *1 --> Sensors
- G -- Can View Only *1 --> IndoorView
- G -- Will Receive Only *1 --> Reports
- G -- Will Receive Only *1 --> Alerts
- G -- Can Monitor *1 --> Indicators
-
- linkStyle 16 stroke-width:2px,fill:none,stroke:green;
- linkStyle 17 stroke-width:2px,fill:none,stroke:green;
- linkStyle 18 stroke-width:2px,fill:none,stroke:green;
- linkStyle 19 stroke-width:2px,fill:none,stroke:green;
- linkStyle 20 stroke-width:2px,fill:none,stroke:green;
- linkStyle 21 stroke-width:2px,fill:none,stroke:green;
-
-test[1* Only those related to the User's Company]
-end
+# Kickstarter
+
+Multi-tenant IoT template that runs on TagoIO. It ships the three things a complete TagoIO application needs: **Analyses** (Deno serverless handlers), **custom Dashboard Widgets**
+(React iframes), and **dashboard helper docs** (the markdown rendered inside each dashboard's `Helper` tab in the application).
+
+The runtime is Deno end-to-end. Widgets add a Vite/React toolchain on top for the browser bundle. Everything is wired as a Deno workspace so analyses and widgets can share types
+without a publish step.
+
+## Layout
+
```
+analysis-kickstarter/
+├── app/ # Deno workspace — Analyses + bundle script
+├── widgets/ # Vite/React workspace — one subfolder per widget
+├── dashboard-helpers/ # Markdown files rendered inside each dashboard's Helper tab in the application
+└── deno.json # Root workspace + top-level tasks
+```
+
+Detailed docs live next to the code:
+
+- [`app/README.md`](./app/README.md) — analyses, triggers, AnalysisRouter, bundling
+- [`widgets/README.md`](./widgets/README.md) — widget stack, dev server, multi custom widget build
+
+## Application model
+
+Two complementary views of the same thing. The TagoIO side (devices and tags) is what the analyses manipulate; the navigation side is what a user sees in the browser.
+
+```
+TagoIO resources User navigation (TagoRUN)
+───────────────── ───────────────────────────
+Settings device (settings_dev) Welcome
+└── Organization device └── Organizations ──► Groups
+ (tag device_type=organization) ├── Map View └── Sensors (per group)
+ ├── Run users └── Create/Edit ├── Sensor List
+ │ (Super Admin │ Org Admin │ Guest) ├── Sensor Detail
+ ├── Alert rows (stored on the org device) │ (Temp gauge / Compressor / Door)
+ └── Group device └── All Devices (Admin)
+ (tag device_type=group) ──► Alerts (Sensor + Global)
+ └── Sensor device ──► Users (Org Admin / Guest)
+ (real device — tag device_type=device,
+ network, model, EUI)
+```
+
+The hierarchy is enforced through device tags: `organization_id` and `group_id` propagate down so TagoIO Access Policies isolate tenants automatically.
+
+## Dashboards
+
+Six dashboards, every one with a `Helper` tab that renders the matching file in [`dashboard-helpers/`](./dashboard-helpers).
+
+| Dashboard | Tabs | CRUD analysis (in [`app/analysis/dashboard/`](./app/analysis/dashboard/)) | Custom widget |
+| ------------- | --------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
+| Organizations | Overview (List + Map) · Helper | `crud-organization.ts` | — |
+| Groups | Cold Rooms · Overview · Helper | `crud-group.ts` | [`cold-room-card-data`](./widgets/cold-room-card-data) on the Cold Rooms tab |
+| Sensors | Overview · All Devices (Admin) · Helper | `crud-sensor.ts` | [`sensor-status`](./widgets/sensor-status) on Overview |
+| Sensor Detail | Overview · Helper | — | [`cold-room-monitor`](./widgets/cold-room-monitor) on Overview |
+| Alerts | Overview · Global Alerts · Helper | `crud-alert.ts` | — |
+| Users | Overview · All Users (Admin) · Helper | `crud-user.ts` | — |
+
+Each CRUD analysis is dispatched by the SDK's `AnalysisRouter` based on the widget that fired the call (input form, custom button, or device-list action). The full mapping is
+documented in [`app/README.md`](./app/README.md).
+
+Three more analyses run outside of any dashboard:
+
+- `actions/uplink-handler.ts` — Action-triggered. Turns each sensor uplink into the `cold_room_card_data` row that powers the Cold Rooms widget.
+- `actions/alert-dispatcher.ts` — Action-triggered. Called by the per-alert Action created by `crud-alert.ts` whenever a condition is met.
+- `scheduled/check-inactive-sensors.ts` — Scheduled (hourly). Flags sensors that haven't reported within the configured threshold and refreshes the connectivity summary shown on
+ the Sensors dashboard.
+
+## Top-level tasks
+
+Run from the repo root:
+
+| Task | What it does |
+| ------------------------- | --------------------------------------------------- |
+| `deno task build:app` | Bundle every analysis into `build/app/*.tagoio.js` |
+| `deno task build:widgets` | Build every widget into `widgets/_dist//` |
+| `deno task dev:widgets` | Start the widget dev server (needs `WIDGET=`) |
+| `deno task test:all` | Run app tests |
+| `deno task lint:all` | Lint app and widgets |
+| `deno task fmt` | Format everything (`deno fmt`) |
+
+Per-workspace task lists live in the folder READMEs.
+
+## Prerequisites
+
+- Deno (latest)
+- TagoIO CLI — `npm install -g @tago-io/cli`, then `tagoio login`
+
+## Conventions
+- `kebab-case` for files and folders.
+- Explicit block braces on every control structure — no single-line `if`/`for`/`while`.
+- Analyses are ephemeral: no in-memory state between runs — persist via device data or entity records.
+- Data queries always set `qty` explicitly; the SDK default (15) silently truncates.
+- Buckets are deprecated — use device storage directly.
-### Support
-You can open an issue or question at [https://github.com/tago-io/analysis-kickstarter/issues](https://github.com/tago-io/analysis-kickstarter/issues).
+Full guidance lives in [`CLAUDE.md`](./CLAUDE.md).
diff --git a/app/README.md b/app/README.md
new file mode 100644
index 0000000..bff6fa0
--- /dev/null
+++ b/app/README.md
@@ -0,0 +1,115 @@
+# `app/` — Analyses
+
+Deno workspace (`@app/application`) containing every TagoIO Analysis the Kickstarter ships. Each analysis is a single, self-contained TypeScript file under `analysis/`. There is no
+build step: to deploy, you **copy the file's contents and paste it into the matching Analysis on the TagoIO admin panel**.
+
+## Layout
+
+```
+app/
+├── analysis/
+│ ├── dashboard/ # Dashboard widget interactions (input forms, buttons, list actions)
+│ │ ├── crud-organization.ts
+│ │ ├── crud-group.ts
+│ │ ├── crud-sensor.ts
+│ │ ├── crud-alert.ts
+│ │ └── crud-user.ts
+│ ├── actions/ # Triggered by TagoIO Actions on data events
+│ │ ├── uplink-handler.ts
+│ │ └── alert-dispatcher.ts
+│ └── scheduled/ # Triggered by a Scheduled Action (cron)
+│ └── check-inactive-sensors.ts
+└── deno.json
+```
+
+## The self-contained file convention
+
+Every analysis under `analysis/` is a single TypeScript file with **no relative imports**. Constants, Zod schemas, helpers and handlers all live in the same file, grouped by
+section headers. The goal is to let a developer new to TagoIO read one analysis top-to-bottom and understand every step it performs — and to make it trivial to copy/paste the whole
+file into a TagoIO Analysis.
+
+Practical consequences:
+
+- Cross-analysis code duplication is intentional. Do not factor shared helpers into a `lib/` folder — keep each file self-explanatory.
+- The only imports allowed are `npm:@tago-io/sdk`, `npm:zod`, `npm:luxon`, `npm:phone`, and `npm:json-2-csv`.
+- `*.test.ts` files sit next to the analysis they cover and run with `deno task test`.
+
+## Handlers
+
+| File | Trigger type | What it does |
+| ------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------- |
+| `dashboard/crud-organization.ts` | Dashboard widget callbacks | Create / edit / delete Organization devices (`device_type=organization`). |
+| `dashboard/crud-group.ts` | Dashboard widget callbacks | Create / edit / delete Group devices (`device_type=group`) inside an Organization. |
+| `dashboard/crud-sensor.ts` | Dashboard widget callbacks | Create / edit / delete Sensor devices (`device_type=device`) inside a Group. |
+| `dashboard/crud-alert.ts` | Dashboard widget callbacks | Persist alert rules on the Organization device, provision per-alert Actions. |
+| `dashboard/crud-user.ts` | Dashboard widget callbacks | Invite / edit / delete Run Users (Org Admin or Guest). Uses SendGrid for the invite mail. |
+| `actions/uplink-handler.ts` | Action — Trigger by Variable | Writes `cold_room_card_data` rows on the Group and Organization devices on every uplink. |
+| `actions/alert-dispatcher.ts` | Action — created by `crud-alert` | Renders the alert message and sends an in-app notification to each recipient. |
+| `scheduled/check-inactive-sensors.ts` | Scheduled Action (hourly cron) | Marks sensors that missed the configured threshold as offline, refreshes the summary. |
+
+## Dashboard analyses and `AnalysisRouter`
+
+The five `crud-*.ts` analyses are called by TagoIO widgets on a dashboard. The SDK's `AnalysisRouter` (`@tago-io/sdk` → `Utils`) inspects the `scope` of the incoming data and
+dispatches to the right handler based on the widget identifier:
+
+| Widget type | Router method | Identifier convention |
+| ---------------- | -------------------------- | --------------------- |
+| Input Form | `whenInputFormID` | `create-` |
+| Custom Button | `whenCustomBtnID` | `edit-` |
+| Device/User List | `whenDeviceListIdentifier` | `delete-` |
+
+For example, `crud-organization.ts` routes to `createOrganization`, `editOrganization`, and `deleteOrganization` based on whether the dashboard fired the `create-org` form, the
+`edit-org` button, or the `delete-org` list action. The same shape repeats for groups, sensors, alerts and users.
+
+Every dashboard analysis reads `context.environment` through `Utils.envToJson` and validates the required env vars early (typically `config_id`, plus `T_ANALYSIS_TOKEN` which the
+runtime injects). `crud-user.ts` additionally requires `SENDGRID_API_KEY` and `sendgrid_from_email`; `crud-alert.ts` additionally requires `alert_dispatcher_id`.
+
+## Action and scheduled analyses
+
+- `uplink-handler.ts` is wired to a TagoIO Action of type _Trigger by Variable_ that targets sensor devices (tag `device_type=device`) and listens to `temperature`, `compressor`
+ and `door`. On each match the analysis writes one `cold_room_card_data` record on both the parent Group device and the parent Organization device — that record powers the Cold
+ Rooms widget.
+- `alert-dispatcher.ts` is wired by `crud-alert.ts` at alert creation time, **one Action per alert**. The Action condition encodes the rule the user picked on the dashboard; when
+ it fires, the analysis loads the persisted alert row, replaces placeholders (`#device_name#`, `#device_id#`, `#sensor_type#`, `#value#`, `#variable#`) and sends an in-app
+ notification to every recipient.
+- `check-inactive-sensors.ts` runs hourly. It compares each sensor's last uplink against the per-org inactivity rules (or the global fallback stored on the settings device) and
+ writes the `last_uplink` parameter and the `device_connectivity_summary` row consumed by the Sensors dashboard.
+
+## Deploy
+
+To get an analysis running on TagoIO:
+
+1. Open the matching Analysis on the TagoIO admin panel (or create one with the same name).
+2. Copy the contents of the `.ts` file from `analysis/` and paste it into the Analysis code editor.
+3. Configure the Analysis environment variables expected by the handler (see each file's docstring; `T_ANALYSIS_TOKEN` is injected by TagoIO).
+4. Save.
+
+There is no automated deploy and nothing to build locally — what you read in `analysis/` is what runs on TagoIO.
+
+## Tasks
+
+Run from `app/` (or as `deno task :app` from the repo root):
+
+| Task | What it does |
+| ----------------------- | ------------------------------------------------- |
+| `deno task test` | Run every `*.test.ts` in the workspace |
+| `deno task test:single` | `deno test --allow-all` — pass a path to scope it |
+| `deno task linter` | Lint (skips tests, allows `any`) |
+| `deno task linter-fix` | Lint with `--fix` |
+
+## Dependencies
+
+Declared in `app/deno.json`:
+
+- `@tago-io/sdk` (inherited from root) — Analysis runtime primitives, `Resources`, `Services`, `Utils.AnalysisRouter`.
+- `zod` — Schema validation for every form payload.
+- `luxon` — Date/time math used across handlers.
+- `phone` — Phone number normalization in `crud-user.ts`.
+- `json-2-csv` — CSV import/export (when needed by a handler).
+
+## Rules that bite
+
+- **No in-memory state between runs** — analyses are ephemeral (Lambda-style). Persist via device data or entity records.
+- **Always set `qty` on data queries** — the default of 15 silently truncates.
+- **Buckets are deprecated** — use device storage directly. Flag any legacy bucket reference for migration.
+- **Explicit block braces** on every `if` / `for` / `while`, even single-line.
diff --git a/app/analysis/actions/alert-dispatcher.ts b/app/analysis/actions/alert-dispatcher.ts
new file mode 100644
index 0000000..19d739d
--- /dev/null
+++ b/app/analysis/actions/alert-dispatcher.ts
@@ -0,0 +1,335 @@
+/**
+ * Alert Dispatcher Analysis
+ *
+ * Educational single-file Analysis that runs every time a sensor variable
+ * crosses the threshold of a user-configured alert. This file is
+ * intentionally self-contained — it has no relative imports.
+ *
+ * What is an "alert dispatch"?
+ * ---------------------------
+ * The Alerts dashboard lets a user describe a condition ("temperature > 80°F",
+ * "door = open", ...). Behind the scenes, the `crud-alert` Analysis stores
+ * that rule on the organization device AND provisions a TagoIO Action that
+ * evaluates the condition on every uplink. When the condition is met, the
+ * Action calls this Analysis — one "dispatch" — so we can resolve the
+ * recipients, render the message, and notify everyone in-app.
+ *
+ * What it does
+ * ------------
+ * 1. Reads the originating Action by id (`environment._action_id`) to find
+ * the `alert_id` and `organization_id` tags.
+ * 2. Reads the persisted alert row from the organization device — the
+ * same row the Alerts table widget renders.
+ * 3. Replaces the placeholder keywords (`#device_name#`, `#device_id#`,
+ * `#sensor_type#`, `#value#`, `#variable#`) with the values from the
+ * uplink that fired this alert.
+ * 4. Sends an in-app notification to every recipient listed on the row.
+ *
+ * Required environment variables
+ * ------------------------------
+ * - T_ANALYSIS_TOKEN : provided automatically by the TagoIO runtime.
+ * - _action_id : provided automatically when the Action calls
+ * this Analysis.
+ *
+ * NOTE
+ * ----
+ * This file is optimized for clarity, not performance. The goal is for a
+ * developer new to TagoIO to read it top-to-bottom and understand every step.
+ */
+
+import { Analysis, type Data, Resources, type TagoContext, Utils } from "npm:@tago-io/sdk";
+import z from "npm:zod";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * Tag keys carried by every alert Action. They tell us which row on the
+ * organization device drives this dispatch.
+ */
+const ACTION_TAG_ALERT_ID = "alert_id";
+const ACTION_TAG_ORG_ID = "organization_id";
+
+/** Variables that make up one alert row on the organization device. */
+const VAR_SEND_TO = "alert_management_users";
+const VAR_MESSAGE = "alert_management_message";
+const VAR_MODEL = "alert_management_type";
+
+/** Sensor tag key used to populate the `#sensor_type#` placeholder. */
+const TAG_SENSOR_TYPE = "sensor_type";
+
+/** Title used on every in-app notification dispatched by this Analysis. */
+const NOTIFICATION_TITLE = "Alert triggered";
+
+// ============================================================================
+// Validation schema
+// ============================================================================
+
+/**
+ * Shape of one data point inside the uplink batch we receive in `scope`.
+ *
+ * The Action only fires when the condition matches, but we still validate
+ * to fail fast (and log a clear reason) if the runtime ever calls us with
+ * an unexpected payload — that's safer than reading `undefined.variable`
+ * deep inside the dispatch.
+ */
+const triggerScopeModel = z.object({
+ device: z.string().min(1, { error: "trigger is missing device id" }),
+ variable: z.string().default(""),
+ value: z.union([z.string(), z.number(), z.boolean()]).optional(),
+});
+
+// ============================================================================
+// Types
+// ============================================================================
+
+/** Everything we need to know about the alert that fired. */
+interface AlertContext {
+ alertID: string;
+ organizationID: string;
+ recipientIDs: string[];
+ messageTemplate: string;
+ triggerVariable: "temperature" | "door" | "inactivity" | string;
+}
+
+/** Everything we need to know about the uplink that fired the alert. */
+interface TriggerContext {
+ sensorID: string;
+ sensorName: string;
+ sensorType: string;
+ variable: string;
+ value: string;
+}
+
+/** Values substituted into the message template. */
+interface RenderContext {
+ deviceName: string;
+ deviceID: string;
+ sensorType: string;
+ value: string;
+ variable: string;
+}
+
+// ============================================================================
+// Helpers — generic
+// ============================================================================
+
+/**
+ * Splits a TagoIO comma-joined recipient list (e.g. `"id1, id2"`) into a
+ * clean array of trimmed non-empty strings.
+ */
+function splitCsv(value: unknown): string[] {
+ if (typeof value !== "string") {
+ return [];
+ }
+ return value
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+}
+
+// ============================================================================
+// Helpers — alert context lookup
+// ============================================================================
+
+/**
+ * Loads the Action by id, reads its tags to know which alert fired, and
+ * pulls the recipients + message template from the organization device.
+ *
+ * Returns undefined when any required piece is missing. The caller logs the
+ * reason and exits cleanly — a misconfigured Action should not crash the
+ * Analysis for other alerts that ARE configured correctly.
+ */
+async function loadAlertContext(actionID: string): Promise {
+ const action = await Resources.actions.info(actionID).catch(() => undefined);
+ if (!action) {
+ console.log(`Action ${actionID} not found; skipping`);
+ return;
+ }
+
+ const alertID = action.tags?.find((tag) => tag.key === ACTION_TAG_ALERT_ID)?.value;
+ const organizationID = action.tags?.find((tag) => tag.key === ACTION_TAG_ORG_ID)?.value;
+ if (!alertID || !organizationID) {
+ console.log(`Action ${actionID} is missing ${ACTION_TAG_ALERT_ID} or ${ACTION_TAG_ORG_ID} tag; skipping`);
+ return;
+ }
+
+ // Recipients and message live on the organization device as two variables
+ // sharing the same `group` (the alert id).
+ const rows = await Resources.devices
+ .getDeviceData(organizationID, { variables: [VAR_SEND_TO, VAR_MESSAGE, VAR_MODEL], groups: alertID, qty: 1 });
+
+ const recipientsRecord = rows.find((row) => row.variable === VAR_SEND_TO)?.value;
+ const messageRecord = rows.find((row) => row.variable === VAR_MESSAGE)?.value;
+ const modelRecord = rows.find((row) => row.variable === VAR_MODEL)?.value;
+
+ const recipientIDs = splitCsv(recipientsRecord);
+ if (recipientIDs.length === 0) {
+ console.log(`Alert ${alertID} has no recipients; skipping`);
+ return;
+ }
+
+ const alertContext: AlertContext = {
+ alertID,
+ organizationID,
+ recipientIDs,
+ messageTemplate: String(messageRecord ?? ""),
+ triggerVariable: String(modelRecord ?? ""),
+ };
+
+ return alertContext;
+}
+
+// ============================================================================
+// Helpers — trigger context lookup
+// ============================================================================
+
+/**
+ * Picks the data point that matches the alert's monitored variable out of
+ * the uplink batch, then resolves the sensor's friendly name + type tag.
+ *
+ * The uplink batch (`scope`) carries every data point the sensor sent in
+ * one push (e.g. `door`, `compressor`, `temperature`). We must filter to
+ * the variable the alert actually watches — otherwise the `#value#` and
+ * `#variable#` placeholders would render whatever happened to come first
+ * in the array.
+ *
+ * The device lookup is tolerated to fail (the alert should still send even
+ * if we only know the id). When that happens, the placeholders fall back
+ * to the raw id, which keeps the notification useful.
+ */
+async function buildTriggerContext(scope: Data[], triggerVariable: string): Promise {
+ const match = scope.find((item) => item.variable === triggerVariable);
+ if (!match) {
+ console.log(`Scope does not contain variable "${triggerVariable}"; skipping`);
+ return;
+ }
+
+ const parsed = await triggerScopeModel.safeParseAsync(match);
+ if (!parsed.success) {
+ // `safeParseAsync` returns the issues instead of throwing, so we log
+ // the first one and skip — calls without a real trigger are not
+ // something the user can fix.
+ console.log(`Skipping dispatch: ${parsed.error.issues[0]?.message ?? "trigger validation failed"}`);
+ return;
+ }
+
+ const { device: sensorID, variable, value } = parsed.data;
+
+ const sensorInfo = await Resources.devices.info(sensorID).catch(() => undefined);
+ const sensorName = sensorInfo?.name ?? sensorID;
+ const sensorType = sensorInfo?.tags.find((tag) => tag.key === TAG_SENSOR_TYPE)?.value ?? "";
+
+ return {
+ sensorID,
+ sensorName,
+ sensorType,
+ variable,
+ value: value === undefined ? "" : String(value),
+ };
+}
+
+// ============================================================================
+// Helpers — message rendering
+// ============================================================================
+
+/**
+ * Replaces every supported `#keyword#` in the message with the matching
+ * value from the uplink that fired the alert. Unknown keywords are left
+ * untouched so the user gets a visible hint if they typed something wrong.
+ */
+function renderMessage(template: string, context: RenderContext): string {
+ return template
+ .replaceAll("#device_name#", context.deviceName)
+ .replaceAll("#device_id#", context.deviceID)
+ .replaceAll("#sensor_type#", context.sensorType)
+ .replaceAll("#value#", context.value)
+ .replaceAll("#variable#", context.variable);
+}
+
+// ============================================================================
+// Helpers — notification dispatch
+// ============================================================================
+
+/**
+ * Sends the rendered message to every recipient as an in-app notification.
+ *
+ * We dispatch serially (not in parallel) because TagoIO applies per-minute
+ * rate limits on the notification endpoint, and a tight `Promise.all` is
+ * the easiest way to trip them on a fan-out.
+ */
+async function dispatchNotifications(recipientIDs: string[], message: string): Promise {
+ for (const userID of recipientIDs) {
+ await Resources.run
+ .notificationCreate(userID, { title: NOTIFICATION_TITLE, message })
+ .catch((error) => {
+ console.error(`Failed to notify ${userID}: ${(error as Error).message ?? error}`);
+ });
+ }
+}
+
+// ============================================================================
+// Entrypoint
+// ============================================================================
+
+/**
+ * Entrypoint invoked by the TagoIO Analysis runtime.
+ *
+ * Steps:
+ * 1. Read the Action id from the environment. Without it we have no way
+ * to know which alert fired.
+ * 2. Load the alert context: the recipients and message template stored
+ * on the organization device. Skip when missing.
+ * 3. Build the trigger context: the sensor name, type, variable, and
+ * value from the uplink that crossed the threshold. Skip when invalid.
+ * 4. Render the message with the placeholder values and dispatch one
+ * in-app notification per recipient.
+ */
+async function startAnalysis(context: TagoContext, scope: Data[]) {
+ console.log("Running Alert Dispatcher");
+
+ const environment = Utils.envToJson(context.environment);
+
+ // Step 1 — identify which Action invoked us.
+ const actionID = environment._action_id;
+ if (!actionID) {
+ console.log("No _action_id in environment; skipping");
+ return;
+ }
+ console.log(`Alert dispatch received: action=${actionID} scope_size=${scope?.length ?? 0}`);
+
+ // Step 2 — load the alert row (recipients + message) for this Action.
+ const alert = await loadAlertContext(actionID);
+ if (!alert) {
+ return;
+ }
+
+ // Step 3 — read the uplink data point that crossed the threshold.
+ const trigger = await buildTriggerContext(scope, alert.triggerVariable);
+ if (!trigger) {
+ return;
+ }
+
+ // Step 4 — render the message and notify each recipient.
+ const renderedMessage = renderMessage(alert.messageTemplate, {
+ deviceName: trigger.sensorName,
+ deviceID: trigger.sensorID,
+ sensorType: trigger.sensorType,
+ value: trigger.value,
+ variable: trigger.variable,
+ });
+
+ await dispatchNotifications(alert.recipientIDs, renderedMessage);
+
+ console.log(`Alert ${alert.alertID} dispatched to ${alert.recipientIDs.length} recipient(s)`);
+}
+
+// The Analysis runtime sets `T_TEST` during local tests so the handler is
+// not wired up automatically. In production the runtime sets
+// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below.
+if (!Deno.env.get("T_TEST")) {
+ Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") });
+}
+
+export { startAnalysis };
diff --git a/app/analysis/actions/uplink-handler.ts b/app/analysis/actions/uplink-handler.ts
new file mode 100644
index 0000000..8e1e47e
--- /dev/null
+++ b/app/analysis/actions/uplink-handler.ts
@@ -0,0 +1,355 @@
+/**
+ * Uplink Handler Analysis
+ *
+ * Educational single-file Analysis that turns raw sensor uplinks into the
+ * `cold_room_card_data` record consumed by the Cold Room dashboards.
+ * This file is intentionally self-contained — it has no relative imports.
+ *
+ * What is an "uplink"?
+ * --------------------
+ * An "uplink" is one batch of data points a sensor pushes into TagoIO
+ * (temperature, status, etc.). The platform stores it on the sensor device
+ * and, if an Action is configured, also calls this Analysis with the same
+ * data points in `scope`.
+ *
+ * How it is triggered
+ * -------------------
+ * By a TagoIO Action of type "Trigger by Variable" that:
+ * - Targets devices tagged `device_type=device` (the sensors).
+ * - Listens to the variables `temperature`, `compressor`, `door` and others.
+ * - Runs this Analysis when any of those variables is written.
+ *
+ * What it writes
+ * --------------
+ * For each uplink, the handler creates (or updates) one row of the variable
+ * `cold_room_card_data` on TWO devices:
+ * - The parent group device, so the group-level Cold Room dashboard sees it.
+ * - The parent organization device, so the org-level Cold Room dashboard
+ * sees it too.
+ * Each sensor owns one row on each device — the row is identified by the
+ * data point's `group` field set to the sensor id.
+ *
+ * Required environment variables
+ * ------------------------------
+ * - T_ANALYSIS_TOKEN : provided automatically by the TagoIO runtime.
+ *
+ * NOTE
+ * ----
+ * This file is optimized for clarity, not performance. The goal is for a
+ * developer new to TagoIO to read it top-to-bottom and understand every step.
+ */
+
+import { Analysis, type Data, Resources, type TagoContext, Utils } from "npm:@tago-io/sdk";
+import z from "npm:zod";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * Name of the variable written on the group and organization devices. The
+ * Cold Room dashboards read this variable to render one card per sensor.
+ */
+const CARD_VARIABLE = "cold_room_card_data";
+
+/**
+ * Tag keys read from the sensor device. They tell us where the card record
+ * should be written (group + organization).
+ */
+const TAG_DEVICE_TYPE = "device_type";
+const TAG_GROUP_ID = "group_id";
+const TAG_ORGANIZATION_ID = "organization_id";
+
+/**
+ * Tag value that marks a device as a sensor. Other device types (group,
+ * organization, config) are skipped so this Analysis only reacts to real
+ * sensor uplinks.
+ */
+const SENSOR_DEVICE_TYPE = "device";
+
+/**
+ * Names of the variables we expect inside the uplink. These are the
+ * identifiers the decoder writes when it parses the sensor payload.
+ */
+const VAR_TEMPERATURE = "temperature";
+const VAR_COMPRESSOR = "compressor";
+const VAR_DOOR = "door";
+
+// ============================================================================
+// Validation schema
+// ============================================================================
+
+/**
+ * Accepts a number or a numeric string and coerces it to a finite number.
+ *
+ * Decoders sometimes emit the temperature as a string (e.g. `"7.0"`) and
+ * sometimes as a number (e.g. `7`). Normalising at this boundary keeps the
+ * rest of the code simple — every caller can assume a real number.
+ */
+const numericLike = z.union([z.number(), z.string()]).transform((value, ctx) => {
+ // Convert to number if it's a string; leave it alone if it's already a number.
+ const parsed = typeof value === "number" ? value : Number(value);
+
+ // Number(NaN), Infinity, and -Infinity are all invalid for our use case.
+ if (!Number.isFinite(parsed)) {
+ ctx.addIssue({ code: "custom", message: "not a finite number" });
+ return z.NEVER;
+ }
+
+ return parsed;
+});
+
+/**
+ * Shape of the cold-room card payload extracted from one uplink.
+ *
+ * Status fields are normalised (trim + lowercase) BEFORE checking against
+ * the enum, so common decoder variants like "ON" or " Open " are accepted.
+ * Without `.pipe`, `"ON"` would be rejected because the enum expects `"on"`.
+ */
+const cardPayloadModel = z.object({
+ temperature_fahrenheit: numericLike,
+ compressor_status: z.string().trim().toLowerCase().pipe(z.enum(["on", "off"], { error: "compressor must be on or off" })),
+ door_status: z.string().trim().toLowerCase().pipe(z.enum(["open", "closed"], { error: "door must be open or closed" })),
+});
+
+// ============================================================================
+// Types
+// ============================================================================
+
+/** Routing context extracted from the sensor's tags. */
+interface SensorContext {
+ sensorID: string;
+ sensorName: string;
+ groupID: string;
+ groupName: string;
+ organizationID: string;
+}
+
+/**
+ * Shape of the metadata stored on the `cold_room_card_data` record.
+ *
+ * Every field here ends up readable by the dashboard widget. Adding a field
+ * is how we expose new information to the UI without changing the widget's
+ * data source — for example, `group_id` and `group_name` let the Cold Room
+ * (Organization) widget cluster cards by their parent group.
+ */
+interface CardMetadata {
+ sensor_name: string;
+ group_name: string;
+ temperature_fahrenheit: number;
+ compressor_status: "on" | "off";
+ door_status: "open" | "closed";
+}
+
+/** Parameters required to create or update a card record. */
+interface UpsertCardParam {
+ readonly targetDeviceID: string;
+ readonly sensorID: string;
+ readonly metadata: CardMetadata;
+ readonly time: Date;
+}
+
+// ============================================================================
+// Helpers — sensor routing
+// ============================================================================
+
+/**
+ * Loads the sensor device and pulls out the tags we need to route the card.
+ *
+ * If the device is not a sensor, or is missing one of the required tags,
+ * we treat it as a configuration issue (not a runtime bug) and return null.
+ * The caller logs the reason and exits cleanly so the Action keeps working
+ * for the other sensors that ARE configured correctly.
+ */
+async function loadSensorContext(sensorID: string): Promise {
+ const sensorInfo = await Resources.devices.info(sensorID);
+
+ // Skip anything that is not tagged as a sensor (group/organization/config
+ // devices share the same Run so they could in theory trigger the Action).
+ const deviceType = sensorInfo.tags.find((tag) => tag.key === TAG_DEVICE_TYPE)?.value;
+ if (deviceType !== SENSOR_DEVICE_TYPE) {
+ console.log(`Device ${sensorID} is not a sensor (${TAG_DEVICE_TYPE}=${deviceType ?? "missing"}); skipping`);
+ return null;
+ }
+
+ const groupID = sensorInfo.tags.find((tag) => tag.key === TAG_GROUP_ID)?.value;
+ if (!groupID) {
+ console.log(`Sensor ${sensorID} has no ${TAG_GROUP_ID} tag; skipping`);
+ return null;
+ }
+
+ const organizationID = sensorInfo.tags.find((tag) => tag.key === TAG_ORGANIZATION_ID)?.value;
+ if (!organizationID) {
+ console.log(`Sensor ${sensorID} has no ${TAG_ORGANIZATION_ID} tag; skipping`);
+ return null;
+ }
+
+ const groupInfo = await Resources.devices.info(groupID);
+
+ return {
+ sensorID,
+ sensorName: sensorInfo.name,
+ groupID,
+ groupName: groupInfo.name,
+ organizationID,
+ };
+}
+
+// ============================================================================
+// Helpers — payload parsing
+// ============================================================================
+
+/**
+ * Reads the three expected variables from the uplink scope and runs them
+ * through the Zod schema.
+ *
+ * All three values must arrive in the same uplink. If any is missing or
+ * fails validation, the function returns null and the upsert is skipped:
+ * a partial card would publish stale state for the missing fields.
+ */
+async function buildCardMetadata(scope: Data[], sensor: SensorContext): Promise {
+ const temperatureValue = scope.find((item) => item.variable === VAR_TEMPERATURE)?.value;
+ const compressorValue = scope.find((item) => item.variable === VAR_COMPRESSOR)?.value;
+ const doorValue = scope.find((item) => item.variable === VAR_DOOR)?.value;
+
+ const parsed = await cardPayloadModel.safeParseAsync({
+ temperature_fahrenheit: temperatureValue,
+ compressor_status: compressorValue,
+ door_status: doorValue,
+ });
+
+ if (!parsed.success) {
+ // `safeParseAsync` returns the issues instead of throwing, so we just
+ // log the first one and skip — uplinks are not user-driven and we don't
+ // want to crash the whole Action because of one malformed message.
+ console.log(`Skipping card upsert: ${parsed.error.issues[0]?.message ?? "validation failed"}`);
+ return null;
+ }
+
+ return { sensor_name: sensor.sensorName, group_name: sensor.groupName, ...parsed.data };
+}
+
+/**
+ * Resolves the timestamp the card record should carry.
+ *
+ * We prefer the uplink's own timestamp so the widget's "x minutes ago"
+ * label reflects when the sensor produced the reading, not when this
+ * Analysis ran. If the scope entry has no time we fall back to `now`.
+ */
+function resolveCardTime(scope: Data[]): Date {
+ const rawTime = scope[0]?.time;
+ if (!rawTime) {
+ return new Date();
+ }
+ return new Date(rawTime);
+}
+
+// ============================================================================
+// Helpers — card upsert
+// ============================================================================
+
+/**
+ * Creates or updates ONE cold-room card row on the target device.
+ *
+ * Each sensor owns exactly one row on each parent device. We discriminate
+ * the rows by setting `group = sensorID` on the data point — TagoIO uses
+ * the `group` field as a sub-record key, so editing the row with the same
+ * `group` value updates that specific row instead of creating duplicates.
+ *
+ * On edit we set `time` explicitly. Spreading `existing` keeps the original
+ * creation timestamp, which would prevent the widget's relative-time label
+ * from ever advancing.
+ */
+async function upsertSensorCard({ targetDeviceID, sensorID, metadata, time }: UpsertCardParam): Promise<"created" | "updated"> {
+ const [existing] = await Resources.devices.getDeviceData(targetDeviceID, {
+ variables: CARD_VARIABLE,
+ groups: sensorID,
+ qty: 1,
+ });
+
+ if (existing) {
+ await Resources.devices.editDeviceData(targetDeviceID, {
+ ...existing,
+ value: sensorID,
+ group: sensorID,
+ time,
+ metadata: { ...existing.metadata, ...metadata },
+ });
+ return "updated";
+ }
+
+ await Resources.devices.sendDeviceData(targetDeviceID, {
+ variable: CARD_VARIABLE,
+ value: sensorID,
+ group: sensorID,
+ time,
+ metadata,
+ });
+ return "created";
+}
+
+// ============================================================================
+// Entrypoint
+// ============================================================================
+
+/**
+ * Entrypoint invoked by the TagoIO Analysis runtime.
+ *
+ * Steps:
+ * 1. Read the sensor id from the scope. Every uplink carries the device
+ * that produced it in `scope[0].device`.
+ * 2. Load the sensor's routing context (group + organization). Skip when
+ * the device is not a sensor or has no routing tags.
+ * 3. Build and validate the card metadata from the uplink. Skip when the
+ * payload is partial or invalid.
+ * 4. Fan-out: write the card on both the group device and the
+ * organization device so dashboards at either level can render it.
+ */
+async function startAnalysis(context: TagoContext, scope: Data[]) {
+ console.log("Running Uplink Handler");
+
+ // `environment` is read so future versions can react to env flags
+ // (e.g. a "dry run" mode). It is not used today.
+ Utils.envToJson(context.environment);
+
+ if (!scope || scope.length === 0) {
+ console.log("No data in scope, skipping");
+ return;
+ }
+
+ // Step 1 — identify which sensor produced this uplink.
+ const sensorID = scope[0].device;
+ console.log(`Uplink received: sensor=${sensorID} variables=[${scope.map((item) => item.variable).join(",")}]`);
+
+ // Step 2 — load the sensor and check that it is routable.
+ const sensor = await loadSensorContext(sensorID);
+ if (!sensor) {
+ return;
+ }
+
+ // Step 3 — extract the card payload from the uplink.
+ const metadata = await buildCardMetadata(scope, sensor);
+ if (!metadata) {
+ return;
+ }
+
+ // Step 4 — fan-out: write the same card on the group AND the organization
+ // device. Two writes are intentional: the dashboards at each level pull
+ // data from the device that sits one hop above the sensor.
+ const time = resolveCardTime(scope);
+
+ const groupResult = await upsertSensorCard({ targetDeviceID: sensor.groupID, sensorID, metadata, time });
+ console.log(`Card on group ${sensor.groupID}: ${groupResult}`);
+
+ const orgResult = await upsertSensorCard({ targetDeviceID: sensor.organizationID, sensorID, metadata, time });
+ console.log(`Card on organization ${sensor.organizationID}: ${orgResult}`);
+}
+
+// The Analysis runtime sets `T_TEST` during local tests so the handler is
+// not wired up automatically. In production the runtime sets
+// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below.
+if (!Deno.env.get("T_TEST")) {
+ Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") });
+}
+
+export { startAnalysis };
diff --git a/app/analysis/dashboard/crud-alert.ts b/app/analysis/dashboard/crud-alert.ts
new file mode 100644
index 0000000..dc54c4d
--- /dev/null
+++ b/app/analysis/dashboard/crud-alert.ts
@@ -0,0 +1,837 @@
+/**
+ * CRUD Alert Analysis
+ *
+ * Educational single-file Analysis that handles the full lifecycle of an
+ * Alert in the TagoIO Kickstarter project: create and delete. This
+ * file is intentionally self-contained — it has no relative imports.
+ *
+ * What an "alert" is in this project
+ * ----------------------------------
+ * An alert is a rule the user sets up on the Alerts dashboard: "notify
+ * these run users when these sensors report this condition". Internally
+ * each alert is stored as a set of data rows on the organization device
+ * (so the Alerts table widget can render it) PLUS — for temperature,
+ * door and compressor models — a TagoIO Action that listens to the
+ * sensor variable and runs the dispatcher Analysis when the condition is
+ * met. For the "inactivity" model (no uplink for X hours) no Action is
+ * created: the scheduled Inactivity Check Analysis picks the row up.
+ *
+ * How it is triggered
+ * -------------------
+ * A dashboard sends Data points to this Analysis. The Analysis Router
+ * from `@tago-io/sdk` inspects the scope and runs the matching handler:
+ *
+ * - Input Form "create-alert" -> createAlert
+ * - Custom Button "edit-alert" -> editAlert
+ * - Device List action "delete-alert" -> deleteAlert
+ *
+ * Required environment variables
+ * ------------------------------
+ * - config_id : ID of the configuration device that stores the
+ * validation messages displayed back to the UI.
+ * - alert_dispatcher_id : ID of the `alert-dispatcher` Analysis. The
+ * Actions created here call it on every match.
+ * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime.
+ *
+ * NOTE
+ * ----
+ * This file is optimized for clarity, not performance. The goal is for a
+ * developer new to TagoIO to read it top-to-bottom and understand every step.
+ */
+
+import { Analysis, type Conditionals, type Data, type DeviceListScope, type TagsObj } from "npm:@tago-io/sdk";
+import { Resources, type RouterConstructor, type TagoContext, Utils } from "npm:@tago-io/sdk";
+import { DateTime } from "npm:luxon";
+import z, { ZodError } from "npm:zod";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * Variables written on the organization device, one row per column on the
+ * Alert List widget table. Every variable for the same alert shares the
+ * same `group` (the alert id), so editing/deleting can target the whole
+ * row through that group filter.
+ */
+const VAR_DEVICES = "alert_management_devices"; // Sensor(s) column
+const VAR_MODEL = "alert_management_type"; // Model column
+const VAR_CONDITION = "alert_management_condition"; // Condition column
+const VAR_VALUE = "alert_management_value"; // Value column
+const VAR_SEND_TO = "alert_management_users"; // Send to column
+const VAR_MESSAGE = "alert_management_message"; // Message column
+
+/**
+ * Form variable names sent by the Input Form widget. See
+ * `alert-scope-creation.jsonc` at the repo root for the full reference.
+ */
+const FORM_SETUP_BY = "new_alert_selected_type";
+const FORM_SENSORS = "new_alert_selected_sensors";
+const FORM_MODEL = "new_alert_type";
+const FORM_CONDITION = "new_alert_condition";
+const FORM_TEMP_VALUE = "new_alert_temp_value";
+const FORM_TEMP_VALUE_BETWEEN = "new_alert_temp_value_between";
+const FORM_DOOR = "new_alert_door_enum";
+const FORM_COMPRESSOR = "new_alert_compressor_enum";
+const FORM_CHECKIN = "new_alert_inactivity_hours";
+const FORM_USERS = "new_alert_users";
+const FORM_MESSAGE = "new_alert_custom_message";
+
+/** Tag identifiers stored on every TagoIO Action created by this CRUD. */
+const ACTION_TAG_ALERT_ID = "alert_id";
+const ACTION_TAG_ORG_ID = "organization_id";
+const ACTION_TAG_TYPE = "action_type";
+const ACTION_TAG_TYPE_VALUE = "alert";
+
+/**
+ * Tag pairs the Action uses to match sensor devices at trigger time.
+ *
+ * `device_type=device` is the project-wide marker that flags a sensor
+ * (set by `crud-sensor.ts`). We use it for `all_sensors` alerts so the
+ * Action automatically covers every sensor in the organization.
+ *
+ * `alert_id=` is added to each selected sensor by this CRUD
+ * when the user picks specific sensors. The Action then matches only
+ * those sensors. The tag is removed on delete.
+ */
+const SENSOR_TAG_DEVICE_TYPE = "device_type";
+const SENSOR_TAG_DEVICE_VALUE = "device";
+const SENSOR_TAG_ALERT_ID = "alert_id";
+
+/** Human-readable labels used in the metadata of the table rows. */
+const MODEL_LABELS: Record = {
+ temperature: "Temperature",
+ door: "Door Status",
+ compressor: "Compressor Status",
+ inactivity: "Inactivity",
+};
+
+const CONDITION_LABELS: Record = {
+ ">": "Greater than",
+ "<": "Less than",
+ "=": "Equal to",
+ "!": "Different from",
+ "><": "Between",
+};
+
+/** Maps each non-inactivity model to the sensor variable the Action listens to. */
+const MODEL_VARIABLE: Record = {
+ temperature: "temperature",
+ door: "door",
+ compressor: "compressor",
+};
+
+// ============================================================================
+// Validation schema
+// ============================================================================
+
+/**
+ * Form fields shared by every model. The model-specific fields are
+ * validated separately inside `parseFormFields`.
+ */
+const commonModel = z.object({
+ setupAlertsBy: z.enum(["all_sensors", "sensors"], { error: "Setup alerts by is required" }),
+ sensors: z.array(z.string()).optional(),
+ recipients: z.array(z.string()).min(1, { error: "At least one recipient is required" }),
+ message: z
+ .string({ error: "Message is required" })
+ .min(1, { error: "Message is required" })
+ .max(500, { error: "Message must be 500 characters or fewer" }),
+});
+
+const temperatureModel = z.object({
+ model: z.literal("temperature"),
+ condition: z.enum(["<", ">", "=", "!", "><"], { error: "Condition is required" }),
+ value: z.coerce.number({ error: "Value must be a number" }),
+ secondValue: z.coerce.number({ error: "Second value must be a number" }).optional(),
+});
+
+const doorModel = z.object({
+ model: z.literal("door"),
+ value: z.enum(["open", "closed"], { error: "Door value must be open or closed" }),
+});
+
+const compressorModel = z.object({
+ model: z.literal("compressor"),
+ value: z.enum(["on", "off"], { error: "Compressor value must be on or off" }),
+});
+
+const inactivityModel = z.object({
+ model: z.literal("inactivity"),
+ inactivityHours: z.coerce
+ .number({ error: "Inactivity hours must be a number" })
+ .int({ error: "Inactivity hours must be a whole number" })
+ .positive({ error: "Inactivity hours must be greater than zero" }),
+});
+
+const modelDiscriminator = z.discriminatedUnion("model", [
+ temperatureModel,
+ doorModel,
+ compressorModel,
+ inactivityModel,
+]);
+
+type CommonFields = z.infer;
+type ModelFields = z.infer;
+type AlertFields = CommonFields & ModelFields;
+
+// ============================================================================
+// Helpers — error handling
+// ============================================================================
+
+/**
+ * Extracts a short, human-readable message from a Zod or generic error and
+ * re-throws it as a plain `Error`. Only the first Zod issue is surfaced
+ * to the user.
+ */
+function getZodErrorMessage(error: unknown): never {
+ if (error instanceof ZodError) {
+ const message = error.issues[0]?.message ?? "Validation error";
+ throw new Error(message);
+ }
+
+ if (error instanceof Error) {
+ throw error;
+ }
+
+ throw new Error("Unknown error occurred");
+}
+
+// ============================================================================
+// Helpers — feedback to the dashboard
+// ============================================================================
+
+type ValidationLevel = "success" | "danger" | "warning";
+
+interface ValidationConfig {
+ validationVariable: string;
+ deviceID: string;
+ sessionID?: string;
+}
+
+/**
+ * Creates a `validate(message, level)` function tied to a specific
+ * validation variable on the configuration device. The dashboard listens
+ * to this variable through a Validation widget and renders the messages
+ * to the user.
+ */
+function initializeValidation(config: ValidationConfig) {
+ let messageIndex = 0;
+
+ return async (message: string, level: ValidationLevel = "success"): Promise => {
+ if (!message?.trim()) {
+ throw new Error("Validation message cannot be empty");
+ }
+
+ const now = DateTime.now();
+ const timeOffset = ++messageIndex * 200;
+
+ await Promise.allSettled([
+ Resources.devices.deleteDeviceData(config.deviceID, {
+ variables: config.validationVariable,
+ qty: 999,
+ end_date: now.minus({ minutes: 1 }).toJSDate(),
+ }),
+ Resources.devices.sendDeviceData(config.deviceID, {
+ variable: config.validationVariable,
+ value: message,
+ time: now.plus({ milliseconds: timeOffset }).toJSDate(),
+ metadata: {
+ type: level,
+ session_id: config.sessionID,
+ show_markdown: false,
+ },
+ }),
+ ]);
+
+ return message;
+ };
+}
+
+// ============================================================================
+// Helpers — form parsing
+// ============================================================================
+
+/**
+ * Splits a TagoIO comma-joined value (e.g. `"id1, id2, id3"`) into a clean
+ * array of trimmed non-empty strings. The form widget joins multi-select
+ * values with `", "` so we normalize at this boundary.
+ */
+function splitCsv(value: unknown): string[] {
+ if (typeof value !== "string") {
+ return [];
+ }
+ return value
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+}
+
+/**
+ * Reads the alert form from the scope and runs it through the Zod schemas.
+ *
+ * The form is variadic — different fields appear depending on the model.
+ * We pick out everything we might need first, then hand it to a
+ * discriminated union so each model only validates what's relevant.
+ */
+async function parseFormFields(scope: Data[]): Promise {
+ const setupAlertsBy = scope.find((item) => item.variable === FORM_SETUP_BY)?.value;
+ const sensors = splitCsv(scope.find((item) => item.variable === FORM_SENSORS)?.value);
+ const model = scope.find((item) => item.variable === FORM_MODEL)?.value;
+ const condition = scope.find((item) => item.variable === FORM_CONDITION)?.value;
+ const tempValue = scope.find((item) => item.variable === FORM_TEMP_VALUE)?.value;
+ const tempValueBetween = scope.find((item) => item.variable === FORM_TEMP_VALUE_BETWEEN)?.value;
+ const door = scope.find((item) => item.variable === FORM_DOOR)?.value;
+ const compressor = scope.find((item) => item.variable === FORM_COMPRESSOR)?.value;
+ const inactivity = scope.find((item) => item.variable === FORM_CHECKIN)?.value;
+ const recipients = splitCsv(scope.find((item) => item.variable === FORM_USERS)?.value);
+ const message = scope.find((item) => item.variable === FORM_MESSAGE)?.value;
+
+ const common = await commonModel.parseAsync({
+ setupAlertsBy,
+ sensors: sensors.length > 0 ? sensors : undefined,
+ recipients,
+ message,
+ });
+
+ if (common.setupAlertsBy === "sensors" && (!common.sensors || common.sensors.length === 0)) {
+ throw new Error("Select at least one sensor");
+ }
+
+ let modelFields: ModelFields;
+ if (model === "temperature") {
+ modelFields = await temperatureModel.parseAsync({
+ model,
+ condition,
+ value: tempValue,
+ secondValue: condition === "><" ? tempValueBetween : undefined,
+ });
+ if (modelFields.condition === "><" && modelFields.secondValue === undefined) {
+ throw new Error("Second value is required for the 'between' condition");
+ }
+ } else if (model === "door") {
+ modelFields = await doorModel.parseAsync({ model, value: door });
+ } else if (model === "compressor") {
+ modelFields = await compressorModel.parseAsync({ model, value: compressor });
+ } else if (model === "inactivity") {
+ modelFields = await inactivityModel.parseAsync({ model, inactivityHours: inactivity });
+ } else {
+ throw new Error("Model is required");
+ }
+
+ return { ...common, ...modelFields };
+}
+
+// ============================================================================
+// Helpers — sensor resolution
+// ============================================================================
+
+/**
+ * Fetches every sensor that belongs to the organization, returning a tiny
+ * shape with just `id` and `name`. The Action creation needs the ids; the
+ * row metadata stores the names so the table renders human-readable chips.
+ */
+async function listOrganizationSensors(organizationID: string): Promise<{ id: string; name: string; tags: TagsObj[] }[]> {
+ const sensors: { id: string; name: string; tags: TagsObj[] }[] = [];
+ for (let page = 1; page < 9999; page++) {
+ const batch = await Resources.devices.list({
+ page,
+ amount: 100,
+ fields: ["id", "name", "tags"],
+ filter: {
+ tags: [
+ { key: "organization_id", value: organizationID },
+ { key: "device_type", value: "device" },
+ ],
+ },
+ resolveBucketName: false,
+ });
+
+ for (const device of batch) {
+ sensors.push({ id: device.id, name: device.name, tags: device.tags });
+ }
+
+ if (batch.length < 100) {
+ break;
+ }
+ }
+
+ return sensors;
+}
+
+/**
+ * Resolves the snapshot of sensors that the alert applies to.
+ *
+ * For `all_sensors` we list every sensor in the organization right now.
+ * For `sensors` we look up the user-selected ids inside that list to grab
+ * the display names. The names go on the chip metadata so the widget can
+ * render labels even when the row is rendered offline.
+ */
+async function resolveTargetSensors(organizationID: string, fields: AlertFields): Promise<{ id: string; name: string; tags: TagsObj[] }[]> {
+ const orgSensors = await listOrganizationSensors(organizationID);
+
+ if (fields.setupAlertsBy === "all_sensors") {
+ if (orgSensors.length === 0) {
+ throw new Error("This organization has no sensors yet");
+ }
+ return orgSensors;
+ }
+
+ const wanted = new Set(fields.sensors ?? []);
+ const matched = orgSensors.filter((sensor) => wanted.has(sensor.id));
+ if (matched.length !== wanted.size) {
+ throw new Error("One or more selected sensors do not belong to this organization");
+ }
+ return matched;
+}
+
+// ============================================================================
+// Helpers — recipient resolution
+// ============================================================================
+
+/**
+ * Resolves run-user ids into `{ id, name }` pairs by listing every user in
+ * the organization once and intersecting with the selection.
+ */
+async function resolveRecipients(organizationID: string, recipientIDs: string[]): Promise<{ id: string; name: string }[]> {
+ const wanted = new Set(recipientIDs);
+ const users = await Resources.run.listUsers({
+ amount: 1000,
+ fields: ["id", "name"],
+ filter: { tags: [{ key: "organization_id", value: organizationID }] },
+ });
+
+ const matched: { id: string; name: string }[] = [];
+ for (const user of users) {
+ if (wanted.has(user.id)) {
+ matched.push({ id: user.id, name: user.name });
+ }
+ }
+
+ if (matched.length !== wanted.size) {
+ throw new Error("One or more recipients do not belong to this organization");
+ }
+ return matched;
+}
+
+// ============================================================================
+// Helpers — table-row persistence on the organization device
+// ============================================================================
+
+interface AlertRowPayload {
+ organizationID: string;
+ alertID: string;
+ sensors: { id: string; name: string }[];
+ recipients: { id: string; name: string }[];
+ fields: AlertFields;
+}
+
+/**
+ * Returns the value and human label that go into the `Value` column for the
+ * resolved model. Temperature `between` joins both numbers; the enum
+ * models map their value to a capitalized label.
+ */
+function buildValueColumn(fields: AlertFields): { value: string; label: string } {
+ if (fields.model === "temperature") {
+ if (fields.condition === "><" && fields.secondValue !== undefined) {
+ return {
+ value: `${fields.value},${fields.secondValue}`,
+ label: `${fields.value} – ${fields.secondValue}`,
+ };
+ }
+ return { value: String(fields.value), label: String(fields.value) };
+ }
+ if (fields.model === "door") {
+ return { value: fields.value, label: fields.value === "open" ? "Open" : "Closed" };
+ }
+ if (fields.model === "compressor") {
+ return { value: fields.value, label: fields.value === "on" ? "On" : "Off" };
+ }
+ return {
+ value: String(fields.inactivityHours),
+ label: `${fields.inactivityHours} hour${fields.inactivityHours === 1 ? "" : "s"}`,
+ };
+}
+
+/**
+ * Writes the six variables that make up one row on the Alert List widget.
+ * Every variable shares the same `group` (the alert id) so edit and delete
+ * can target the row through that group filter alone.
+ */
+async function persistAlertRow(payload: AlertRowPayload): Promise {
+ const { organizationID, alertID, sensors, recipients, fields } = payload;
+
+ const devicesValue = fields.setupAlertsBy === "all_sensors" ? "all_sensors" : sensors.map((sensor) => sensor.id).join(", ");
+ const devicesLabel = fields.setupAlertsBy === "all_sensors" ? "All Sensors" : sensors.map((sensor) => sensor.name).join(", ");
+ const devicesChips = fields.setupAlertsBy === "all_sensors"
+ ? [{ label: "All Sensors", value: "all_sensors" }]
+ : sensors.map((sensor) => ({ label: sensor.name, value: sensor.id }));
+
+ const condition = fields.model === "temperature" ? fields.condition : "";
+ const conditionLabel = condition ? CONDITION_LABELS[condition] : "";
+
+ const valueColumn = buildValueColumn(fields);
+
+ const recipientsValue = recipients.map((user) => user.id).join(", ");
+ const recipientsChips = recipients.map((user) => ({ label: user.name, value: user.id }));
+
+ // We need each data point to share the SAME `group` (alertID) so widget
+ // queries by group return the whole row. Tag the rows with the org id
+ // and alert id metadata too — useful for downstream debugging.
+ const commonMetadata = { alert_id: alertID, organization_id: organizationID };
+
+ let unitForValue: { unit: string } | undefined = undefined;
+ if (fields.model === "temperature") {
+ unitForValue = { unit: "°F" };
+ }
+ if (fields.model === "inactivity") {
+ unitForValue = { unit: "hour(s)" };
+ }
+
+ await Resources.devices.sendDeviceData(organizationID, [
+ {
+ variable: VAR_DEVICES,
+ value: devicesValue,
+ group: alertID,
+ metadata: { ...commonMetadata, label: devicesLabel, sentValues: devicesChips },
+ },
+ {
+ variable: VAR_MODEL,
+ value: fields.model,
+ group: alertID,
+ metadata: { ...commonMetadata, label: MODEL_LABELS[fields.model] },
+ },
+ {
+ variable: VAR_CONDITION,
+ value: condition,
+ group: alertID,
+ metadata: { ...commonMetadata, label: conditionLabel },
+ },
+ {
+ variable: VAR_VALUE,
+ value: valueColumn.value,
+ ...unitForValue,
+ group: alertID,
+ metadata: { ...commonMetadata, label: valueColumn.label },
+ },
+ {
+ variable: VAR_SEND_TO,
+ value: recipientsValue,
+ group: alertID,
+ metadata: { ...commonMetadata, sentValues: recipientsChips },
+ },
+ {
+ variable: VAR_MESSAGE,
+ value: fields.message,
+ group: alertID,
+ metadata: { ...commonMetadata },
+ },
+ ]);
+}
+
+/**
+ * Deletes every data row that shares the alert id as group on the
+ * organization device. Called from delete AND from edit (we re-create the
+ * row from scratch on edit so we don't have to update six points
+ * individually).
+ */
+async function deleteAlertRow(organizationID: string, alertID: string): Promise {
+ await Resources.devices.deleteDeviceData(organizationID, {
+ groups: alertID,
+ qty: 9999,
+ });
+}
+
+// ============================================================================
+// Helpers — TagoIO Action lifecycle
+// ============================================================================
+
+/** Subset of AlertFields that the Action layer can actually handle. */
+type ActionableAlertFields = CommonFields & Exclude;
+
+/**
+ * Builds the trigger array for the TagoIO Action.
+ *
+ * The Action listens to a SENSOR variable on every device that matches a
+ * tag pair. This way one Action covers many sensors at once, and new
+ * sensors that match the tag are automatically included
+ *
+ * Two cases:
+ * - `setupAlertsBy === "all_sensors"` → match every sensor in this org
+ * by the project-wide marker tag `device_type=device`. The Action's
+ * own `organization_id` tag is what keeps it scoped to one tenant
+ * (see `createAlertAction`).
+ * - `setupAlertsBy === "sensors"` → match sensors that carry the
+ * `alert_id` tag pointing at this alert. We add that tag to the
+ * selected sensors at create time (see `tagSensorsForAlert`).
+ */
+function buildTriggers(alertID: string, fields: ActionableAlertFields): Array<{
+ tag_key: string;
+ tag_value: string;
+ variable: string;
+ is: Conditionals;
+ value: string;
+ second_value?: string;
+ value_type: "string" | "number" | "boolean" | "*";
+}> {
+ const variable = MODEL_VARIABLE[fields.model];
+
+ const tagPair = fields.setupAlertsBy === "all_sensors"
+ ? { tag_key: SENSOR_TAG_DEVICE_TYPE, tag_value: SENSOR_TAG_DEVICE_VALUE }
+ : { tag_key: SENSOR_TAG_ALERT_ID, tag_value: alertID };
+
+ if (fields.model === "temperature") {
+ return [{
+ ...tagPair,
+ variable,
+ is: fields.condition,
+ value: String(fields.value),
+ ...(fields.condition === "><" && fields.secondValue !== undefined ? { second_value: String(fields.secondValue) } : {}),
+ value_type: "number",
+ }];
+ }
+
+ // door + compressor — equality match on a string value.
+ return [{
+ ...tagPair,
+ variable,
+ is: "=",
+ value: fields.value,
+ value_type: "string",
+ }];
+}
+
+/**
+ * Creates the TagoIO Action that watches the configured sensors and calls
+ * the dispatcher Analysis on every match. Tags carry the alert id so we
+ * can find this Action again on edit/delete.
+ */
+async function createAlertAction(params: {
+ alertID: string;
+ organizationID: string;
+ dispatcherID: string;
+ fields: AlertFields;
+}): Promise {
+ const { alertID, organizationID, dispatcherID, fields } = params;
+
+ if (fields.model === "inactivity") {
+ return;
+ }
+
+ const triggers = buildTriggers(alertID, fields);
+
+ // The SDK only types `condition` triggers with a `device` id, but the
+ // TagoIO API also accepts `tag_key`/`tag_value` here — the platform
+ // matches every device whose tags include the pair. We cast through
+ // `unknown` to avoid silencing other shape errors.
+ await Resources.actions.create({
+ name: `[alert] ${alertID}`,
+ active: true,
+ type: "condition",
+ tags: [
+ { key: ACTION_TAG_ALERT_ID, value: alertID },
+ { key: ACTION_TAG_ORG_ID, value: organizationID },
+ { key: ACTION_TAG_TYPE, value: ACTION_TAG_TYPE_VALUE },
+ ],
+ trigger: triggers as unknown as any,
+ action: { type: "script", script: [dispatcherID] },
+ });
+}
+
+/**
+ * Adds the `alert_id` tag to every selected sensor. The Action then picks
+ * them up automatically via its `tag_key/tag_value` trigger. Existing tags
+ * on each sensor are preserved.
+ */
+async function tagSensorsForAlert(alertID: string, sensorList: { id: string; tags: TagsObj[] }[]): Promise {
+ for (const sensor of sensorList) {
+ const filteredTags = sensor.tags.filter((tag) => !(tag.key === SENSOR_TAG_ALERT_ID && tag.value === alertID));
+ const nextTags = [...filteredTags, { key: SENSOR_TAG_ALERT_ID, value: alertID }];
+ await Resources.devices.edit(sensor.id, { tags: nextTags }).catch((error) => {
+ console.error(`Failed to tag sensor ${sensor.id}: ${(error as Error).message}`);
+ });
+ }
+}
+
+/**
+ * Removes the `alert_id=alertID` tag from every sensor that currently
+ * carries it. We discover the sensors with `Resources.devices.list`
+ * filtered by the tag, so we do not need to remember which sensors were
+ * picked at create time.
+ */
+async function untagSensorsForAlert(alertID: string): Promise {
+ const sensors = await Resources.devices.list({
+ amount: 1000,
+ fields: ["id", "tags"],
+ filter: { tags: [{ key: SENSOR_TAG_ALERT_ID, value: alertID }] },
+ resolveBucketName: false,
+ });
+
+ for (const sensor of sensors) {
+ const nextTags = sensor.tags.filter((tag) => !(tag.key === SENSOR_TAG_ALERT_ID && tag.value === alertID));
+ await Resources.devices.edit(sensor.id, { tags: nextTags }).catch((error) => {
+ console.error(`Failed to untag sensor ${sensor.id}: ${(error as Error).message}`);
+ });
+ }
+}
+
+/**
+ * Returns the id of the single Action tagged with the given alert id, or
+ * null if none exists. Used by delete so we never operate on stale
+ * references.
+ */
+async function findActionByAlertID(alertID: string): Promise {
+ const actions = await Resources.actions.list({
+ amount: 10,
+ fields: ["id", "tags"],
+ filter: { tags: [{ key: ACTION_TAG_ALERT_ID, value: alertID }] },
+ });
+
+ const action = actions[0];
+ return action ? action.id : null;
+}
+
+/** Deletes the TagoIO Action that backs the alert, if any. */
+async function deleteAlertAction(alertID: string): Promise {
+ const actionID = await findActionByAlertID(alertID);
+ if (actionID) {
+ await Resources.actions.delete(actionID).catch(console.error);
+ }
+}
+
+// ============================================================================
+// CREATE flow
+// ============================================================================
+
+async function createAlert({ context, environment, scope }: RouterConstructor & { scope: Data[] }) {
+ console.log("[createAlert] start");
+
+ if (!("variable" in scope[0])) {
+ console.error("Not a valid TagoIO Data");
+ return;
+ }
+ if (!context) {
+ throw "[Error] Missing analysis context.";
+ }
+
+ const configDevID = environment.config_id;
+ const dispatcherID = environment.alert_dispatcher_id;
+
+ const organizationID = scope[0].device;
+ if (!organizationID) {
+ throw "[Error] Missing organization ID in scope.";
+ }
+
+ const sessionID = z.string().parse(scope.find((item) => item.variable === "create_alert_session_id")?.value);
+ const validate = initializeValidation({ validationVariable: "create_alert_validation", deviceID: configDevID, sessionID });
+
+ await validate("Creating alert, please wait...", "warning").catch(console.log);
+
+ const fields = await parseFormFields(scope)
+ .catch(getZodErrorMessage)
+ .catch(async (error: Error) => {
+ await validate(error.message, "danger");
+ throw error;
+ });
+
+ // Resolve sensors and recipients against the live tenant data so we can
+ // store labels and reject ids that don't belong to this organization.
+ const sensors = await resolveTargetSensors(organizationID, fields).catch(async (error: Error) => {
+ await validate(error.message, "danger");
+ throw error;
+ });
+
+ const recipients = await resolveRecipients(organizationID, fields.recipients).catch(async (error: Error) => {
+ await validate(error.message, "danger");
+ throw error;
+ });
+
+ // The data point id of the first row becomes the alert id. We generate
+ // it ourselves by deriving from `Date.now()` so it's predictable inside
+ // this function (Resources.devices.sendDeviceData does not return the
+ // generated id in the array shape).
+ const alertID = `alert_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
+
+ await persistAlertRow({ organizationID, alertID, sensors, recipients, fields });
+
+ // When the user picked specific sensors, write the `alert_id` tag on each
+ // one BEFORE creating the Action — the Action's `tag_key/tag_value`
+ // trigger needs the tags to be already in place to match.
+ if (fields.setupAlertsBy === "sensors") {
+ await tagSensorsForAlert(alertID, sensors);
+ }
+
+ await createAlertAction({
+ alertID,
+ organizationID,
+ dispatcherID,
+ fields,
+ }).catch(async (error: Error) => {
+ // Rollback the table row AND the sensor tags if Action creation fails.
+ // The user shouldn't see a half-created alert.
+ await deleteAlertRow(organizationID, alertID).catch(console.error);
+ if (fields.setupAlertsBy === "sensors") {
+ await untagSensorsForAlert(alertID).catch(console.error);
+ }
+ await validate(`Failed to create alert action: ${error.message}`, "danger");
+ throw error;
+ });
+
+ await validate("Alert created successfully!", "success");
+}
+
+// ============================================================================
+// DELETE flow
+// ============================================================================
+
+async function deleteAlert({ scope }: RouterConstructor & { scope: DeviceListScope[] }) {
+ console.log("[deleteAlert] start");
+
+ const entry = scope[0];
+ const alertID = entry?.group;
+ const organizationID = entry?.device;
+
+ if (!alertID) {
+ throw "[Error] Missing alert id (group) in scope.";
+ }
+ if (!organizationID) {
+ throw "[Error] Missing organization ID in scope.";
+ }
+
+ await deleteAlertRow(organizationID, alertID);
+ await deleteAlertAction(alertID);
+ // Strip the `alert_id` tag from any sensor that still carries it.
+ // No-op for `all_sensors` alerts (we never tagged anything in that case).
+ await untagSensorsForAlert(alertID);
+}
+
+// ============================================================================
+// Router entrypoint
+// ============================================================================
+
+async function startAnalysis(context: TagoContext, scope: Data[]): Promise {
+ console.log("Running CRUD Alert Analysis");
+ console.log("Scope:", scope);
+
+ const environment = Utils.envToJson(context.environment);
+ if (!environment.config_id) {
+ throw "Missing config_id environment variable";
+ }
+ if (!environment.alert_dispatcher_id) {
+ throw "Missing alert_dispatcher_id environment variable";
+ }
+
+ const router = new Utils.AnalysisRouter({ scope, context, environment });
+
+ router.register(createAlert).whenInputFormID("create-alert");
+ router.register(deleteAlert).whenVariableLike("alert_management_").whenWidgetExec("delete");
+
+ const result = await router.exec();
+ console.log("Services found:", result.services);
+}
+
+if (!Deno.env.get("T_TEST")) {
+ Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") });
+}
+
+export { startAnalysis };
diff --git a/app/analysis/dashboard/crud-group.ts b/app/analysis/dashboard/crud-group.ts
new file mode 100644
index 0000000..93c619a
--- /dev/null
+++ b/app/analysis/dashboard/crud-group.ts
@@ -0,0 +1,627 @@
+/**
+ * CRUD Group Analysis
+ *
+ * Educational single-file Analysis that handles the full lifecycle of a
+ * Group resource in the TagoIO Kickstarter project: create, edit and
+ * delete. This file is intentionally self-contained — it has no relative
+ * imports.
+ *
+ * How it is triggered
+ * -------------------
+ * A dashboard sends Data points to this Analysis. The Analysis Router from
+ * `@tago-io/sdk` inspects the scope and runs the matching handler:
+ *
+ * - Input Form "create-group" -> createGroup
+ * - Custom Button "edit-group" -> editGroup
+ * - Device List action "delete-group" -> deleteGroup
+ *
+ * Required environment variables
+ * ------------------------------
+ * - config_id : ID of the configuration device that stores the
+ * dashboard's per-organization data and is used to
+ * publish validation messages back to the UI.
+ * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime.
+ *
+ * NOTE
+ * ----
+ * This file is optimized for clarity, not performance. The goal is for a
+ * developer new to TagoIO to read it top-to-bottom and understand every step.
+ */
+
+import { Analysis, type Data, type DeviceCreateInfo, type DeviceListScope } from "npm:@tago-io/sdk";
+import { Resources, type RouterConstructor, Services, type TagoContext, type TagsObj, Utils } from "npm:@tago-io/sdk";
+import { DateTime } from "npm:luxon";
+import z, { ZodError } from "npm:zod";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * Custom HTTPS Storage Network ID. Used by configuration devices that only
+ * hold data (organizations, groups) instead of receiving real uplinks.
+ */
+const STORAGE_NETWORK_ID = "62336c32ab6e0d0012e06c04";
+
+/**
+ * Database Connector ID paired with the storage network above.
+ */
+const DATABASE_CONNECTOR_ID = "62333bd36977fc001a2990c8";
+
+/**
+ * Tag key/value used to find the dashboard that manages the sensors of a
+ * single group. The dashboard is matched by its `export_id` tag, which is
+ * set when the dashboard template is imported into the account.
+ */
+const GROUP_DASHBOARD_TAG_KEY = "export_id";
+const GROUP_DASHBOARD_TAG_VALUE = "sensor-management";
+
+// ============================================================================
+// Validation schema
+// ============================================================================
+
+/**
+ * Address accepted as the already-normalized "lat,lng;label" string used by
+ * the Device List widget on the Edit flow.
+ */
+const addressStringSchema = z
+ .string()
+ .min(3, { error: "Address must be at least 3 characters" })
+ .max(200, { error: "Address must be less than 200 characters" });
+
+/**
+ * Address schema for the Create form. The dashboard sends the address as a
+ * TagoIO location Data point ({ value, location: { coordinates: [lng, lat] } }).
+ * We accept it as an object and transform it into the "lat,lng;label" string
+ * that TagoIO widgets expect when reading back the value from device params.
+ */
+const addressLocationSchema = z
+ .object({
+ value: z
+ .string()
+ .min(3, { error: "Address must be at least 3 characters" })
+ .max(200, { error: "Address must be less than 200 characters" })
+ .optional(),
+ location: z.object({
+ coordinates: z
+ .array(z.number(), { error: "Address Coordinates are required." })
+ .length(2, { error: "Invalid coordinates" }),
+ }),
+ })
+ .optional()
+ .transform(convertLocationToString);
+
+const groupModel = z.object({
+ name: z
+ .string({ error: "Name is required" })
+ .min(1, { error: "Name must be at least 1 character" })
+ .max(40, { error: "Name must be less than 40 characters" }),
+ address: z.union([addressStringSchema, addressLocationSchema]).optional(),
+});
+
+/**
+ * Partial schema reused by the Edit flow — every field becomes optional so
+ * we only validate what the Device List widget actually sent.
+ */
+const groupEditModel = groupModel.partial();
+
+// ============================================================================
+// Helpers — formatting and error handling
+// ============================================================================
+
+/**
+ * Converts a TagoIO location Data point into the "lat,lng;label" string
+ * format. Returns an empty string if the input is missing or malformed.
+ *
+ * The TagoIO `coordinates` array is stored as `[longitude, latitude]`, so
+ * we swap them when building the human-readable string.
+ */
+function convertLocationToString(data?: { value?: string; location?: { coordinates: number[] } }): string {
+ if (!data?.location?.coordinates || data.location.coordinates.length !== 2) {
+ return "";
+ }
+
+ const [lng, lat] = data.location.coordinates;
+ const label = data.value ?? "";
+ return `${lat},${lng};${label}`;
+}
+
+/**
+ * Extracts a short, human-readable message from a Zod or generic error and
+ * re-throws it as a plain `Error`. This keeps the validation feedback
+ * concise — only the first Zod issue is surfaced to the user.
+ */
+function getZodErrorMessage(error: unknown): never {
+ if (error instanceof ZodError) {
+ const message = error.issues[0]?.message ?? "Validation error";
+ throw new Error(message);
+ }
+
+ if (error instanceof Error) {
+ throw error;
+ }
+
+ throw new Error("Unknown error occurred");
+}
+
+// ============================================================================
+// Helpers — feedback to the dashboard
+// ============================================================================
+
+type ValidationLevel = "success" | "danger" | "warning";
+
+interface ValidationConfig {
+ validationVariable: string;
+ deviceID: string;
+ sessionID?: string;
+}
+
+/**
+ * Creates a `validate(message, level)` function tied to a specific
+ * validation variable on the configuration device. The dashboard listens
+ * to this variable through a Validation widget and renders the messages
+ * to the user.
+ *
+ * Each call cleans up old validation entries (>1 minute) and writes a new
+ * one with a small timestamp offset, so multiple messages from the same
+ * run still appear in the correct order on the dashboard.
+ */
+function initializeValidation(config: ValidationConfig) {
+ let messageIndex = 0;
+
+ return async (message: string, level: ValidationLevel = "success"): Promise => {
+ if (!message?.trim()) {
+ throw new Error("Validation message cannot be empty");
+ }
+
+ const now = DateTime.now();
+ // Each subsequent message is pushed 200ms forward so the dashboard
+ // renders them in insertion order even if the API timestamps collide.
+ const timeOffset = ++messageIndex * 200;
+
+ await Promise.allSettled([
+ Resources.devices.deleteDeviceData(config.deviceID, {
+ variables: config.validationVariable,
+ qty: 999,
+ end_date: now.minus({ minutes: 1 }).toJSDate(),
+ }),
+ Resources.devices.sendDeviceData(config.deviceID, {
+ variable: config.validationVariable,
+ value: message,
+ time: now.plus({ milliseconds: timeOffset }).toJSDate(),
+ metadata: {
+ type: level,
+ session_id: config.sessionID,
+ show_markdown: false,
+ },
+ }),
+ ]);
+
+ return message;
+ };
+}
+
+/**
+ * Sends an in-app notification to the Run User who triggered the
+ * Analysis. Falls back to a developer notification if no user can be
+ * identified — useful for edit/delete flows where the dashboard doesn't
+ * expose a Validation widget.
+ */
+async function sendNotificationFeedback(params: { environment: Record; title?: string; message: string }): Promise {
+ const { environment, title, message } = params;
+ const userID = environment?._user_id;
+
+ // No user context — notify the developer via the Analysis token.
+ if (!userID) {
+ const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") });
+ await services.notification.send({ title: title || "Operation error", message });
+ return;
+ }
+
+ // Confirm the user still exists before sending an in-app notification.
+ const user = await Resources.run.userInfo(userID).catch(() => null);
+ if (!user) {
+ const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") });
+ await services.notification.send({ title: title || "Operation error", message });
+ return;
+ }
+
+ await Resources.run.notificationCreate(userID, {
+ title: title || "Operation error",
+ message,
+ });
+}
+
+// ============================================================================
+// Helpers — resource lookups
+// ============================================================================
+
+/**
+ * Checks whether a device with the given name and/or tags already exists.
+ *
+ * @param isEdit - During an edit, the device being modified is itself
+ * returned by the search, so we only consider it a duplicate when more
+ * than one device matches. During a create, any match is a duplicate.
+ */
+async function deviceExists(params: { name?: string; tags: { key: string; value: string }[]; isEdit?: boolean }): Promise {
+ const { name, tags, isEdit = false } = params;
+
+ // Paginate through the device list with a comfortable page size. The
+ // Kickstarter is unlikely to hit the upper bound, but we keep the cap
+ // explicit for safety.
+ const found: { id: string }[] = [];
+ for (let page = 1; page < 9999; page++) {
+ const batch = await Resources.devices.list({
+ page,
+ amount: 100,
+ fields: ["id", "name", "tags"],
+ filter: { name, tags },
+ resolveBucketName: false,
+ });
+
+ found.push(...batch);
+ if (batch.length < 100) {
+ break;
+ }
+ }
+
+ if (isEdit) {
+ return found.length > 1;
+ }
+
+ return found.length > 0;
+}
+
+/**
+ * Finds a Dashboard ID by a tag value. Used to compose the URL that opens
+ * the per-group management dashboard right after creation.
+ */
+async function getDashboardIDByTag(tagKey: string, tagValue: string): Promise {
+ const [dashboard] = await Resources.dashboards.list({
+ amount: 1,
+ fields: ["id", "tags"],
+ filter: { tags: [{ key: tagKey, value: tagValue }] },
+ });
+
+ if (!dashboard?.id) {
+ throw new Error(`Dashboard with ${tagKey}=${tagValue} not found`);
+ }
+
+ return dashboard.id;
+}
+
+// ============================================================================
+// CREATE flow
+// ============================================================================
+
+/**
+ * Reads the form fields sent by the dashboard from the scope and runs
+ * them through the Zod schema. The schema also transforms the location
+ * Data point into a "lat,lng;label" string.
+ */
+function extractCreateFormFields(scope: Data[]) {
+ const newGroupName = scope.find((item: Data) => item.variable === "new_group_name")?.value;
+ const newGroupAddress = scope.find((item: Data) => item.variable === "new_group_address");
+
+ return groupModel.parseAsync({
+ name: newGroupName,
+ address: newGroupAddress,
+ });
+}
+
+/**
+ * Creates the group device on TagoIO and applies the identity tags
+ * (`organization_id`, `group_id`, `device_type`). The device id is reused
+ * as the group id throughout the application.
+ *
+ * The device is created with the parent-org and type tags, and then a
+ * second edit adds the `group_id` tag pointing to its own id. This two
+ * step approach is needed because the id is only known after creation.
+ */
+async function installGroupDevice(params: { name: string; organizationID: string }): Promise {
+ const tags: TagsObj[] = [
+ { key: "organization_id", value: params.organizationID },
+ { key: "device_type", value: "group" },
+ ];
+
+ const deviceData: DeviceCreateInfo = {
+ name: params.name,
+ type: "mutable",
+ network: STORAGE_NETWORK_ID,
+ connector: DATABASE_CONNECTOR_ID,
+ tags,
+ };
+
+ const newDevice = await Resources.devices.create(deviceData);
+
+ const newTags: TagsObj[] = tags.concat([
+ { key: "group_id", value: newDevice.device_id },
+ ]);
+
+ await Resources.devices.edit(newDevice.device_id, { tags: newTags });
+
+ return newDevice.device_id;
+}
+
+/**
+ * Handles the "create-group" Input Form submission.
+ *
+ * Steps:
+ * 1. Confirm the scope is a Data array sent by the form.
+ * 2. Read the session id so validation messages reach the right user.
+ * 3. Validate form fields with Zod; surface the first issue if any.
+ * 4. Reject duplicate group names inside the parent organization.
+ * 5. Create the device, tag it, and store its params (URL + address).
+ * 6. Send a success message back to the dashboard.
+ */
+async function createGroup({ environment, scope }: RouterConstructor & { scope: Data[] }) {
+ // The router can hand us non-Data scopes for other triggers, so we
+ // double-check that the first element actually looks like Data.
+ if (!("variable" in scope[0])) {
+ console.error("Not a valid TagoIO Data");
+ return;
+ }
+
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ throw "[Error] Missing config_id environment variable.";
+ }
+
+ // The form is rendered on the per-organization Groups dashboard, so the
+ // organization id is the device id stored in `scope[0].device`.
+ const organizationID = scope[0].device;
+ if (!organizationID) {
+ throw "[Error] Missing organization ID in scope.";
+ }
+
+ // The session id is generated by the dashboard form and lets the
+ // Validation widget filter messages to the user who triggered the run.
+ const sessionID = z.string().parse(scope.find((item: Data) => item.variable === "create_group_session_id")?.value);
+ const validate = initializeValidation({ validationVariable: "create_group_validation", deviceID: configDevID, sessionID });
+
+ // Friendly "working on it" message now that validation passed.
+ await validate("Adding group, please wait...", "warning").catch(console.log);
+
+ // Validate the form. If Zod fails, surface the first issue to the user
+ // and abort the run. The double `.catch` keeps the happy path readable.
+ const formFields = await extractCreateFormFields(scope)
+ .catch(getZodErrorMessage)
+ .catch(async (error: Error) => {
+ await validate(error.message, "danger");
+ throw error;
+ });
+
+ // Reject duplicates within the same organization.
+ const isNameInUse = await deviceExists({
+ name: formFields.name,
+ tags: [
+ { key: "organization_id", value: organizationID },
+ { key: "device_type", value: "group" },
+ ],
+ });
+
+ if (isNameInUse) {
+ throw await validate(
+ `A group with name ${formFields.name} already exists within this organization.`,
+ "danger",
+ );
+ }
+
+ // Create the device and tag it as a group of the current organization.
+ const groupID = await installGroupDevice({ name: formFields.name, organizationID });
+
+ // Build the URL that opens the per-group sensors dashboard and store it
+ // as a device param so the front-end can read it later.
+ const dashboardID = await getDashboardIDByTag(GROUP_DASHBOARD_TAG_KEY, GROUP_DASHBOARD_TAG_VALUE);
+ const dashboardURL = `/dashboards/info/${dashboardID}?settings=${configDevID}&group_dev=${groupID}`;
+
+ await Resources.devices.paramSet(groupID, [
+ { key: "dashboard_url", value: dashboardURL, sent: true },
+ { key: "group_address", value: formFields.address, sent: true },
+ ]);
+
+ await validate(`Group ${formFields.name} successfully added!`, "success");
+}
+
+// ============================================================================
+// EDIT flow
+// ============================================================================
+
+/**
+ * Restores a group device to its previous state when an edit fails
+ * validation. The Device List widget includes the previous values under
+ * `scope[0].old`, so we use that snapshot to roll back the change.
+ *
+ * Group edits only touch `name` and `param.group_address`, so we handle
+ * both fields explicitly instead of using a generic resolver.
+ */
+async function undoGroupChanges(groupID: string, scope: DeviceListScope[]): Promise {
+ const deviceScope = scope[0];
+ const oldValues = deviceScope?.old ?? {};
+
+ for (const key of Object.keys(deviceScope)) {
+ // Roll back the device name.
+ if (key === "name" && typeof oldValues[key] === "string") {
+ await Resources.devices.edit(groupID, { name: oldValues[key] as string });
+ continue;
+ }
+
+ // Roll back any edited param. Only `group_address` is exposed on the
+ // UI today, but we keep the branch generic in case more params are
+ // added later.
+ if (key.startsWith("param.")) {
+ const paramKey = key.replace("param.", "");
+ const oldValue = oldValues[key] as string | undefined;
+ if (oldValue === undefined) {
+ continue;
+ }
+
+ // Look up the existing param entry so we keep its id when updating.
+ const paramList = await Resources.devices.paramList(groupID);
+ const existing = paramList.find((p) => p.key === paramKey);
+ await Resources.devices.paramSet(groupID, {
+ id: existing?.id,
+ key: paramKey,
+ value: oldValue,
+ sent: true,
+ });
+ }
+ }
+}
+
+/**
+ * Handles the "edit-group" Custom Button on the Device List widget.
+ *
+ * The Device List sends both the new and the old value for each edited
+ * field. We validate the new values, and on any failure we restore the
+ * old ones and notify the user.
+ */
+async function editGroup({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) {
+ const groupID = scope[0].device;
+ if (!groupID) {
+ throw "[Error] Missing group ID in scope.";
+ }
+
+ const newName = scope[0]?.name;
+ const newAddress = scope[0]?.["param.group_address"] || undefined;
+
+ // Validate the partial payload. If Zod rejects it, undo the change and
+ // notify the user — then bubble the error up so the run is logged.
+ await groupEditModel
+ .parseAsync({ name: newName, address: newAddress })
+ .catch(getZodErrorMessage)
+ .catch(async (error: Error) => {
+ await undoGroupChanges(groupID, scope);
+ await sendNotificationFeedback({ environment, message: error.message });
+ throw error;
+ });
+
+ // If the name changed, make sure no other group inside this organization
+ // is using it. We read the parent organization from the group's tags.
+ if (newName) {
+ const groupInfo = await Resources.devices.info(groupID);
+ const organizationID = z
+ .string()
+ .parse(groupInfo.tags.find((tag) => tag.key === "organization_id")?.value);
+
+ const isNameInUse = await deviceExists({
+ name: newName,
+ tags: [
+ { key: "organization_id", value: organizationID },
+ { key: "device_type", value: "group" },
+ ],
+ isEdit: true,
+ });
+
+ if (isNameInUse) {
+ await undoGroupChanges(groupID, scope);
+ await sendNotificationFeedback({
+ environment,
+ message: `A group with name ${newName} already exists within this organization.`,
+ });
+ throw `A group with name ${newName} already exists within this organization.`;
+ }
+ }
+}
+
+// ============================================================================
+// DELETE flow
+// ============================================================================
+
+/**
+ * Deletes every device tagged with this group (sensors and any dummy
+ * device that carries the `group_id` tag of this group, except the group
+ * device itself, which has already been removed by the caller).
+ */
+async function deleteGroupDevices(groupID: string): Promise {
+ const devices = await Resources.devices.list({
+ amount: 9999,
+ page: 1,
+ fields: ["id"],
+ filter: { tags: [{ key: "group_id", value: groupID }] },
+ });
+
+ for (const device of devices) {
+ await Resources.devices.delete(device.id).catch(console.log);
+ }
+}
+
+/**
+ * Handles the "delete-group" identifier on the Device List widget.
+ *
+ * Cascades:
+ * 1. Wipe the group's data row in the config device.
+ * 2. Capture the group's name before the device is gone.
+ * 3. Delete the group device itself.
+ * 4. Delete every sensor (and dummy device) that carried this group_id.
+ * 5. Notify the user.
+ */
+async function deleteGroup({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) {
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ throw "[Error] Missing config_id environment variable.";
+ }
+
+ const groupID = scope[0].device;
+ if (!groupID) {
+ throw "[Error] Missing group ID in scope.";
+ }
+
+ // Capture the name now — the device will be gone before we send the
+ // notification at the end of the flow.
+ const groupInfo = await Resources.devices.info(groupID);
+
+ // Remove the group's row from the config device storage.
+ await Resources.devices.deleteDeviceData(configDevID, { groups: groupID, qty: 9999 });
+
+ // Delete the group device itself first so the cascade below does not
+ // need to filter it out.
+ await Resources.devices.delete(groupID).catch(console.log);
+
+ // Cascade into child resources (sensors and any dummy device sharing
+ // the same `group_id` tag).
+ await deleteGroupDevices(groupID);
+
+ await sendNotificationFeedback({
+ environment,
+ title: "Group removed",
+ message: `Group ${groupInfo.name} successfully removed!`,
+ });
+}
+
+// ============================================================================
+// Router entrypoint
+// ============================================================================
+
+/**
+ * Entrypoint invoked by the TagoIO Analysis runtime. Reads the scope and
+ * environment, sets up the router, and dispatches to the matching CRUD
+ * handler.
+ */
+async function startAnalysis(context: TagoContext, scope: Data[]): Promise {
+ console.log("Running CRUD Group Analysis");
+ console.log("Scope:", scope);
+
+ const environment = Utils.envToJson(context.environment);
+ if (!environment.config_id) {
+ throw "Missing config_id environment variable";
+ }
+
+ const router = new Utils.AnalysisRouter({ scope, context, environment });
+
+ router.register(createGroup).whenInputFormID("create-group");
+ router.register(editGroup).whenCustomBtnID("edit-group");
+ router.register(deleteGroup).whenDeviceListIdentifier("delete-group");
+
+ const result = await router.exec();
+ console.log("Services found:", result.services);
+}
+
+// The Analysis runtime sets `T_TEST` during local tests so the handler is
+// not wired up automatically. In production the runtime sets
+// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below.
+if (!Deno.env.get("T_TEST")) {
+ Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") });
+}
+
+export { startAnalysis };
diff --git a/app/analysis/dashboard/crud-organization.ts b/app/analysis/dashboard/crud-organization.ts
new file mode 100644
index 0000000..7b717a8
--- /dev/null
+++ b/app/analysis/dashboard/crud-organization.ts
@@ -0,0 +1,662 @@
+/**
+ * CRUD Organization Analysis
+ *
+ * Educational single-file Analysis that handles the full lifecycle of an
+ * Organization resource in the TagoIO Kickstarter project: create, edit and
+ * delete. This file is intentionally self-contained — it has no relative
+ * imports.
+ *
+ * How it is triggered
+ * -------------------
+ * A dashboard sends Data points to this Analysis. The Analysis Router from
+ * `@tago-io/sdk` inspects the scope and runs the matching handler:
+ *
+ * - Input Form "create-org" -> createOrganization
+ * - Custom Button "edit-org" -> editOrganization
+ * - Device List action "delete-org" -> deleteOrganization
+ *
+ * Required environment variables
+ * ------------------------------
+ * - config_id : ID of the configuration device that stores the
+ * dashboard's per-organization data and is used to
+ * publish validation messages back to the UI.
+ * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime.
+ *
+ * NOTE
+ * ----
+ * This file is optimized for clarity, not performance. The goal is for a
+ * developer new to TagoIO to read it top-to-bottom and understand every step.
+ */
+
+import { Analysis, type Data, type DataToSend, type DeviceCreateInfo, type DeviceListScope } from "npm:@tago-io/sdk";
+import { Resources, type RouterConstructor, Services, type TagoContext, type TagsObj, Utils } from "npm:@tago-io/sdk";
+import { DateTime } from "npm:luxon";
+import z, { ZodError } from "npm:zod";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * Custom HTTPS Storage Network ID. Used by configuration devices that only
+ * hold data (organizations, groups) instead of receiving real uplinks.
+ */
+const STORAGE_NETWORK_ID = "62336c32ab6e0d0012e06c04";
+
+/**
+ * Database Connector ID paired with the storage network above.
+ */
+const DATABASE_CONNECTOR_ID = "62333bd36977fc001a2990c8";
+
+/**
+ * Tag key/value used to find the dashboard that manages a single
+ * organization. The dashboard is matched by its `export_id` tag, which is
+ * set when the dashboard template is imported into the account.
+ */
+const ORG_DASHBOARD_TAG_KEY = "export_id";
+
+// ============================================================================
+// Validation schema
+// ============================================================================
+
+/**
+ * Address accepted as the already-normalized "lat,lng;label" string used by
+ * the Device List widget on the Edit flow. The Create flow does not reach
+ * this branch because it sends a TagoIO location Data point, which is
+ * handled by `addressLocationSchema` below.
+ */
+const addressStringSchema = z
+ .string()
+ .min(3, { error: "Address must be at least 3 characters" })
+ .max(200, { error: "Address must be less than 200 characters" });
+
+/**
+ * Address schema for the Create form. The dashboard sends the address as a
+ * TagoIO location Data point ({ value, location: { coordinates: [lng, lat] } }).
+ * We accept it as an object and transform it into the "lat,lng;label" string
+ * that TagoIO widgets expect when reading back the value from device params.
+ */
+const addressLocationSchema = z
+ .object({
+ value: z
+ .string()
+ .min(3, { error: "Address must be at least 3 characters" })
+ .max(200, { error: "Address must be less than 200 characters" })
+ .optional(),
+ location: z.object({
+ coordinates: z
+ .array(z.number(), { error: "Address Coordinates are required." })
+ .length(2, { error: "Invalid coordinates" }),
+ }),
+ })
+ .optional()
+ .transform(convertLocationToString);
+
+const orgModel = z.object({
+ name: z
+ .string({ error: "Name is required" })
+ .min(1, { error: "Name must be at least 1 character" })
+ .max(40, { error: "Name must be less than 40 characters" }),
+ address: z.union([addressStringSchema, addressLocationSchema]).optional(),
+});
+
+/**
+ * Partial schema reused by the Edit flow — every field becomes optional so
+ * we only validate what the Device List widget actually sent.
+ */
+const orgEditModel = orgModel.partial();
+
+// ============================================================================
+// Helpers — formatting and error handling
+// ============================================================================
+
+/**
+ * Converts a TagoIO location Data point into the "lat,lng;label" string
+ * format. Returns an empty string if the input is missing or malformed.
+ *
+ * The TagoIO `coordinates` array is stored as `[longitude, latitude]`, so
+ * we swap them when building the human-readable string.
+ */
+function convertLocationToString(data?: { value?: string; location?: { coordinates: number[] } }): string {
+ if (!data?.location?.coordinates || data.location.coordinates.length !== 2) {
+ return "";
+ }
+
+ const [lng, lat] = data.location.coordinates;
+ const label = data.value ?? "";
+ return `${lat},${lng};${label}`;
+}
+
+/**
+ * Extracts a short, human-readable message from a Zod or generic error and
+ * re-throws it as a plain `Error`. This keeps the validation feedback
+ * concise — only the first Zod issue is surfaced to the user.
+ */
+function getZodErrorMessage(error: unknown): never {
+ if (error instanceof ZodError) {
+ const message = error.issues[0]?.message ?? "Validation error";
+ throw new Error(message);
+ }
+
+ if (error instanceof Error) {
+ throw error;
+ }
+
+ throw new Error("Unknown error occurred");
+}
+
+// ============================================================================
+// Helpers — feedback to the dashboard
+// ============================================================================
+
+type ValidationLevel = "success" | "danger" | "warning";
+
+interface ValidationConfig {
+ validationVariable: string;
+ deviceID: string;
+ sessionID?: string;
+}
+
+/**
+ * Creates a `validate(message, level)` function tied to a specific
+ * validation variable on the configuration device. The dashboard listens
+ * to this variable through a Validation widget and renders the messages
+ * to the user.
+ *
+ * Each call cleans up old validation entries (>1 minute) and writes a new
+ * one with a small timestamp offset, so multiple messages from the same
+ * run still appear in the correct order on the dashboard.
+ */
+function initializeValidation(config: ValidationConfig) {
+ let messageIndex = 0;
+
+ return async (message: string, level: ValidationLevel = "success"): Promise => {
+ if (!message?.trim()) {
+ throw new Error("Validation message cannot be empty");
+ }
+
+ const now = DateTime.now();
+ // Each subsequent message is pushed 200ms forward so the dashboard
+ // renders them in insertion order even if the API timestamps collide.
+ const timeOffset = ++messageIndex * 200;
+
+ await Promise.allSettled([
+ Resources.devices.deleteDeviceData(config.deviceID, {
+ variables: config.validationVariable,
+ qty: 999,
+ end_date: now.minus({ minutes: 1 }).toJSDate(),
+ }),
+ Resources.devices.sendDeviceData(config.deviceID, {
+ variable: config.validationVariable,
+ value: message,
+ time: now.plus({ milliseconds: timeOffset }).toJSDate(),
+ metadata: {
+ type: level,
+ session_id: config.sessionID,
+ show_markdown: false,
+ },
+ }),
+ ]);
+
+ return message;
+ };
+}
+
+/**
+ * Sends an in-app notification to the Run User who triggered the
+ * Analysis. Falls back to a developer notification if no user can be
+ * identified — useful for edit/delete flows where the dashboard doesn't
+ * expose a Validation widget.
+ */
+async function sendNotificationFeedback(params: { environment: Record; title?: string; message: string }): Promise {
+ const { environment, title, message } = params;
+ const userID = environment?._user_id;
+
+ // No user context — notify the developer via the Analysis token.
+ if (!userID) {
+ const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") });
+ await services.notification.send({ title: title || "Operation error", message });
+ return;
+ }
+
+ // Confirm the user still exists before sending an in-app notification.
+ const user = await Resources.run.userInfo(userID).catch(() => null);
+ if (!user) {
+ const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") });
+ await services.notification.send({ title: title || "Operation error", message });
+ return;
+ }
+
+ await Resources.run.notificationCreate(userID, {
+ title: title || "Operation error",
+ message,
+ });
+}
+
+// ============================================================================
+// Helpers — resource lookups
+// ============================================================================
+
+/**
+ * Checks whether a device with the given name and/or tags already exists.
+ *
+ * @param isEdit - During an edit, the device being modified is itself
+ * returned by the search, so we only consider it a duplicate when more
+ * than one device matches. During a create, any match is a duplicate.
+ */
+async function deviceExists(params: { name?: string; tags: { key: string; value: string }[]; isEdit?: boolean }): Promise {
+ const { name, tags, isEdit = false } = params;
+
+ // Paginate through the device list with a comfortable page size. The
+ // Kickstarter is unlikely to hit the upper bound, but we keep the cap
+ // explicit for safety.
+ const found: { id: string }[] = [];
+ for (let page = 1; page < 9999; page++) {
+ const batch = await Resources.devices.list({
+ page,
+ amount: 100,
+ fields: ["id", "name", "tags"],
+ filter: { name, tags },
+ resolveBucketName: false,
+ });
+
+ found.push(...batch);
+ if (batch.length < 100) {
+ break;
+ }
+ }
+
+ if (isEdit) {
+ return found.length > 1;
+ }
+
+ return found.length > 0;
+}
+
+/**
+ * Finds a Dashboard ID by a tag value. Used to compose the URL that opens
+ * the per-organization management dashboard right after creation.
+ */
+async function getDashboardIDByTag(tagKey: string, tagValue: string): Promise {
+ const [dashboard] = await Resources.dashboards.list({
+ amount: 1,
+ fields: ["id", "tags"],
+ filter: { tags: [{ key: tagKey, value: tagValue }] },
+ });
+
+ if (!dashboard?.id) {
+ throw new Error(`Dashboard with ${tagKey}=${tagValue} not found`);
+ }
+
+ return dashboard.id;
+}
+
+// ============================================================================
+// CREATE flow
+// ============================================================================
+
+/**
+ * Reads the form fields sent by the dashboard from the scope and runs
+ * them through the Zod schema. The schema also transforms the location
+ * Data point into a "lat,lng;label" string.
+ */
+function extractCreateFormFields(scope: Data[]) {
+ const newOrgName = scope.find((item: Data) => item.variable === "new_organization_name")?.value;
+ const newOrgAddress = scope.find((item: Data) => item.variable === "new_organization_address");
+
+ return orgModel.parseAsync({
+ name: newOrgName,
+ address: newOrgAddress,
+ });
+}
+
+/**
+ * Creates the organization device on TagoIO and applies the identity tags
+ * (`organization_id`, `device_type`). The device id is reused as the organization
+ * id throughout the application.
+ */
+async function installOrganizationDevice(name: string): Promise {
+ const tags: TagsObj[] = [
+ { key: "device_type", value: "organization" },
+ ];
+
+ const deviceData: DeviceCreateInfo = {
+ name,
+ type: "mutable",
+ network: STORAGE_NETWORK_ID,
+ connector: DATABASE_CONNECTOR_ID,
+ tags,
+ };
+
+ const newDevice = await Resources.devices.create(deviceData);
+
+ const newTags: TagsObj[] = tags.concat([
+ { key: "organization_id", value: newDevice.device_id },
+ ]);
+
+ await Resources.devices.edit(newDevice.device_id, { tags: newTags });
+
+ return newDevice.device_id;
+}
+
+/**
+ * Handles the "create-org" Input Form submission.
+ *
+ * Steps:
+ * 1. Confirm the scope is a Data array sent by the form.
+ * 2. Read the session id so validation messages reach the right user.
+ * 3. Validate form fields with Zod; surface the first issue if any.
+ * 4. Reject duplicate organization names.
+ * 5. Create the device, tag it, and store its params (URL + address).
+ * 6. Send a success message back to the dashboard.
+ */
+async function createOrganization({ environment, scope }: RouterConstructor & { scope: Data[] }) {
+ // The router can hand us non-Data scopes for other triggers, so we
+ // double-check that the first element actually looks like Data.
+ if (!("variable" in scope[0])) {
+ console.error("Not a valid TagoIO Data");
+ return;
+ }
+
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ throw "[Error] Missing config_id environment variable.";
+ }
+
+ // The session id is generated by the dashboard form and lets the
+ // Validation widget filter messages to the user who triggered the run.
+ const sessionID = z.string().parse(scope.find((item: Data) => item.variable === "create_organization_session_id")?.value);
+ const validate = initializeValidation({ validationVariable: "create_organization_validation", deviceID: configDevID, sessionID });
+
+ // Friendly "working on it" message while we hit the TagoIO API.
+ await validate("Adding organization, please wait...", "warning").catch(console.log);
+
+ // Validate the form. If Zod fails, surface the first issue to the user
+ // and abort the run. The double `.catch` keeps the happy path readable.
+ const formFields = await extractCreateFormFields(scope)
+ .catch(getZodErrorMessage)
+ .catch(async (error: Error) => {
+ await validate(error.message, "danger");
+ throw error;
+ });
+
+ // Reject duplicates before creating any device.
+ const isNameInUse = await deviceExists({
+ name: formFields.name,
+ tags: [{ key: "device_type", value: "organization" }],
+ });
+
+ if (isNameInUse) {
+ throw await validate(`An organization with name ${formFields.name} already exists.`, "danger");
+ }
+
+ // Create the device and tag it as an organization.
+ const organizationID = await installOrganizationDevice(formFields.name);
+
+ // Build the URL that opens the per-organization dashboard and store it
+ // as a device param so the front-end can read it later.
+ const dashboardID = await getDashboardIDByTag(ORG_DASHBOARD_TAG_KEY, "group-management");
+ const dashboardURL = `/dashboards/info/${dashboardID}?settings=${configDevID}&org_dev=${organizationID}`;
+
+ await Resources.devices.paramSet(organizationID, [
+ { key: "dashboard_url", value: dashboardURL, sent: true },
+ { key: "organization_address", value: formFields.address, sent: true },
+ ]);
+
+ const location = scope.find((x) => x.variable === "new_organization_address")?.location;
+ const organizationAddressData: DataToSend = {
+ variable: "organization_address",
+ metadata: { label: formFields.name, url: dashboardURL, color: "#B0B0B0" },
+ location: location,
+ group: organizationID,
+ };
+ await Resources.devices.sendDeviceData(configDevID, organizationAddressData);
+
+ return validate(`Organization ${formFields.name} successfully added!`, "success");
+}
+
+// ============================================================================
+// EDIT flow
+// ============================================================================
+
+/**
+ * Restores an organization device to its previous state when an edit
+ * fails validation. The Device List widget includes the previous values
+ * under `scope[0].old`, so we use that snapshot to roll back the change.
+ *
+ * Organization edits only touch `name` and `param.organization_address`,
+ * so we handle both fields explicitly instead of using a generic resolver.
+ */
+async function undoOrganizationChanges(organizationID: string, scope: DeviceListScope[]): Promise {
+ const deviceScope = scope[0];
+ const oldValues = deviceScope?.old ?? {};
+
+ for (const key of Object.keys(deviceScope)) {
+ // Roll back the device name.
+ if (key === "name" && typeof oldValues[key] === "string") {
+ await Resources.devices.edit(organizationID, { name: oldValues[key] as string });
+ continue;
+ }
+
+ // Roll back any edited param. Only `organization_address` is exposed
+ // on the UI today, but we keep the branch generic in case more params
+ // are added later.
+ if (key.startsWith("param.")) {
+ const paramKey = key.replace("param.", "");
+ const oldValue = oldValues[key] as string | undefined;
+ if (oldValue === undefined) {
+ continue;
+ }
+
+ // Look up the existing param entry so we keep its id when updating.
+ const paramList = await Resources.devices.paramList(organizationID);
+ const existing = paramList.find((p) => p.key === paramKey);
+ await Resources.devices.paramSet(organizationID, {
+ id: existing?.id,
+ key: paramKey,
+ value: oldValue,
+ sent: true,
+ });
+ }
+ }
+}
+
+/**
+ * Handles the "edit-org" Custom Button on the Device List widget.
+ *
+ * The Device List sends both the new and the old value for each edited
+ * field. We validate the new values, and on any failure we restore the
+ * old ones and notify the user.
+ */
+async function editOrganization({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) {
+ const organizationID = scope[0].device;
+ if (!organizationID) {
+ throw "[Error] Missing organization ID in scope.";
+ }
+
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ throw "[Error] Missing config_id environment variable.";
+ }
+
+ const newName = scope[0]?.name;
+ const newAddress = scope[0]?.["param.organization_address"] || undefined;
+
+ // Validate the partial payload. If Zod rejects it, undo the change and
+ // notify the user — then bubble the error up so the run is logged.
+ await orgEditModel
+ .parseAsync({ name: newName, address: newAddress })
+ .catch(getZodErrorMessage)
+ .catch(async (error: Error) => {
+ await undoOrganizationChanges(organizationID, scope);
+ await sendNotificationFeedback({ environment, message: error.message });
+ throw error;
+ });
+
+ // If the name changed, make sure no other organization is using it.
+ if (newName) {
+ const isNameInUse = await deviceExists({
+ name: newName,
+ tags: [{ key: "device_type", value: "organization" }],
+ isEdit: true,
+ });
+
+ if (isNameInUse) {
+ await undoOrganizationChanges(organizationID, scope);
+ await sendNotificationFeedback({
+ environment,
+ message: `An organization with name ${newName} already exists.`,
+ });
+ throw `An organization with name ${newName} already exists.`;
+ }
+ }
+
+ // Keep the `organization_address` row on the config device in sync with
+ // the edited values. This is what powers the Organization List and the
+ // Map View on the Organizations dashboard.
+ const [orgAddressData] = await Resources.devices.getDeviceData(configDevID, {
+ variables: "organization_address",
+ groups: organizationID,
+ qty: 1,
+ });
+
+ if (orgAddressData) {
+ const [locationValue] = (newAddress ?? "").split(";");
+ const [latString, lngString] = locationValue.split(",");
+
+ await Resources.devices.editDeviceData(configDevID, {
+ ...orgAddressData,
+ metadata: {
+ ...orgAddressData.metadata,
+ label: newName || orgAddressData.metadata?.label,
+ },
+ location: { lat: Number(latString), lng: Number(lngString) },
+ });
+ }
+}
+
+// ============================================================================
+// DELETE flow
+// ============================================================================
+
+/**
+ * Deletes every Run User tagged with this organization.
+ */
+async function deleteOrganizationUsers(organizationID: string): Promise {
+ // Paginate through every user tagged with this organization id.
+ const users: { id: string }[] = [];
+ for (let page = 1; page < 9999; page++) {
+ const batch = await Resources.run.listUsers({
+ page,
+ amount: 40,
+ fields: ["id"],
+ filter: { tags: [{ key: "organization_id", value: organizationID }] },
+ });
+
+ users.push(...batch);
+ if (batch.length < 40) {
+ break;
+ }
+ }
+
+ for (const user of users) {
+ await Resources.run.userDelete(user.id).catch(console.log);
+ }
+}
+
+/**
+ * Deletes every device that belongs to the organization (groups, sensors,
+ * dummy devices, etc.). The organization device itself is removed by the
+ * caller after this function returns.
+ */
+async function deleteOrganizationDevices(organizationID: string): Promise {
+ const devices = await Resources.devices.list({
+ amount: 9999,
+ page: 1,
+ fields: ["id"],
+ filter: { tags: [{ key: "organization_id", value: organizationID }] },
+ });
+
+ for (const device of devices) {
+ await Resources.devices.delete(device.id).catch(console.log);
+ }
+}
+
+/**
+ * Handles the "delete-org" identifier on the Device List widget.
+ *
+ * Cascades:
+ * 1. Wipe the organization's data row in the config device.
+ * 2. Delete all related Run Users.
+ * 3. Delete all related devices (groups, sensors, ...).
+ * 4. Delete the organization device itself.
+ * 5. Notify the user.
+ */
+async function deleteOrganization({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) {
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ throw "[Error] Missing config_id environment variable.";
+ }
+
+ const organizationID = scope[0].device;
+ if (!organizationID) {
+ throw "[Error] Missing organization ID in scope.";
+ }
+
+ // Capture the name now — the device will be gone before we send the
+ // notification at the end of the flow.
+ const organizationInfo = await Resources.devices.info(organizationID);
+
+ // Remove the organization's row from the config device storage.
+ await Resources.devices.deleteDeviceData(configDevID, { groups: organizationID, qty: 9999 });
+
+ // Delete the organization device itself.
+ await Resources.devices.delete(organizationID).catch(console.log);
+
+ // Cascade into child resources.
+ await deleteOrganizationUsers(organizationID);
+ await deleteOrganizationDevices(organizationID);
+
+ await sendNotificationFeedback({
+ environment,
+ title: "Organization removed",
+ message: `Organization ${organizationInfo.name} successfully removed!`,
+ });
+}
+
+// ============================================================================
+// Router entrypoint
+// ============================================================================
+
+/**
+ * Entrypoint invoked by the TagoIO Analysis runtime. Reads the scope and
+ * environment, sets up the router, and dispatches to the matching CRUD
+ * handler.
+ */
+async function startAnalysis(context: TagoContext, scope: Data[]): Promise {
+ console.log("Running CRUD Organization Analysis");
+ console.log("Scope:", scope);
+
+ const environment = Utils.envToJson(context.environment);
+ if (!environment.config_id) {
+ throw "Missing config_id environment variable";
+ }
+
+ const router = new Utils.AnalysisRouter({ scope, context, environment });
+
+ router.register(createOrganization).whenInputFormID("create-organization");
+ router.register(editOrganization).whenCustomBtnID("edit-organization");
+ router.register(deleteOrganization).whenDeviceListIdentifier("delete-organization");
+
+ const result = await router.exec();
+ console.log("Services found:", result.services);
+}
+
+// The Analysis runtime sets `T_TEST` during local tests so the handler is
+// not wired up automatically. In production the runtime sets
+// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below.
+if (!Deno.env.get("T_TEST")) {
+ Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") });
+}
+
+export { startAnalysis };
diff --git a/app/analysis/dashboard/crud-sensor.ts b/app/analysis/dashboard/crud-sensor.ts
new file mode 100644
index 0000000..08d384b
--- /dev/null
+++ b/app/analysis/dashboard/crud-sensor.ts
@@ -0,0 +1,664 @@
+/**
+ * CRUD Sensor Analysis
+ *
+ * Educational single-file Analysis that handles the full lifecycle of a
+ * Sensor resource in the TagoIO Kickstarter project: create, edit and
+ * delete. This file is intentionally self-contained — it has no relative
+ * imports.
+ *
+ * How it is triggered
+ * -------------------
+ * A dashboard sends Data points to this Analysis. The Analysis Router from
+ * `@tago-io/sdk` inspects the scope and runs the matching handler:
+ *
+ * - Input Form "create-sensor" -> createSensor
+ * - Custom Button "edit-sensor" -> editSensor
+ * - Device List action "delete-sensor" -> deleteSensor
+ *
+ * Required environment variables
+ * ------------------------------
+ * - config_id : ID of the configuration device that stores the
+ * dashboard's per-organization data and is used to
+ * publish validation messages back to the UI.
+ * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime.
+ *
+ * NOTE
+ * ----
+ * This file is optimized for clarity, not performance. The goal is for a
+ * developer new to TagoIO to read it top-to-bottom and understand every step.
+ */
+
+import { Analysis, type Data, type DeviceCreateInfo, type DeviceListScope } from "npm:@tago-io/sdk";
+import { Resources, type RouterConstructor, Services, type TagoContext, type TagsObj, Utils } from "npm:@tago-io/sdk";
+import { DateTime } from "npm:luxon";
+import z, { ZodError } from "npm:zod";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * Tag key/value used to find the dashboard that opens a single sensor's
+ * detail view. The dashboard is matched by its `export_id` tag, set when
+ * the dashboard template is imported into the account.
+ */
+const SENSOR_DASHBOARD_TAG_KEY = "export_id";
+const SENSOR_DASHBOARD_TAG_VALUE = "sensor-freezer-dash";
+
+/**
+ * Variable used on the group device to expose the sensor connectivity
+ * summary (total registered, online, offline) that powers the Sensor
+ * Status cards on the Sensors dashboard.
+ */
+const SUMMARY_VARIABLE = "device_connectivity_summary";
+
+// ============================================================================
+// Validation schema
+// ============================================================================
+
+const sensorModel = z.object({
+ name: z
+ .string({ error: "Name is required" })
+ .min(1, { error: "Name must be at least 1 character" })
+ .max(40, { error: "Name must be less than 40 characters" }),
+ eui: z
+ .string({ error: "EUI is required" })
+ .regex(/^[0-9a-fA-F]{16}$/, { error: "EUI must be 16 hexadecimal characters" }),
+ network: z
+ .string({ error: "Network is required" })
+ .min(1, { error: "Network is required" }),
+ connector: z
+ .string({ error: "Connector is required" })
+ .min(1, { error: "Connector is required" }),
+});
+
+/**
+ * Partial schema reused by the Edit flow. The Device List widget only
+ * exposes the name today, so every other field becomes optional and is
+ * skipped when not present.
+ */
+const sensorEditModel = sensorModel.partial();
+
+// ============================================================================
+// Helpers — error handling
+// ============================================================================
+
+/**
+ * Extracts a short, human-readable message from a Zod or generic error and
+ * re-throws it as a plain `Error`. Only the first Zod issue is surfaced
+ * to the user.
+ */
+function getZodErrorMessage(error: unknown): never {
+ if (error instanceof ZodError) {
+ const message = error.issues[0]?.message ?? "Validation error";
+ throw new Error(message);
+ }
+
+ if (error instanceof Error) {
+ throw error;
+ }
+
+ throw new Error("Unknown error occurred");
+}
+
+// ============================================================================
+// Helpers — feedback to the dashboard
+// ============================================================================
+
+type ValidationLevel = "success" | "danger" | "warning";
+
+interface ValidationConfig {
+ validationVariable: string;
+ deviceID: string;
+ sessionID?: string;
+}
+
+/**
+ * Creates a `validate(message, level)` function tied to a specific
+ * validation variable on the configuration device. The dashboard listens
+ * to this variable through a Validation widget and renders the messages
+ * to the user.
+ *
+ * Each call cleans up old validation entries (>1 minute) and writes a new
+ * one with a small timestamp offset, so multiple messages from the same
+ * run still appear in the correct order on the dashboard.
+ */
+function initializeValidation(config: ValidationConfig) {
+ let messageIndex = 0;
+
+ return async (message: string, level: ValidationLevel = "success"): Promise => {
+ if (!message?.trim()) {
+ throw new Error("Validation message cannot be empty");
+ }
+
+ const now = DateTime.now();
+ // Each subsequent message is pushed 200ms forward so the dashboard
+ // renders them in insertion order even if the API timestamps collide.
+ const timeOffset = ++messageIndex * 200;
+
+ await Promise.allSettled([
+ Resources.devices.deleteDeviceData(config.deviceID, {
+ variables: config.validationVariable,
+ qty: 999,
+ end_date: now.minus({ minutes: 1 }).toJSDate(),
+ }),
+ Resources.devices.sendDeviceData(config.deviceID, {
+ variable: config.validationVariable,
+ value: message,
+ time: now.plus({ milliseconds: timeOffset }).toJSDate(),
+ metadata: {
+ type: level,
+ session_id: config.sessionID,
+ show_markdown: false,
+ },
+ }),
+ ]);
+
+ return message;
+ };
+}
+
+/**
+ * Sends an in-app notification to the Run User who triggered the
+ * Analysis. Falls back to a developer notification if no user can be
+ * identified — useful for edit/delete flows where the dashboard doesn't
+ * expose a Validation widget.
+ */
+async function sendNotificationFeedback(params: { environment: Record; title?: string; message: string }): Promise {
+ const { environment, title, message } = params;
+ const userID = environment?._user_id;
+
+ if (!userID) {
+ const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") });
+ await services.notification.send({ title: title || "Operation error", message });
+ return;
+ }
+
+ const user = await Resources.run.userInfo(userID).catch(() => null);
+ if (!user) {
+ const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") });
+ await services.notification.send({ title: title || "Operation error", message });
+ return;
+ }
+
+ await Resources.run.notificationCreate(userID, {
+ title: title || "Operation error",
+ message,
+ });
+}
+
+// ============================================================================
+// Helpers — resource lookups
+// ============================================================================
+
+/**
+ * Checks whether a device with the given name and/or tags already exists.
+ *
+ * @param isEdit - During an edit, the device being modified is itself
+ * returned by the search, so we only consider it a duplicate when more
+ * than one device matches. During a create, any match is a duplicate.
+ */
+async function deviceExists(params: { name?: string; tags: { key: string; value: string }[]; isEdit?: boolean }): Promise {
+ const { name, tags, isEdit = false } = params;
+
+ const found: { id: string }[] = [];
+ for (let page = 1; page < 9999; page++) {
+ const batch = await Resources.devices.list({
+ page,
+ amount: 100,
+ fields: ["id", "name", "tags"],
+ filter: { name, tags },
+ resolveBucketName: false,
+ });
+
+ found.push(...batch);
+ if (batch.length < 100) {
+ break;
+ }
+ }
+
+ if (isEdit) {
+ return found.length > 1;
+ }
+
+ return found.length > 0;
+}
+
+/**
+ * Finds a Dashboard ID by a tag value. Used to compose the URL that
+ * opens the sensor detail dashboard right after creation.
+ */
+async function getDashboardIDByTag(tagKey: string, tagValue: string): Promise {
+ const [dashboard] = await Resources.dashboards.list({
+ amount: 1,
+ fields: ["id", "tags"],
+ filter: { tags: [{ key: tagKey, value: tagValue }] },
+ });
+
+ if (!dashboard?.id) {
+ throw new Error(`Dashboard with ${tagKey}=${tagValue} not found`);
+ }
+
+ return dashboard.id;
+}
+
+// ============================================================================
+// Helpers — sensor summary
+// ============================================================================
+
+/**
+ * Recounts the sensors registered under a group and upserts the
+ * `device_connectivity_summary` data record on the group device. The
+ * Sensor Status cards on the Sensors dashboard read this record.
+ *
+ * The total is always recomputed from a fresh device list so concurrent
+ * create/delete runs do not drift the counter. If the record does not
+ * exist yet, it is created. If it exists, only `total_registered` is
+ * refreshed; `online` and `offline` are preserved.
+ */
+async function updateSensorSummary(groupID: string): Promise {
+ const sensors: { id: string }[] = [];
+ for (let page = 1; page < 9999; page++) {
+ const batch = await Resources.devices.list({
+ page,
+ amount: 100,
+ fields: ["id"],
+ filter: {
+ tags: [
+ { key: "group_id", value: groupID },
+ { key: "device_type", value: "device" },
+ ],
+ },
+ resolveBucketName: false,
+ });
+
+ sensors.push(...batch);
+ if (batch.length < 100) {
+ break;
+ }
+ }
+
+ const totalRegistered = sensors.length;
+
+ const [existing] = await Resources.devices.getDeviceData(groupID, {
+ variables: SUMMARY_VARIABLE,
+ qty: 1,
+ });
+
+ if (existing) {
+ await Resources.devices.editDeviceData(groupID, {
+ ...existing,
+ value: totalRegistered,
+ metadata: {
+ ...existing.metadata,
+ total_registered: totalRegistered,
+ },
+ });
+ return;
+ }
+
+ await Resources.devices.sendDeviceData(groupID, {
+ variable: SUMMARY_VARIABLE,
+ value: totalRegistered,
+ metadata: {
+ total_registered: totalRegistered,
+ online: undefined,
+ offline: undefined,
+ },
+ });
+}
+
+// ============================================================================
+// CREATE flow
+// ============================================================================
+
+/**
+ * Reads the form fields sent by the dashboard from the scope and runs
+ * them through the Zod schema.
+ */
+function extractCreateFormFields(scope: Data[]) {
+ const newSensorName = scope.find((item: Data) => item.variable === "new_sensor_name")?.value;
+ const newSensorEui = scope.find((item: Data) => item.variable === "new_sensor_eui")?.value;
+ const newSensorNetwork = scope.find((item: Data) => item.variable === "new_sensor_network")?.value;
+ const newSensorConnector = scope.find((item: Data) => item.variable === "new_sensor_connector")?.value;
+
+ return sensorModel.parseAsync({
+ name: newSensorName,
+ eui: newSensorEui,
+ network: newSensorNetwork,
+ connector: newSensorConnector,
+ });
+}
+
+/**
+ * Creates the sensor device on TagoIO. The device is created with the
+ * parent-org, group, eui and type tags already attached. A second edit
+ * adds the `sensor_id` tag pointing to its own id, since the id is only
+ * known after creation.
+ */
+interface InstallSensorParams {
+ name: string;
+ eui: string;
+ network: string;
+ connector: string;
+ organizationID: string;
+ groupID: string;
+}
+
+async function installSensorDevice(params: InstallSensorParams): Promise {
+ const tags: TagsObj[] = [
+ { key: "organization_id", value: params.organizationID },
+ { key: "group_id", value: params.groupID },
+ { key: "device_eui", value: params.eui },
+ { key: "device_type", value: "device" },
+ { key: "sensor_type", value: "freezer" },
+ ];
+
+ const deviceData: DeviceCreateInfo = {
+ name: params.name,
+ type: "immutable",
+ chunk_period: "month",
+ chunk_retention: 1,
+ serie_number: params.eui,
+ network: params.network,
+ connector: params.connector,
+ tags,
+ };
+
+ const newDevice = await Resources.devices.create(deviceData);
+
+ const newTags = tags.concat({ key: "sensor_id", value: newDevice.device_id });
+ await Resources.devices.edit(newDevice.device_id, { tags: newTags });
+
+ return newDevice.device_id;
+}
+
+/**
+ * Handles the "create-sensor" Input Form submission.
+ *
+ * Steps:
+ * 1. Confirm the scope is a Data array sent by the form.
+ * 2. Read the session id so validation messages reach the right user.
+ * 3. Validate form fields with Zod; surface the first issue if any.
+ * 4. Reject duplicate sensor names within the parent group.
+ * 5. Reject duplicate EUIs across the whole application.
+ * 6. Create the device, tag it, and store its params (URL + EUI).
+ * 7. Refresh the group's `device_connectivity_summary` record.
+ * 8. Send a success message back to the dashboard.
+ */
+async function createSensor({ environment, scope }: RouterConstructor & { scope: Data[] }) {
+ if (!("variable" in scope[0])) {
+ console.error("Not a valid TagoIO Data");
+ return;
+ }
+
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ throw "[Error] Missing config_id environment variable.";
+ }
+
+ // The form is rendered on the per-group Sensors dashboard, so the
+ // group id is the device id stored in `scope[0].device`.
+ const groupID = scope[0].device;
+ if (!groupID) {
+ throw "[Error] Missing group ID in scope.";
+ }
+
+ const sessionID = z.string().parse(scope.find((item: Data) => item.variable === "create_sensor_session_id")?.value);
+ const validate = initializeValidation({ validationVariable: "create_sensor_validation", deviceID: configDevID, sessionID });
+
+ // Friendly "working on it" message now that validation passed.
+ await validate("Adding sensor, please wait...", "warning").catch(console.log);
+
+ // Validate the form. If Zod fails, surface the first issue to the user
+ // and abort the run.
+ const formFields = await extractCreateFormFields(scope)
+ .catch(getZodErrorMessage)
+ .catch(async (error: Error) => {
+ await validate(error.message, "danger");
+ throw error;
+ });
+
+ // Reject duplicate names inside the same group.
+ const isNameInUse = await deviceExists({
+ name: formFields.name,
+ tags: [
+ { key: "group_id", value: groupID },
+ { key: "device_type", value: "device" },
+ ],
+ });
+
+ if (isNameInUse) {
+ throw await validate(
+ `A sensor with name ${formFields.name} already exists within this group.`,
+ "danger",
+ );
+ }
+
+ // Reject duplicate EUIs anywhere in the application.
+ const isEuiInUse = await deviceExists({
+ tags: [{ key: "device_eui", value: formFields.eui }],
+ });
+
+ if (isEuiInUse) {
+ throw await validate(`A sensor with EUI ${formFields.eui} already exists.`, "danger");
+ }
+
+ // The organization id lives on the parent group device's tags. We
+ // need it so the new sensor inherits the same tenant scope.
+ const groupInfo = await Resources.devices.info(groupID);
+ const organizationID = z.string().parse(groupInfo.tags.find((x) => x.key === "organization_id")?.value);
+
+ const sensorID = await installSensorDevice({
+ name: formFields.name,
+ eui: formFields.eui,
+ network: formFields.network,
+ connector: formFields.connector,
+ organizationID,
+ groupID,
+ });
+
+ // Build the URL that opens the sensor detail dashboard and store it
+ // (plus the EUI) as device params for the front-end.
+ const dashboardID = await getDashboardIDByTag(SENSOR_DASHBOARD_TAG_KEY, SENSOR_DASHBOARD_TAG_VALUE);
+ const dashboardURL = `/dashboards/info/${dashboardID}?settings=${configDevID}&sensor_dev=${sensorID}`;
+
+ // The simulator supports two types of freezers, each sending different data values.
+ // By default, it uses type 1, so we randomly assign a different type to ensure
+ // data variation across the sensors.
+ const freezerType = Math.random() < 0.5 ? "1" : "2";
+
+ await Resources.devices.paramSet(sensorID, [
+ { key: "dashboard_url", value: dashboardURL, sent: true },
+ { key: "sensor_eui", value: formFields.eui, sent: true },
+ { key: "freezer", value: freezerType, sent: true },
+ ]);
+
+ // Refresh the group's connectivity summary so the Sensor Status cards
+ // reflect the new total.
+ await updateSensorSummary(groupID);
+
+ await validate(`Sensor ${formFields.name} successfully added!`, "success");
+}
+
+// ============================================================================
+// EDIT flow
+// ============================================================================
+
+/**
+ * Restores a sensor device to its previous state when an edit fails
+ * validation. The Device List widget includes the previous values under
+ * `scope[0].old`, so we use that snapshot to roll back the change.
+ *
+ * Sensor edits only touch the `name` field today, but the loop is kept
+ * generic so future params can be added without rewriting the rollback.
+ */
+async function undoSensorChanges(sensorID: string, scope: DeviceListScope[]): Promise {
+ const deviceScope = scope[0];
+ const oldValues = deviceScope?.old ?? {};
+
+ for (const key of Object.keys(deviceScope)) {
+ if (key === "name" && typeof oldValues[key] === "string") {
+ await Resources.devices.edit(sensorID, { name: oldValues[key] as string });
+ continue;
+ }
+
+ if (key.startsWith("param.")) {
+ const paramKey = key.replace("param.", "");
+ const oldValue = oldValues[key] as string | undefined;
+ if (oldValue === undefined) {
+ continue;
+ }
+
+ const paramList = await Resources.devices.paramList(sensorID);
+ const existing = paramList.find((p) => p.key === paramKey);
+ await Resources.devices.paramSet(sensorID, {
+ id: existing?.id,
+ key: paramKey,
+ value: oldValue,
+ sent: true,
+ });
+ }
+ }
+}
+
+/**
+ * Handles the "edit-sensor" Custom Button on the Device List widget.
+ *
+ * The Device List sends both the new and the old value for each edited
+ * field. We validate the new values, and on any failure we restore the
+ * old ones and notify the user.
+ */
+async function editSensor({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) {
+ const sensorID = scope[0].device;
+ if (!sensorID) {
+ throw "[Error] Missing sensor ID in scope.";
+ }
+
+ const newName = scope[0]?.name;
+
+ // Validate the partial payload. If Zod rejects it, undo the change and
+ // notify the user — then bubble the error up so the run is logged.
+ await sensorEditModel
+ .parseAsync({ name: newName })
+ .catch(getZodErrorMessage)
+ .catch(async (error: Error) => {
+ await undoSensorChanges(sensorID, scope);
+ await sendNotificationFeedback({ environment, message: error.message });
+ throw error;
+ });
+
+ // If the name changed, make sure no other sensor inside this group is
+ // using it. We read the parent group from the sensor's tags.
+ if (newName) {
+ const sensorInfo = await Resources.devices.info(sensorID);
+ const groupID = z
+ .string()
+ .parse(sensorInfo.tags.find((tag) => tag.key === "group_id")?.value);
+
+ const isNameInUse = await deviceExists({
+ name: newName,
+ tags: [
+ { key: "group_id", value: groupID },
+ { key: "device_type", value: "device" },
+ ],
+ isEdit: true,
+ });
+
+ if (isNameInUse) {
+ await undoSensorChanges(sensorID, scope);
+ await sendNotificationFeedback({
+ environment,
+ message: `A sensor with name ${newName} already exists within this group.`,
+ });
+ throw `A sensor with name ${newName} already exists within this group.`;
+ }
+ }
+}
+
+// ============================================================================
+// DELETE flow
+// ============================================================================
+
+/**
+ * Handles the "delete-sensor" identifier on the Device List widget.
+ *
+ * Steps:
+ * 1. Clean any rows the dashboard wrote to the config device keyed
+ * to this sensor.
+ * 2. Capture the sensor info (name, organization id, group id) before
+ * the device is removed.
+ * 3. Delete the sensor device.
+ * 4. Clean any rows keyed to this sensor on the parent organization
+ * and group devices. Without these the org and group dashboards
+ * keep showing the removed sensor's rows.
+ * 5. Refresh the group's `device_connectivity_summary` record.
+ * 6. Notify the user.
+ */
+async function deleteSensor({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) {
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ throw "[Error] Missing config_id environment variable.";
+ }
+
+ const sensorID = scope[0].device;
+ if (!sensorID) {
+ throw "[Error] Missing sensor ID in scope.";
+ }
+
+ await Resources.devices.deleteDeviceData(configDevID, { groups: sensorID, qty: 9999 }).catch(console.error);
+
+ const sensorInfo = await Resources.devices.info(sensorID);
+ const organizationID = z.string().parse(sensorInfo.tags.find((tag) => tag.key === "organization_id")?.value);
+ const groupID = z.string().parse(sensorInfo.tags.find((tag) => tag.key === "group_id")?.value);
+
+ await Resources.devices.delete(sensorID).catch(console.log);
+
+ await Resources.devices.deleteDeviceData(organizationID, { groups: sensorID, qty: 9999 }).catch(console.log);
+ await Resources.devices.deleteDeviceData(groupID, { groups: sensorID, qty: 9999 }).catch(console.log);
+
+ await updateSensorSummary(groupID);
+
+ await sendNotificationFeedback({
+ environment,
+ title: "Sensor removed",
+ message: `Sensor ${sensorInfo.name} successfully removed!`,
+ });
+}
+
+// ============================================================================
+// Router entrypoint
+// ============================================================================
+
+/**
+ * Entrypoint invoked by the TagoIO Analysis runtime. Reads the scope and
+ * environment, sets up the router, and dispatches to the matching CRUD
+ * handler.
+ */
+async function startAnalysis(context: TagoContext, scope: Data[]): Promise {
+ console.log("Running CRUD Sensor Analysis");
+ console.log("Scope:", scope);
+
+ const environment = Utils.envToJson(context.environment);
+ if (!environment.config_id) {
+ throw "Missing config_id environment variable";
+ }
+
+ const router = new Utils.AnalysisRouter({ scope, context, environment });
+
+ router.register(createSensor).whenInputFormID("create-sensor");
+ router.register(editSensor).whenCustomBtnID("edit-sensor");
+ router.register(deleteSensor).whenDeviceListIdentifier("delete-sensor");
+
+ const result = await router.exec();
+ console.log("Services found:", result.services);
+}
+
+// The Analysis runtime sets `T_TEST` during local tests so the handler is
+// not wired up automatically. In production the runtime sets
+// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below.
+if (!Deno.env.get("T_TEST")) {
+ Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") });
+}
+
+export { startAnalysis };
diff --git a/app/analysis/dashboard/crud-user.ts b/app/analysis/dashboard/crud-user.ts
new file mode 100644
index 0000000..cb95752
--- /dev/null
+++ b/app/analysis/dashboard/crud-user.ts
@@ -0,0 +1,591 @@
+/**
+ * CRUD User Analysis
+ *
+ * Educational single-file Analysis that handles the full lifecycle of a
+ * Run User in the TagoIO Kickstarter project: create, edit and delete.
+ * This file is intentionally self-contained — it has no relative imports.
+ *
+ * How it is triggered
+ * -------------------
+ * A dashboard sends Data points to this Analysis. The Analysis Router from
+ * `@tago-io/sdk` inspects the scope and runs the matching handler:
+ *
+ * - Input Form "create-user" -> createUser
+ * - User List edit action "edit-user" -> editUser
+ * - User List delete action "delete-user" -> deleteUser
+ *
+ * Required environment variables
+ * ------------------------------
+ * - config_id : ID of the configuration device that stores the
+ * dashboard's per-organization data and is used to
+ * publish validation messages back to the UI.
+ * - SENDGRID_API_KEY : Secret used to send the invite email.
+ * - sendgrid_from_email : Verified sender address used by SendGrid.
+ * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime.
+ *
+ * NOTE
+ * ----
+ * This file is optimized for clarity, not performance. The goal is for a
+ * developer new to TagoIO to read it top-to-bottom and understand every step.
+ */
+
+import { Analysis, type Data, type TagoContext, type TagsObj, type UserListScope } from "npm:@tago-io/sdk";
+import { Resources, type RouterConstructor, Services, Utils } from "npm:@tago-io/sdk";
+import { DateTime } from "npm:luxon";
+import { phone as parsePhone } from "npm:phone";
+import z, { ZodError } from "npm:zod";
+
+// ============================================================================
+// Validation schema
+// ============================================================================
+
+/**
+ * Allowed access levels. Each maps to a different Access Policy on TagoIO
+ * Run, which controls which dashboards, devices and entities the user can
+ * see.
+ */
+const userAccessModel = z.enum(["admin", "org_admin", "guest"], { error: "Invalid user access level" });
+
+const userModel = z.object({
+ name: z
+ .string({ error: "Name is required" })
+ .min(3, { message: "Name must be at least 3 characters long" })
+ .max(40, { message: "Name must be less than 40 characters long" }),
+ email: z.email({ message: "Invalid email address" }),
+ // Phone is optional. When present, it must start with the country code
+ // and be a valid international number. The transform normalizes the
+ // value into the canonical `+CCNNN...` shape returned by `phone`.
+ phone: z.preprocess(
+ (x) => (x === undefined || x === null || x === "" ? undefined : String(x)),
+ z
+ .string()
+ .refine((x) => x.startsWith("+"), { message: "Phone number should have the country code, e.g. +1 for US" })
+ .refine((x) => parsePhone(x).isValid, { message: "Invalid phone number" })
+ .transform((val) => parsePhone(val).phoneNumber ?? val)
+ .optional(),
+ ),
+ access: userAccessModel,
+});
+
+/** Partial schema reused by the Edit flow. */
+const userEditModel = userModel.partial();
+
+// ============================================================================
+// Helpers — error handling
+// ============================================================================
+
+/**
+ * Extracts a short, human-readable message from a Zod or generic error and
+ * re-throws it as a plain `Error`. Only the first Zod issue is surfaced
+ * to the user.
+ */
+function getZodErrorMessage(error: unknown): never {
+ if (error instanceof ZodError) {
+ const message = error.issues[0]?.message ?? "Validation error";
+ throw new Error(message);
+ }
+
+ if (error instanceof Error) {
+ throw error;
+ }
+
+ throw new Error("Unknown error occurred");
+}
+
+// ============================================================================
+// Helpers — feedback to the dashboard
+// ============================================================================
+
+type ValidationLevel = "success" | "danger" | "warning";
+
+interface ValidationConfig {
+ validationVariable: string;
+ deviceID: string;
+ sessionID?: string;
+}
+
+/**
+ * Creates a `validate(message, level)` function tied to a specific
+ * validation variable on the configuration device. The dashboard listens
+ * to this variable through a Validation widget and renders the messages
+ * to the user.
+ *
+ * Each call cleans up old validation entries (>1 minute) and writes a new
+ * one with a small timestamp offset, so multiple messages from the same
+ * run still appear in the correct order on the dashboard.
+ */
+function initializeValidation(config: ValidationConfig) {
+ let messageIndex = 0;
+
+ return async (message: string, level: ValidationLevel = "success"): Promise => {
+ if (!message?.trim()) {
+ throw new Error("Validation message cannot be empty");
+ }
+
+ const now = DateTime.now();
+ // Each subsequent message is pushed 200ms forward so the dashboard
+ // renders them in insertion order even if the API timestamps collide.
+ const timeOffset = ++messageIndex * 200;
+
+ await Promise.allSettled([
+ Resources.devices.deleteDeviceData(config.deviceID, {
+ variables: config.validationVariable,
+ qty: 999,
+ end_date: now.minus({ minutes: 1 }).toJSDate(),
+ }),
+ Resources.devices.sendDeviceData(config.deviceID, {
+ variable: config.validationVariable,
+ value: message,
+ time: now.plus({ milliseconds: timeOffset }).toJSDate(),
+ metadata: {
+ type: level,
+ session_id: config.sessionID,
+ show_markdown: false,
+ },
+ }),
+ ]);
+
+ return message;
+ };
+}
+
+/**
+ * Sends an in-app notification to the Run User who triggered the
+ * Analysis. Falls back to a developer notification if no user can be
+ * identified — useful for edit/delete flows where the dashboard doesn't
+ * expose a Validation widget.
+ */
+async function sendNotificationFeedback(params: { environment: Record; title?: string; message: string }): Promise {
+ const { environment, title, message } = params;
+ const userID = environment?._user_id;
+
+ if (!userID) {
+ const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") });
+ await services.notification.send({ title: title || "Operation error", message });
+ return;
+ }
+
+ const user = await Resources.run.userInfo(userID).catch(() => null);
+ if (!user) {
+ const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") });
+ await services.notification.send({ title: title || "Operation error", message });
+ return;
+ }
+
+ await Resources.run.notificationCreate(userID, {
+ title: title || "Operation error",
+ message,
+ });
+}
+
+// ============================================================================
+// Helpers — invite flow
+// ============================================================================
+
+/**
+ * Generates a random temporary password used as the initial credential
+ * for the invited user. The password always includes at least one
+ * uppercase letter, lowercase letter, digit and special character.
+ */
+function generatePassword(): string {
+ const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ const lower = "abcdefghijklmnopqrstuvwxyz";
+ const digits = "0123456789";
+ const symbols = "@!&";
+ const all = upper + lower + digits + symbols;
+
+ const randomIndex = (max: number): number => {
+ const bytes = new Uint32Array(1);
+ const limit = Math.floor(0x100000000 / max) * max;
+ let value = 0;
+ do {
+ globalThis.crypto.getRandomValues(bytes);
+ value = bytes[0];
+ } while (value >= limit);
+ return value % max;
+ };
+ const pick = (chars: string) => chars[randomIndex(chars.length)];
+
+ const out = Array.from({ length: 12 }, () => pick(all));
+ // Force the first four characters to one of each pool so the password
+ // satisfies a typical "at least one of each" policy.
+ out[0] = pick(upper);
+ out[1] = pick(lower);
+ out[2] = pick(digits);
+ out[3] = pick(symbols);
+
+ return out.join("");
+}
+
+interface InviteEmailParams {
+ context: TagoContext;
+ toEmail: string;
+ name: string;
+ password: string;
+ runURL: string;
+}
+
+/**
+ * Sends the welcome email with the temporary password through SendGrid.
+ * The template `user_registration` must already exist in the SendGrid
+ * account and accept `name`, `email`, `password` and `url_platform`
+ * params.
+ */
+async function sendInviteEmail({ context, toEmail, name, password, runURL }: InviteEmailParams): Promise {
+ const environment = Utils.envToJson(context.environment);
+ const sendgrid = new Services({ token: context.token }).sendgrid;
+
+ await sendgrid
+ .send({
+ from: environment.sendgrid_from_email,
+ to: [toEmail],
+ template: {
+ name: "user_registration",
+ params: {
+ name,
+ email: toEmail,
+ password,
+ url_platform: runURL,
+ },
+ },
+ sendgrid_api_key: environment.SENDGRID_API_KEY,
+ })
+ .catch((error) => {
+ console.error("Email sending failed:", error);
+ });
+}
+
+interface InviteUserParams {
+ context: TagoContext;
+ email: string;
+ name: string;
+ phone?: string;
+ tags: TagsObj[];
+ runURL: string;
+}
+
+/**
+ * Invites a Run User: sends the welcome email, then creates the account
+ * with the temporary password. If creation fails because the user
+ * already exists, the existing user's tags are merged with the new ones
+ * so the same email can be re-invited into another organization.
+ */
+async function inviteUser({ context, email, name, phone, tags, runURL }: InviteUserParams): Promise {
+ const normalizedEmail = email.toLowerCase();
+ const password = generatePassword();
+
+ const accountInfo = await Resources.account.info();
+ const timezone = accountInfo.timezone || "America/New_York";
+
+ await sendInviteEmail({ context, toEmail: normalizedEmail, name, password, runURL });
+
+ const userPayload = {
+ active: true,
+ company: "",
+ email: normalizedEmail,
+ language: "en",
+ name,
+ phone: String(phone ?? ""),
+ tags,
+ timezone,
+ password,
+ };
+
+ const created = await Resources.run.userCreate(userPayload).catch((error) => {
+ console.error("User creation failed:", error);
+ return null;
+ });
+
+ if (created?.user) {
+ return created.user;
+ }
+
+ // Fallback: the user already exists. Merge the new tags into the
+ // existing user so the invite still grants access to the new tenant.
+ const existing = (await Resources.run.listUsers({
+ amount: 1,
+ fields: ["id", "tags"],
+ filter: { email: normalizedEmail },
+ }))[0];
+
+ if (!existing) {
+ throw new Error("Failed to create or update user account");
+ }
+
+ const keptTags = (existing.tags ?? []).filter((tag) => !tags.some((next) => next.key === tag.key));
+ await Resources.run.userEdit(existing.id, { tags: [...keptTags, ...tags] });
+ return existing.id;
+}
+
+// ============================================================================
+// CREATE flow
+// ============================================================================
+
+/**
+ * Reads the form fields sent by the dashboard from the scope and runs
+ * them through the Zod schema.
+ */
+function extractCreateFormFields(scope: Data[]) {
+ const newUserName = scope.find((item: Data) => item.variable === "new_user_name")?.value;
+ const newUserEmail = scope.find((item: Data) => item.variable === "new_user_email")?.value;
+ const newUserPhone = scope.find((item: Data) => item.variable === "new_user_phone")?.value;
+ const newUserAccess = scope.find((item: Data) => item.variable === "new_user_access")?.value;
+
+ return userModel.parseAsync({
+ name: newUserName,
+ email: newUserEmail,
+ phone: newUserPhone,
+ access: newUserAccess,
+ });
+}
+
+/**
+ * Handles the "create-user" Input Form submission.
+ *
+ * Steps:
+ * 1. Confirm the scope is a Data array sent by the form.
+ * 2. Read the session id so validation messages reach the right user.
+ * 3. Validate form fields with Zod; surface the first issue if any.
+ * 4. Reject duplicate email addresses.
+ * 5. Build the access tags. Admins are application-wide and have no
+ * organization scope; org_admin and guest are scoped to the parent
+ * organization.
+ * 6. Send the invite email and create (or update) the Run User.
+ * 7. Send a success message back to the dashboard.
+ */
+async function createUser({ context, environment, scope }: RouterConstructor & { scope: Data[] }) {
+ if (!("variable" in scope[0])) {
+ console.error("Not a valid TagoIO Data");
+ return;
+ }
+
+ if (!context) {
+ throw "[Error] Missing analysis context.";
+ }
+
+ if (!environment.SENDGRID_API_KEY || !environment.sendgrid_from_email) {
+ throw "[Error] Missing secrets 'SENDGRID_API_KEY' or 'sendgrid_from_email'.";
+ }
+
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ throw "[Error] Missing config_id environment variable.";
+ }
+
+ // The form is rendered on the per-organization Users dashboard, so
+ // the organization id is the device id stored in `scope[0].device`.
+ const organizationID = scope[0].device;
+ if (!organizationID) {
+ throw "[Error] Missing organization ID in scope.";
+ }
+
+ const sessionID = z.string().parse(scope.find((item: Data) => item.variable === "create_user_session_id")?.value);
+ const validate = initializeValidation({ validationVariable: "create_user_validation", deviceID: configDevID, sessionID });
+
+ // Friendly "working on it" message now that we have the session id.
+ await validate("Adding user, please wait...", "warning").catch(console.log);
+
+ // Validate the form. If Zod fails, surface the first issue to the user
+ // and abort the run.
+ const formFields = await extractCreateFormFields(scope)
+ .catch(getZodErrorMessage)
+ .catch(async (error: Error) => {
+ await validate(error.message, "danger");
+ throw error;
+ });
+
+ // Reject duplicate email addresses anywhere in the Run.
+ const existingUsers = await Resources.run.listUsers({
+ amount: 1,
+ fields: ["id"],
+ filter: { email: formFields.email.toLowerCase() },
+ });
+ if (existingUsers.length > 0) {
+ throw await validate("This email address is already in use.", "danger");
+ }
+
+ // Build the tag list. Application admins span every organization so
+ // they don't get an `organization_id` tag; the other levels are
+ // scoped to the organization that triggered the form.
+ const tags: TagsObj[] = [
+ { key: "access", value: formFields.access },
+ { key: "visualize_user", value: "true" },
+ ];
+ if (formFields.access !== "admin") {
+ tags.push({ key: "user_organization_id", value: organizationID });
+ tags.push({ key: "organization_id", value: organizationID });
+ }
+
+ // The invite email links back to the same Run instance the user is
+ // signing up to, so pull the URL from the Run info endpoint.
+ const { url: runURL } = await Resources.run.info();
+
+ await inviteUser({
+ context,
+ email: formFields.email,
+ name: formFields.name,
+ phone: formFields.phone,
+ tags,
+ runURL,
+ }).catch(async (error: unknown) => {
+ const message = error instanceof Error ? error.message : String(error);
+ throw await validate(message, "danger");
+ });
+
+ await validate(`User ${formFields.name} successfully added!`, "success");
+}
+
+// ============================================================================
+// EDIT flow
+// ============================================================================
+
+/**
+ * Restores a user to its previous state when an edit fails validation.
+ * The User List widget includes the previous values under `scope[0].old`,
+ * so we use that snapshot to roll back the change.
+ *
+ * The `tags.access` key needs special handling: tags are an array on
+ * TagoIO, so the rollback merges the previous access value into the
+ * existing tag set instead of overwriting all tags.
+ */
+async function undoUserChanges(scope: UserListScope[]): Promise {
+ const userID = scope[0].user;
+ const old = scope[0]?.old ?? {};
+
+ const updates: { name?: string; phone?: string } = {};
+ if (typeof old.name === "string") {
+ updates.name = old.name;
+ }
+ if (typeof old.phone === "string") {
+ updates.phone = old.phone;
+ }
+ if (Object.keys(updates).length > 0) {
+ await Resources.run.userEdit(userID, updates).catch(console.error);
+ }
+
+ const oldAccess = old["tags.access"];
+ if (oldAccess !== undefined) {
+ const userInfo = await Resources.run.userInfo(userID);
+ const mergedTags: TagsObj[] = (userInfo.tags ?? []).map((tag) => tag.key === "access" ? { key: "access", value: String(oldAccess) } : tag);
+ await Resources.run.userEdit(userID, { tags: mergedTags }).catch(console.error);
+ }
+}
+
+/**
+ * Handles the "edit-user" action on the User List widget.
+ *
+ * The User List sends both the new and the old value for each edited
+ * field. We validate the new values; on any failure we restore the old
+ * ones and notify the user. The `access` field is stored as a tag, so
+ * updates have to merge it back into the existing tag array.
+ */
+async function editUser({ scope, environment }: RouterConstructor & { scope: UserListScope[] }) {
+ const userID = scope[0]?.user;
+ if (!userID) {
+ throw "[Error] Missing user ID in scope.";
+ }
+
+ const newName = scope[0]?.name;
+ const newPhone = scope[0]?.phone || undefined;
+ const newAccess = scope[0]?.["tags.access"];
+
+ await userEditModel
+ .parseAsync({ name: newName, phone: newPhone, access: newAccess })
+ .catch(getZodErrorMessage)
+ .catch(async (error: Error) => {
+ await undoUserChanges(scope);
+ await sendNotificationFeedback({ environment, message: error.message });
+ throw error;
+ });
+
+ if (newAccess !== undefined) {
+ const userInfo = await Resources.run.userInfo(userID);
+ const existingTags: TagsObj[] = userInfo.tags ?? [];
+ const hasAccessTag = existingTags.some((tag) => tag.key === "access");
+
+ const mergedTags = hasAccessTag
+ ? existingTags.map((tag) => (tag.key === "access" ? { key: "access", value: String(newAccess) } : tag))
+ : [...existingTags, { key: "access", value: String(newAccess) }];
+
+ await Resources.run.userEdit(userID, { tags: mergedTags });
+ }
+}
+
+// ============================================================================
+// DELETE flow
+// ============================================================================
+
+/**
+ * Handles the "delete-user" identifier on the User List widget.
+ *
+ * Steps:
+ * 1. Capture the user info (email) before the account is removed, so
+ * we can include it in the success notification.
+ * 2. Clean any rows the dashboard wrote to the config device keyed to
+ * this user. Without this, ghost rows would remain on widgets that
+ * group data by `user_id`.
+ * 3. Delete the Run User.
+ * 4. Notify the operator.
+ */
+async function deleteUser({ scope, environment }: RouterConstructor & { scope: UserListScope[] }) {
+ const userID = scope[0]?.user;
+ if (!userID) {
+ throw "[Error] Missing user ID in scope.";
+ }
+
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ throw "[Error] Missing config_id environment variable.";
+ }
+
+ const userInfo = await Resources.run.userInfo(userID);
+
+ await Resources.devices
+ .deleteDeviceData(configDevID, { groups: userID, qty: 9999 })
+ .catch(console.error);
+
+ await Resources.run.userDelete(userID);
+
+ await sendNotificationFeedback({
+ environment,
+ title: "User removed",
+ message: `User ${userInfo.email} successfully removed!`,
+ });
+}
+
+// ============================================================================
+// Router entrypoint
+// ============================================================================
+
+/**
+ * Entrypoint invoked by the TagoIO Analysis runtime. Reads the scope and
+ * environment, sets up the router, and dispatches to the matching CRUD
+ * handler.
+ */
+async function startAnalysis(context: TagoContext, scope: Data[]): Promise {
+ console.log("Running CRUD User Analysis");
+ console.log("Scope:", scope);
+
+ const environment = Utils.envToJson(context.environment);
+ if (!environment.config_id) {
+ throw "Missing config_id environment variable";
+ }
+
+ const router = new Utils.AnalysisRouter({ scope, context, environment });
+
+ router.register(createUser).whenInputFormID("create-user");
+ router.register(editUser).whenUserListIdentifier("edit-user");
+ router.register(deleteUser).whenUserListIdentifier("delete-user");
+
+ const result = await router.exec();
+ console.log("Services found:", result.services);
+}
+
+// The Analysis runtime sets `T_TEST` during local tests so the handler is
+// not wired up automatically. In production the runtime sets
+// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below.
+if (!Deno.env.get("T_TEST")) {
+ Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") });
+}
+
+export { startAnalysis };
diff --git a/app/analysis/scheduled/check-inactive-sensors.ts b/app/analysis/scheduled/check-inactive-sensors.ts
new file mode 100644
index 0000000..4409097
--- /dev/null
+++ b/app/analysis/scheduled/check-inactive-sensors.ts
@@ -0,0 +1,658 @@
+/**
+ * Check Inactive Sensors Analysis
+ *
+ * Educational single-file Analysis that scans every sensor in the
+ * application, marks the ones that have not reported within the configured
+ * threshold as offline, and refreshes the connectivity summary used by the
+ * Sensors dashboard. This file is intentionally self-contained — it has no
+ * relative imports.
+ *
+ * How it is triggered
+ * -------------------
+ * By a TagoIO Scheduled Action (cron). The recommended cadence is hourly:
+ * the inactivity threshold itself is configured in hours, so running more
+ * often just creates extra load without changing the outcome.
+ *
+ * Execution model
+ * ---------------
+ * The hot path is "no sensor changed state". We exploit that by short-
+ * circuiting: after fetching the sensor list we ask whether any sensor is
+ * even capable of producing a notification or recovery transition this
+ * run. The cheapest sufficient condition is "(sensor's last_input is older
+ * than one hour) OR (sensor already carries the inactivity_notified tag)"
+ * — because the smallest configurable threshold is one hour, and recovery
+ * only matters when the notified tag is present. When no sensor matches
+ * that, we skip loading the global config AND the per-org Checkin rules
+ * entirely and only refresh `last_uplink` plus the connectivity summary.
+ *
+ * Per-organization Checkin rules are now loaded lazily and cached per
+ * organization id during a single execution. An organization without any
+ * Checkin rule is cached as an empty array so we never re-query it.
+ *
+ * What it reads
+ * -------------
+ * From the configuration device (`environment.config_id`), under the
+ * `global-inactivity` group — used as a fallback when no per-organization
+ * Checkin rule applies:
+ * - `global_alert_value` (number, e.g. `2`) — how many units of time
+ * without uplink mean "inactive".
+ * - `global_alert_value` `unit` field — must be `"hour"` today.
+ * - `global_alert_message` (string) — text used in the in-app
+ * notification sent to organization users. Optional; falls back to
+ * `"Device inactivity detected"`.
+ *
+ * From each organization device (tag `device_type=organization`) WHEN
+ * one of its sensors is a candidate this run:
+ * - The Checkin alert rows persisted by `crud-alert.ts`. Each rule
+ * overrides the global threshold and recipients for the sensors it
+ * covers (or for every sensor in the org when `all_sensors` was
+ * selected at creation time).
+ *
+ * What it writes
+ * --------------
+ * - On every sensor: the `last_uplink` device parameter, set to the
+ * integer number of hours since the last uplink. The Sensor List
+ * widget reads this to render the `Last seen(h)` column.
+ * - On every group device that owns at least one sensor: the
+ * `device_connectivity_summary` row, with `online` and `offline`
+ * counts inside `metadata`. `total_registered` is owned by the
+ * sensor CRUD analysis and preserved on edit.
+ * - On the sensors that just went inactive: the tag
+ * `inactivity_notified=true`. This is what stops the next run from
+ * re-notifying the same sensor every hour.
+ * - To the users tagged with the sensor's `organization_id`: an in-app
+ * notification announcing the inactivity.
+ *
+ * Required environment variables
+ * ------------------------------
+ * - config_id : ID of the configuration device that stores the
+ * inactivity threshold and alert message.
+ * - T_ANALYSIS_TOKEN : provided automatically by the TagoIO runtime.
+ */
+
+import { Analysis, type Data, type DeviceListItem, type DeviceQuery, Resources, type TagoContext, type TagsObj, type UserInfo, type UserQuery, Utils } from "npm:@tago-io/sdk";
+import z from "npm:zod";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const CONFIG_GROUP = "global-inactivity";
+const SUMMARY_VARIABLE = "device_connectivity_summary";
+const NOTIFIED_TAG = "inactivity_notified";
+const LAST_UPLINK_PARAM = "last_uplink";
+const DEFAULT_ALERT_MESSAGE = "Device inactivity detected";
+const NOTIFICATION_TITLE = "Device inactivity";
+
+const ALERT_VAR_MODEL = "alert_management_type";
+const ALERT_VAR_DEVICES = "alert_management_devices";
+const ALERT_VAR_VALUE = "alert_management_value";
+const ALERT_VAR_SEND_TO = "alert_management_users";
+const ALERT_VAR_MESSAGE = "alert_management_message";
+
+const CONFIG_VAR_VALUE = "global_alert_value";
+const CONFIG_VAR_MESSAGE = "global_alert_message";
+
+const MS_PER_HOUR = 60 * 60 * 1000;
+
+/**
+ * Only `hour` is supported today. To accept more units (minute, day, ...)
+ * extend BOTH this map AND the `unit` enum inside `thresholdModel` below.
+ */
+const UNIT_MS: Record = {
+ hour: 60 * 60 * 1000,
+};
+
+// ============================================================================
+// Validation schema
+// ============================================================================
+
+const thresholdModel = z.object({
+ value: z.union([z.number(), z.string()]).transform((rawValue, ctx) => {
+ const parsed = typeof rawValue === "number" ? rawValue : Number(rawValue);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ ctx.addIssue({ code: "custom", message: "global_alert_value must be a positive number" });
+ return z.NEVER;
+ }
+ return parsed;
+ }),
+ unit: z.string().trim().toLowerCase().pipe(z.enum(["hour"], { error: "unit must be 'hour'" })),
+});
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface InactivityConfig {
+ thresholdMs: number;
+ alertMessage: string;
+}
+
+interface SensorBucket {
+ online: number;
+ offline: number;
+}
+
+type SensorRecord = Pick;
+type UserRecord = Pick;
+
+interface OrgCheckinRule {
+ organizationID: string;
+ thresholdMs: number;
+ sensorIDs: Set | "all";
+ recipientIDs: string[];
+ message: string;
+}
+
+interface NewlyInactive {
+ sensor: SensorRecord;
+ rule: OrgCheckinRule | null;
+}
+
+// Lazy per-organization cache built during a single execution. An empty
+// array means "already checked, no Checkin rules" — that's what stops us
+// from re-querying the same org twice. Use `cache.has(orgID)` to tell
+// "not yet queried" from "queried and empty".
+type RuleCache = Map;
+
+// ============================================================================
+// Helpers — paginated list fetchers
+// ============================================================================
+
+async function fetchDeviceList(filter: DeviceQuery["filter"]): Promise {
+ const FIELDS: (keyof DeviceListItem)[] = ["id", "name", "tags", "last_input", "created_at"];
+ const PAGE_SIZE = 100;
+ const MAX_PAGES = 9999;
+
+ const devices: SensorRecord[] = [];
+
+ for (let page = 1; page < MAX_PAGES; page++) {
+ const batch = await Resources.devices.list({
+ page,
+ fields: FIELDS,
+ filter,
+ resolveBucketName: false,
+ amount: PAGE_SIZE,
+ });
+
+ devices.push(...(batch as SensorRecord[]));
+
+ if (batch.length < PAGE_SIZE) {
+ break;
+ }
+ }
+
+ return devices;
+}
+
+async function fetchUserList(filter: UserQuery["filter"]): Promise {
+ const FIELDS: (keyof UserInfo)[] = ["id", "name", "phone", "company", "tags", "active", "email", "timezone"];
+ const PAGE_SIZE = 40;
+ const MAX_PAGES = 9999;
+
+ const users: UserRecord[] = [];
+
+ for (let page = 1; page < MAX_PAGES; page++) {
+ const batch = await Resources.run.listUsers({
+ page,
+ fields: FIELDS,
+ filter,
+ amount: PAGE_SIZE,
+ });
+
+ users.push(...(batch as UserRecord[]));
+
+ if (batch.length < PAGE_SIZE) {
+ break;
+ }
+ }
+
+ return users;
+}
+
+// ============================================================================
+// Helpers — config loading
+// ============================================================================
+
+/**
+ * Reads the inactivity threshold and alert message from the config device
+ * in a single getDeviceData call (variables accepts an array).
+ */
+async function loadInactivityConfig(configDevID: string): Promise {
+ // `qty` on getDeviceData is per-variable, so qty:1 returns at most one
+ // record per requested variable.
+ const records = await Resources.devices.getDeviceData(configDevID, {
+ variables: [CONFIG_VAR_VALUE, CONFIG_VAR_MESSAGE],
+ groups: CONFIG_GROUP,
+ qty: 1,
+ });
+
+ const valueRecord = records.find((record) => record.variable === CONFIG_VAR_VALUE);
+ if (!valueRecord) {
+ console.log("No global_alert_value found in config, skipping");
+ return null;
+ }
+
+ const parsed = await thresholdModel.safeParseAsync({
+ value: valueRecord.value,
+ unit: valueRecord.unit,
+ });
+
+ if (!parsed.success) {
+ const firstIssue = parsed.error.issues[0]?.message ?? "Validation error";
+ console.log(`Invalid global_alert_value/unit: ${firstIssue}`);
+ return null;
+ }
+
+ const messageRecord = records.find((record) => record.variable === CONFIG_VAR_MESSAGE);
+
+ return {
+ thresholdMs: parsed.data.value * UNIT_MS[parsed.data.unit],
+ alertMessage: String(messageRecord?.value ?? DEFAULT_ALERT_MESSAGE),
+ };
+}
+
+// ============================================================================
+// Helpers — per-organization Checkin alerts (lazy + cached)
+// ============================================================================
+
+/**
+ * Returns the Checkin rules for a single organization, using `cache` to
+ * avoid re-fetching the same org twice during one execution. When the org
+ * has no Checkin rules we still store an empty array so the next caller
+ * short-circuits without hitting the API.
+ */
+async function getRulesForOrganization(organizationID: string, cache: RuleCache): Promise {
+ const cached = cache.get(organizationID);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const rules: OrgCheckinRule[] = [];
+
+ const modelRecords = await Resources.devices.getDeviceData(organizationID, {
+ variables: ALERT_VAR_MODEL,
+ qty: 9999,
+ });
+ const checkinModelRecords = modelRecords.filter((record) => record.value === "inactivity");
+
+ for (const modelRecord of checkinModelRecords) {
+ const alertID = modelRecord.group;
+ if (!alertID) {
+ continue;
+ }
+
+ // Fetch all four alert fields in a single getDeviceData call by
+ // passing the variables array. Records come back unordered, so we
+ // pick them up by `record.variable`.
+ const alertRecords = await Resources.devices
+ .getDeviceData(organizationID, {
+ variables: [ALERT_VAR_DEVICES, ALERT_VAR_VALUE, ALERT_VAR_SEND_TO, ALERT_VAR_MESSAGE],
+ groups: alertID,
+ qty: 1,
+ })
+ .catch(() => []);
+
+ const devicesRecord = alertRecords.find((record) => record.variable === ALERT_VAR_DEVICES);
+ const valueRecord = alertRecords.find((record) => record.variable === ALERT_VAR_VALUE);
+ const recipientsRecord = alertRecords.find((record) => record.variable === ALERT_VAR_SEND_TO);
+ const messageRecord = alertRecords.find((record) => record.variable === ALERT_VAR_MESSAGE);
+
+ const inactivityHours = Number(valueRecord?.value);
+ if (!Number.isFinite(inactivityHours) || inactivityHours <= 0) {
+ console.log(`Alert ${alertID} on org ${organizationID} has invalid inactivity hours; skipping`);
+ continue;
+ }
+
+ const rawDevices = String(devicesRecord?.value ?? "").trim();
+ const sensorIDs: Set | "all" = rawDevices === "all_sensors" ? "all" : new Set(
+ rawDevices
+ .split(",")
+ .map((value) => value.trim())
+ .filter((value) => value.length > 0),
+ );
+
+ const recipientIDs = String(recipientsRecord?.value ?? "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter((value) => value.length > 0);
+
+ if (recipientIDs.length === 0) {
+ console.log(`Alert ${alertID} on org ${organizationID} has no recipients; skipping`);
+ continue;
+ }
+
+ rules.push({
+ organizationID,
+ thresholdMs: inactivityHours * MS_PER_HOUR,
+ sensorIDs,
+ recipientIDs,
+ message: String(messageRecord?.value ?? DEFAULT_ALERT_MESSAGE),
+ });
+ }
+
+ cache.set(organizationID, rules);
+ return rules;
+}
+
+/**
+ * Picks the first per-organization Checkin rule that applies to the given
+ * sensor. Multiple Checkin alerts targeting the same sensor in the same
+ * org is not a supported workflow today.
+ */
+function findRuleForSensor(rules: OrgCheckinRule[], sensorID: string): OrgCheckinRule | null {
+ for (const rule of rules) {
+ if (rule.sensorIDs === "all" || rule.sensorIDs.has(sensorID)) {
+ return rule;
+ }
+ }
+ return null;
+}
+
+// ============================================================================
+// Helpers — classification
+// ============================================================================
+
+function isSensorInactive(sensor: SensorRecord, now: number, thresholdMs: number): boolean {
+ if (!sensor.last_input) {
+ return false;
+ }
+ return now - new Date(sensor.last_input).getTime() > thresholdMs;
+}
+
+function wasAlreadyNotified(sensor: SensorRecord): boolean {
+ return sensor.tags.some((tag) => tag.key === NOTIFIED_TAG && tag.value === "true");
+}
+
+/**
+ * Returns true if any sensor in the list could possibly change state this
+ * run: either it has been silent for more than the minimum threshold (1h),
+ * or it is already flagged as notified and therefore eligible for recovery.
+ * When this returns false we skip loading the global config and the
+ * per-org rules entirely.
+ */
+function hasAlertCandidates(sensors: SensorRecord[], now: number): boolean {
+ for (const sensor of sensors) {
+ if (wasAlreadyNotified(sensor)) {
+ return true;
+ }
+ if (sensor.last_input && now - new Date(sensor.last_input).getTime() > MS_PER_HOUR) {
+ return true;
+ }
+ }
+ return false;
+}
+
+async function syncLastUplinkHours(sensorID: string, sensorLastInput: Date, now: number) {
+ const hours = Math.floor((now - new Date(sensorLastInput).getTime()) / MS_PER_HOUR);
+ const nextValue = String(hours);
+
+ const params = await Resources.devices.paramList(sensorID);
+ const existing = params.find((param) => param.key === LAST_UPLINK_PARAM);
+ if (existing?.value === nextValue) {
+ return;
+ }
+
+ await Resources.devices.paramSet(sensorID, {
+ id: existing?.id,
+ key: LAST_UPLINK_PARAM,
+ value: nextValue,
+ sent: true,
+ });
+}
+
+// ============================================================================
+// Helpers — summary upsert
+// ============================================================================
+
+async function upsertGroupSummary(groupID: string, counts: SensorBucket) {
+ const [existing] = await Resources.devices.getDeviceData(groupID, {
+ variables: SUMMARY_VARIABLE,
+ qty: 1,
+ });
+
+ if (existing) {
+ await Resources.devices.editDeviceData(groupID, {
+ ...existing,
+ metadata: {
+ ...existing.metadata,
+ online: counts.online,
+ offline: counts.offline,
+ },
+ });
+ return;
+ }
+
+ const total = counts.online + counts.offline;
+ await Resources.devices.sendDeviceData(groupID, {
+ variable: SUMMARY_VARIABLE,
+ value: total,
+ metadata: {
+ total_registered: total,
+ online: counts.online,
+ offline: counts.offline,
+ },
+ });
+}
+
+// ============================================================================
+// Helpers — notifications & state transitions
+// ============================================================================
+
+async function notifyOrganizationUsers(organizationID: string, message: string) {
+ const users = await fetchUserList({ tags: [{ key: "organization_id", value: organizationID }] });
+ for (const user of users) {
+ await Resources.run.notificationCreate(user.id, {
+ title: NOTIFICATION_TITLE,
+ message,
+ });
+ }
+}
+
+async function notifyUsers(userIDs: string[], message: string) {
+ for (const userID of userIDs) {
+ await Resources.run.notificationCreate(userID, {
+ title: NOTIFICATION_TITLE,
+ message,
+ }).catch((error) => {
+ console.error(`Failed to notify ${userID}: ${(error as Error).message ?? error}`);
+ });
+ }
+}
+
+/**
+ * `Resources.devices.edit({ tags })` REPLACES the full tag array, so
+ * callers must always send every tag they want to keep.
+ */
+function setOrReplaceTag(tags: TagsObj[], key: string, value: string): TagsObj[] {
+ const otherTags = tags.filter((tag) => tag.key !== key);
+ return [...otherTags, { key, value }];
+}
+
+async function handleNewlyInactive(entry: NewlyInactive, globalAlertMessage: string) {
+ const { sensor, rule } = entry;
+ const organizationID = sensor.tags.find((tag) => tag.key === "organization_id")?.value;
+
+ if (rule) {
+ await notifyUsers(rule.recipientIDs, rule.message);
+ } else if (organizationID) {
+ await notifyOrganizationUsers(organizationID, globalAlertMessage);
+ }
+
+ await Resources.devices.edit(sensor.id, {
+ tags: setOrReplaceTag(sensor.tags, NOTIFIED_TAG, "true"),
+ });
+ console.log(`Sensor ${sensor.id} marked inactive and notified (${rule ? "per-org rule" : "global"})`);
+}
+
+async function handleRecovered(sensor: SensorRecord) {
+ await Resources.devices.edit(sensor.id, {
+ tags: sensor.tags.filter((tag) => tag.key !== NOTIFIED_TAG),
+ });
+ console.log(`Sensor ${sensor.id} recovered, tag cleared`);
+}
+
+// ============================================================================
+// Sensor processing
+// ============================================================================
+
+/**
+ * Walks every sensor, refreshes its `last_uplink` parameter, increments
+ * the per-group online/offline bucket, and (when `fullFlow` is true)
+ * collects state transitions to notify or recover.
+ *
+ * `fullFlow=false` runs the short-circuit path: we know up front that no
+ * sensor can be inactive (every last_input is within the last hour and no
+ * sensor carries the notified tag), so we just count everyone as online
+ * and avoid touching config or rules. `globalThresholdMs` is unused in
+ * that path; `ruleCache` is consulted only in the full path.
+ */
+async function processSensors(
+ sensors: SensorRecord[],
+ now: number,
+ fullFlow: boolean,
+ globalThresholdMs: number,
+ ruleCache: RuleCache,
+): Promise<{
+ countsByGroup: Map;
+ newlyInactive: NewlyInactive[];
+ recovered: SensorRecord[];
+}> {
+ const countsByGroup = new Map();
+ const newlyInactive: NewlyInactive[] = [];
+ const recovered: SensorRecord[] = [];
+
+ for (const sensor of sensors) {
+ if (!sensor.last_input) {
+ continue;
+ }
+
+ const groupID = sensor.tags.find((tag) => tag.key === "group_id")?.value;
+ const organizationID = sensor.tags.find((tag) => tag.key === "organization_id")?.value;
+ if (!groupID || !organizationID) {
+ continue;
+ }
+
+ await syncLastUplinkHours(sensor.id, sensor.last_input, now);
+
+ const bucket = countsByGroup.get(groupID) ?? { online: 0, offline: 0 };
+
+ if (!fullFlow) {
+ bucket.online += 1;
+ countsByGroup.set(groupID, bucket);
+ continue;
+ }
+
+ const orgRules = await getRulesForOrganization(organizationID, ruleCache);
+ const rule = findRuleForSensor(orgRules, sensor.id);
+ const thresholdMs = rule ? rule.thresholdMs : globalThresholdMs;
+
+ const inactive = isSensorInactive(sensor, now, thresholdMs);
+ const alreadyNotified = wasAlreadyNotified(sensor);
+
+ if (inactive) {
+ bucket.offline += 1;
+ } else {
+ bucket.online += 1;
+ }
+ countsByGroup.set(groupID, bucket);
+
+ if (inactive && !alreadyNotified) {
+ newlyInactive.push({ sensor, rule });
+ } else if (!inactive && alreadyNotified) {
+ recovered.push(sensor);
+ }
+ }
+
+ return { countsByGroup, newlyInactive, recovered };
+}
+
+// ============================================================================
+// Entrypoint
+// ============================================================================
+
+/**
+ * Entrypoint invoked by the TagoIO Analysis runtime.
+ *
+ * Steps:
+ * 1. Fetch every sensor in the application.
+ * 2. Decide between short-circuit and full flow via `hasAlertCandidates`.
+ * Short-circuit kicks in when no sensor has been silent for over an
+ * hour AND no sensor still carries the `inactivity_notified` tag —
+ * in that case no state transition can happen and we skip loading
+ * both the global config and the per-org Checkin rules.
+ * 3. Walk the sensors via `processSensors`, refreshing `last_uplink`
+ * on each. In the full flow we also resolve the per-org threshold
+ * (lazy-loaded and cached) and collect newly-inactive / recovered
+ * sensors.
+ * 4. Upsert `device_connectivity_summary` on every group device that
+ * owns at least one sensor.
+ * 5. Notify users and tag the newly-inactive sensors so we don't spam
+ * them next run.
+ * 6. Clear the notified tag for sensors that came back online.
+ */
+async function startAnalysis(context: TagoContext, _scope: Data[]) {
+ console.log("Running Inactivity Check Analysis");
+
+ const environment = Utils.envToJson(context.environment);
+
+ const configDevID = environment.config_id;
+ if (!configDevID) {
+ console.log("Missing config_id environment variable, skipping");
+ return;
+ }
+
+ const sensors = await fetchDeviceList({ tags: [{ key: "device_type", value: "device" }] });
+ console.log(`Scanning ${sensors.length} sensors`);
+
+ const now = Date.now();
+ const ruleCache: RuleCache = new Map();
+
+ // Step 2 — decide between short-circuit and full flow.
+ const fullFlow = hasAlertCandidates(sensors, now);
+ let config: InactivityConfig | null = null;
+
+ if (fullFlow) {
+ config = await loadInactivityConfig(configDevID);
+ if (!config) {
+ return;
+ }
+ console.log(`Global threshold = ${config.thresholdMs}ms; per-org rules will load lazily.`);
+ } else {
+ console.log("No alert candidates this run — skipping config and rule loading.");
+ }
+
+ // Step 3 — walk sensors.
+ const { countsByGroup, newlyInactive, recovered } = await processSensors(
+ sensors,
+ now,
+ fullFlow,
+ config?.thresholdMs ?? 0,
+ ruleCache,
+ );
+
+ // Step 4 — refresh group summaries.
+ for (const [groupID, counts] of countsByGroup) {
+ await upsertGroupSummary(groupID, counts);
+ }
+
+ // Step 5 — notify + flag newly inactive sensors.
+ for (const entry of newlyInactive) {
+ await handleNewlyInactive(entry, config?.alertMessage ?? DEFAULT_ALERT_MESSAGE);
+ }
+
+ // Step 6 — clear the flag for sensors that recovered.
+ for (const sensor of recovered) {
+ await handleRecovered(sensor);
+ }
+
+ console.log(
+ `Inactivity scan finished. Groups updated: ${countsByGroup.size}, newly inactive: ${newlyInactive.length}, recovered: ${recovered.length}`,
+ );
+}
+
+if (!Deno.env.get("T_TEST")) {
+ Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") });
+}
+
+export { startAnalysis };
diff --git a/app/deno.json b/app/deno.json
new file mode 100644
index 0000000..b22b6a4
--- /dev/null
+++ b/app/deno.json
@@ -0,0 +1,25 @@
+{
+ "name": "@app/application",
+ "version": "1.0.0",
+ "type": "module",
+ "exports": {},
+ "tasks": {
+ "test": "deno test --allow-all .",
+ "test:single": "deno test --allow-all",
+ "linter": "deno lint --rules-exclude=no-explicit-any,hugoalh/fmt-jsdoc --ignore='**/*.test.ts' .",
+ "linter-fix": "deno lint --fix .",
+ "build": "deno run --allow-read --allow-write --allow-run bundle.ts"
+ },
+ "imports": {
+ "json-2-csv": "npm:json-2-csv@^5.5.10",
+ "luxon": "npm:luxon@^3.7.2",
+ "phone": "npm:phone@^3.1.50",
+ "zod": "npm:zod@^4.3.6",
+ "@std/fs": "jsr:@std/fs@^1.0.23",
+ "@std/path": "jsr:@std/path@^1.1.4"
+ },
+ "test": {
+ "include": ["**/*.test.ts"],
+ "exclude": ["build/"]
+ }
+}
diff --git a/biome.json b/biome.json
deleted file mode 100644
index eec48cf..0000000
--- a/biome.json
+++ /dev/null
@@ -1,119 +0,0 @@
-{
- "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
- "organizeImports": { "enabled": true },
- "linter": {
- "enabled": true,
- "rules": {
- "recommended": false,
- "complexity": {
- "noBannedTypes": "error",
- "noExtraBooleanCast": "error",
- "noForEach": "warn",
- "noMultipleSpacesInRegularExpressionLiterals": "error",
- "noUselessCatch": "error",
- "noUselessThisAlias": "error",
- "noUselessTypeConstraint": "error",
- "noWith": "error",
- "useArrowFunction": "warn",
- "useFlatMap": "warn"
- },
- "correctness": {
- "noConstAssign": "error",
- "noConstantCondition": "error",
- "noEmptyCharacterClassInRegex": "error",
- "noEmptyPattern": "error",
- "noGlobalObjectCalls": "error",
- "noInnerDeclarations": "error",
- "noInvalidConstructorSuper": "error",
- "noNewSymbol": "error",
- "noNonoctalDecimalEscape": "error",
- "noPrecisionLoss": "error",
- "noSelfAssign": "error",
- "noSetterReturn": "error",
- "noSwitchDeclarations": "error",
- "noUndeclaredVariables": "error",
- "noUnreachable": "error",
- "noUnreachableSuper": "error",
- "noUnsafeFinally": "error",
- "noUnsafeOptionalChaining": "error",
- "noUnusedLabels": "error",
- "noUnusedVariables": "error",
- "useIsNan": "error",
- "useValidForDirection": "error",
- "useYield": "error"
- },
- "style": {
- "noDefaultExport": "warn",
- "noNamespace": "error",
- "useAsConstAssertion": "error",
- "useBlockStatements": "error",
- "useFilenamingConvention": {
- "level": "error",
- "options": { "requireAscii": true, "filenameCases": ["kebab-case"] }
- }
- },
- "suspicious": {
- "noAssignInExpressions": "error",
- "noAsyncPromiseExecutor": "error",
- "noCatchAssign": "error",
- "noClassAssign": "error",
- "noCompareNegZero": "error",
- "noControlCharactersInRegex": "error",
- "noDebugger": "error",
- "noDuplicateCase": "error",
- "noDuplicateClassMembers": "error",
- "noDuplicateObjectKeys": "error",
- "noDuplicateParameters": "error",
- "noEmptyBlockStatements": "error",
- "noExplicitAny": "warn",
- "noExtraNonNullAssertion": "error",
- "noFallthroughSwitchClause": "error",
- "noFunctionAssign": "error",
- "noGlobalAssign": "error",
- "noImportAssign": "error",
- "noMisleadingCharacterClass": "error",
- "noMisleadingInstantiator": "error",
- "noPrototypeBuiltins": "error",
- "noRedeclare": "error",
- "noShadowRestrictedNames": "error",
- "noUnsafeDeclarationMerging": "error",
- "noUnsafeNegation": "error",
- "useAwait": "off",
- "useGetterReturn": "error",
- "useIsArray": "error",
- "useValidTypeof": "error"
- }
- },
- "ignore": [
- ".eslintrc.js",
- "tsconfig.json",
- "**/lib-cov",
- "**/coverage",
- "**/__mocks__",
- "infrastructure/overwrite",
- "**/newrelic.js",
- "**/__test__",
- "**/*.NOTE.*",
- "**/*.js",
- "**/build",
- "cli/*"
- ]
- },
- "javascript": {
- "globals": ["test", "it", "describe", "expect", "vitest"],
- "formatter": {
- "jsxQuoteStyle": "double",
- "quoteProperties": "asNeeded",
- "trailingCommas": "es5",
- "semicolons": "always",
- "arrowParentheses": "always",
- "bracketSpacing": true,
- "bracketSameLine": false,
- "quoteStyle": "double",
- "attributePosition": "auto",
- "lineWidth": 180,
- "indentStyle": "space",
- "indentWidth": 2
- }
- }
-}
diff --git a/dashboard-helpers/HELPER_ALERTS.md b/dashboard-helpers/HELPER_ALERTS.md
new file mode 100644
index 0000000..9794816
--- /dev/null
+++ b/dashboard-helpers/HELPER_ALERTS.md
@@ -0,0 +1,88 @@
+# 🚨 Alerts
+
+## 💡 What is an Alert?
+
+An **Alert** is a rule that watches your sensors and notifies the right people the moment something needs attention — a freezer warming up, a door left open, a compressor that
+stopped, or a sensor that went silent. Each alert is scoped to one **organization**, so users in one tenant only see (and only receive) the alerts that belong to them.
+
+You can create as many alerts as you need, mix and match sensor selections, and customize the message that goes out so the recipient knows exactly what happened and where.
+
+## 🛠️ What can I do on this dashboard?
+
+This dashboard lists every alert configured for the organization you opened.
+
+- 📋 **By Sensor — Alert List** — A table with every alert: which sensor(s) it watches, the model (Temperature, Door, Compressor, Inactivity), the condition, the value, the
+ recipients, and the message. From here, you can:
+ - **Delete** an alert (the trash icon under _Controls_). Deletion is irreversible — the rule is removed and no more notifications fire for it.
+- 🌐 **Global Alerts** — A read-only tab showing the application-wide defaults (used when an organization has no specific Inactivity rule of its own).
+- ➕ **Create Alert** — Opens the form to add a new alert. You choose which sensors to watch, the model, the condition, the recipients, and the message.
+
+## 🧩 The four alert types
+
+| Model | What it watches | Example condition |
+| ------------------ | ------------------------------------------ | ---------------------------------------------------------- |
+| 🌡️ **Temperature** | The `temperature` variable from the sensor | `> 80°F`, `< 20°F`, `between 30-40°F`, `= 50°F`, `!= 60°F` |
+| 🚪 **Door** | The `door` variable (open/closed) | `door = open` |
+| ⚙️ **Compressor** | The `compressor` variable (on/off) | `compressor = off` |
+| ⏰ **Inactivity** | How long since the sensor's last uplink | `no data for 2 hours` |
+
+Temperature is always in **°F**. Door and Compressor are enums chosen from a dropdown. Inactivity is measured in hours.
+
+## ✉️ The notification message
+
+The message is sent **in-app** (the bell icon at the top of the dashboard) to every recipient you picked. You can personalize it with placeholders that are replaced at the moment
+the alert fires:
+
+- `#device_name#` — the friendly name of the sensor
+- `#device_id#` — the sensor's internal ID
+- `#sensor_type#` — the sensor's type tag (e.g. `freezer`)
+- `#value#` — the value that crossed the threshold
+- `#variable#` — the variable name that triggered (e.g. `temperature`)
+
+Example: _"Freezer 02 is too hot: temperature reached #value#°F"_ becomes _"Freezer 02 is too hot: temperature reached 84°F"_.
+
+## ⚙️ How it works behind the scenes
+
+Each alert is one logical record on the **organization device**, written as six variables sharing the same `group` (which doubles as the alert ID). The widget table reads those
+rows directly — that's why a new alert shows up on the table right after creation.
+
+For **Temperature, Door, and Compressor** alerts, the `createAlert` analysis function also provisions a TagoIO **Action** of type `condition`:
+
+1. ✅ Validates the form fields with a Zod schema (model, condition, value, recipients, message).
+2. 💾 Writes the six alert variables on the organization device, grouped by a fresh alert ID.
+3. 🏷️ If the alert targets specific sensors, it tags each chosen sensor with `alert_id = ` so the Action can find them by tag.
+4. 🎬 Creates a TagoIO Action whose trigger matches either _every device in the organization_ (`device_type = device`) or _only the sensors with the matching `alert_id` tag_.
+5. 📨 The Action calls the `alert-dispatcher` analysis whenever the condition is met. The dispatcher reads the alert row, substitutes the placeholders, and sends the in-app
+ notification to each recipient.
+
+**Inactivity** alerts work differently — they are NOT backed by a TagoIO Action (the platform cannot natively detect "no data for X hours"). Instead, the scheduled
+`check-inactive-sensors` analysis runs periodically, reads every organization's Inactivity rules, and fires notifications using the per-organization recipients and message.
+
+The delete flow runs in reverse: it removes the six variables from the organization device, removes the `alert_id` tag from any sensors that were tagged for it, and deletes the
+TagoIO Action (when there is one).
+
+## ❓ Common questions
+
+**What's the difference between _All Sensors_ and _Sensors_ in "Setup alerts by"?** _All Sensors_ makes the alert watch every sensor currently in the organization, including any
+sensor added later — the Action's trigger is keyed by the `device_type = device` tag. _Sensors_ lets you pick a specific list; only the sensors you check are tagged and watched.
+
+**If I create an All Sensors alert and add a new sensor later, will it be covered?** Yes, automatically — because the Action filters by the `device_type` tag, which every sensor in
+the organization already has.
+
+**Why don't I see an Action created for my Inactivity alert?** By design. Inactivity is detected by a scheduled scan, not by a TagoIO condition Action. The rule is stored on the
+organization device and read on each run of the `check-inactive-sensors` analysis.
+
+**Do recipients receive an email or SMS?** No — only in-app notifications (the bell at the top of the dashboard). Email and SMS are out of scope for this template.
+
+**What happens to old notifications if I delete an alert?** Already-sent notifications stay in the recipient's inbox; only future notifications stop. Deleting the alert removes the
+rule, the row in the table, the Action (if any), and the `alert_id` tag on the targeted sensors.
+
+**Can two alerts target the same sensor?** Yes — a sensor can be watched by as many alerts as you want, and each fires independently.
+
+## 💎 Tips
+
+- Use placeholders in the message so the same alert template works for every sensor. _"#device_name# reported #variable# = #value#"_ is more useful than a static text.
+- For Temperature _Between_, set the lower bound first and the upper bound second. The alert fires when the value falls **inside** the range.
+- Inactivity alerts are great as a safety net: even if a specific Temperature or Door alert is missing, an Inactivity rule will catch sensors that simply stopped reporting.
+- Prefer one _All Sensors_ alert over creating a separate alert per sensor — easier to maintain and automatically covers newly added sensors.
+- Keep the recipient list lean. Notification fatigue makes important alerts get ignored.
diff --git a/dashboard-helpers/HELPER_FREEZER_SIMULATOR.md b/dashboard-helpers/HELPER_FREEZER_SIMULATOR.md
new file mode 100644
index 0000000..d5ba4e5
--- /dev/null
+++ b/dashboard-helpers/HELPER_FREEZER_SIMULATOR.md
@@ -0,0 +1,59 @@
+# 🔍 Freezer Simulator
+
+## 💡 What is this dashboard?
+
+The **Freezer Simulator** dashboard is the deep-dive view of a single sensor. You land here by clicking the **View** icon next to a sensor on the Sensors dashboard — there is no
+entry for it on the sidebar because it always needs a specific sensor in scope.
+
+## 🛠️ What can I do on this dashboard?
+
+The header keeps the breadcrumb selectors (Organization, Group, Sensor) populated from the deep-link, so you always know which sensor you are looking at. The dashboard itself has
+two tabs:
+
+- 📊 **Overview tab** — the default view, made up of three areas:
+ - **Live Cold Room Monitor** (custom widget) — three large cards at the top: _TEMPERATURE_ (gauge + value in °F), _COMPRESSOR STATUS_ (ON/OFF), and _DOOR STATUS_ (OPEN/CLOSED).
+ Each card shows a "— Xm ago" label so you can tell at a glance whether the value is fresh.
+ - **Sensor History table** — a paginated list of the last readings with columns _Temperature_, _Door Status_, _Compressor Status_, and _Date and Time_. The table loads up to
+ 1,000 records, paginated; scroll back through the pages to see older uplinks.
+ - **Temperature History (header button)** — opens a 24-hour line chart of the temperature in a modal over the dashboard. It supports drag-to-zoom; a _Reset Zoom_ button brings
+ you back to the full window. Use it when you need to spot trends, drops, or warm-ups that a single value cannot show.
+- 📘 **Helper tab** — this document.
+
+## ⚙️ How it works behind the scenes
+
+There is no per-sensor dashboard _device_ — the dashboard reads the sensor's own device directly. The deep-link from the Sensors table sets `sensor_dev=` in the URL, and
+every widget on this page is bound to that scope through TagoIO Blueprints.
+
+- The three **live cards** in the Cold Room Monitor are fed by the sensor device's latest values for the variables `temperature`, `compressor`, and `door`. The custom widget
+ formats them and computes the "X minutes ago" label from each value's timestamp.
+- The **Sensor History table** queries those same variables on the sensor device, ordered by time descending. It is a regular TagoIO data table, which is why pagination and the
+ total record count work out of the box.
+- The **Temperature History chart** is a Line Chart widget bound to the `temperature` variable on the sensor device, configured for the last 24 hours.
+
+Because every widget reads the sensor device directly, no analysis runs when you open this dashboard. The data you see was written by the `uplink-handler` analysis (and by the
+decoder) when the sensor pushed its uplinks.
+
+## ❓ Common questions
+
+**Why isn't this dashboard in the sidebar?** It only makes sense for one sensor at a time, and that sensor comes from the URL parameters set by the Sensors dashboard. Without that
+scope the dashboard would have nothing to render, so it is intentionally accessed only via the **View** icon.
+
+**The cards show "— Xh ago" with a large number. What does that mean?** The sensor hasn't pushed an uplink for that long. It is the same signal the Cold Rooms tab uses on the
+Groups dashboard. Check the device's connectivity (network, battery) if the time keeps climbing.
+
+**Why does the Temperature History chart only cover 24 hours?** It is configured for a 24-hour window because that matches the most common operational question (_"what has this
+freezer done today?"_).
+
+**Can I edit the sensor from here?** No — this is a read-only operational view. To rename or delete the sensor, go back to the Sensors dashboard and use the row controls.
+
+**My table is empty even though the cards show data. Why?** The cards read the _latest_ value, while the table queries the last 1,000 ordered by time. If you just provisioned the
+sensor and only one uplink arrived, the table will have a single row.
+
+## 💎 Tips
+
+- Use the Temperature History modal as your first stop when investigating an alert: a chart usually answers _"is this a spike or a sustained problem?"_ faster than scrolling the
+ table.
+- The Sensor History table lines up _Temperature_, _Door Status_, and _Compressor Status_ on the same row, so it is the right place to correlate events — for example, to check
+ whether a temperature spike coincided with the door opening or the compressor turning off.
+- The `— Xm ago` label is your fastest health check. Two of the three cards lagging behind usually means the sensor itself is offline; one card alone usually means a decoder or
+ payload issue.
diff --git a/dashboard-helpers/HELPER_GROUPS.md b/dashboard-helpers/HELPER_GROUPS.md
new file mode 100644
index 0000000..fc232ed
--- /dev/null
+++ b/dashboard-helpers/HELPER_GROUPS.md
@@ -0,0 +1,67 @@
+# 🧩 Groups
+
+## 💡 What is a Group?
+
+A **Group** is a subdivision inside an Organization. It represents a physical or logical area where sensors are installed — for example, a cold room, a warehouse, a production
+line, a floor, or a zone. Each sensor in the application belongs to exactly one group, and each group belongs to exactly one organization.
+
+Groups are the layer that makes large deployments manageable. Instead of looking at every sensor at once, users navigate Organization → Group → Sensor, which keeps each screen
+focused on the right scope.
+
+## 🛠️ What can I do on this dashboard?
+
+This dashboard shows every group that belongs to the organization selected at the top. It is split into three tabs:
+
+- ❄️ **Cold Rooms tab** — A live-monitoring view showing every sensor in the organization grouped by its parent group. Each group is rendered as a section header with a count (e.g:
+ _"5 SENSORS"_) followed by one card per sensor. Every card surfaces the sensor name, time since the last uplink, current temperature (°F), compressor status (ON/OFF), and door
+ status (OPEN/CLOSED). A search button in the top-right filters the cards by sensor name, which is handy when an organization has many sensors. This is the tab to open first when
+ a user wants to know the current state of their cold rooms.
+- 🏢 **Organization selector** — The dropdown at the top lets you switch between organizations without leaving this dashboard. It uses TagoIO's Blueprint feature: changing the
+ organization rewrites every widget in place to show that organization's groups.
+- 📋 **Overview tab — Group List** — A table with the name and address of every group inside the current organization. From here, you can:
+ - **View** a group (the icon on the left) — opens the Sensors dashboard already filtered for that group.
+ - **Edit** the group's name (the pencil icon under _Controls_).
+ - **Delete** the group (the trash icon under _Controls_). Deleting a group also deletes every sensor that belongs to it.
+- ➕ **Create Group** — Opens a form to add a new group to the current organization. You provide a name and an address; the group is created inside the organization you are
+ viewing.
+
+## ⚙️ How it works behind the scenes
+
+Each group is stored as a TagoIO **device** of type `mutable` with the tags `device_type = group` and `organization_id = `. This device stores the group's metadata
+(name, address) and acts as the anchor for every sensor that belongs to it.
+
+When you create a group, the `createGroup` analysis function runs and:
+
+1. ✅ Validates the form fields using a Zod schema.
+2. 🔍 Checks that the name is unique inside the parent organization (the same name is allowed in different organizations).
+3. 🏷️ Creates the device, applies the `organization_id`, `group_id`, and `device_type` tags, and stores the address as a device parameter.
+
+Sensors that you create later carry the matching `group_id` tag, which is how this dashboard's Sensors view knows what to display.
+
+The **Cold Rooms tab** is powered by a TagoIO **custom widget** that reads the `cold_room_card_data` variable from the organization device. That variable is written by the
+`uplink-handler` analysis on every sensor uplink: one record per sensor, grouped by sensor id, with metadata for the sensor name, parent group name, temperature, compressor status,
+and door status. The widget uses the `group_name` field to cluster the cards by group at render time — no extra query is needed.
+
+## ❓ Common questions
+
+**Why is the organization selector at the top?** Because this same dashboard is reused for every organization. The selector tells the dashboard which organization to scope to, and
+the Blueprint feature reloads every widget with that organization's data. This is a TagoIO pattern that lets you keep one dashboard configuration while serving many tenants.
+
+**Can two groups have the same name?** Two groups in the _same_ organization cannot share a name — the analysis blocks it. Two groups in _different_ organizations can share a name
+without any conflict.
+
+**What happens if I delete a group that already has sensors?** The delete action removes every sensor inside the group along with the group device itself. The action is
+irreversible, so make sure you are deleting the right one. If you only want to move sensors to another group, edit the sensors first.
+
+**Why does a sensor card on the Cold Rooms tab show stale data or `— Xh ago`?** The card mirrors whatever the sensor last reported. If the time-since-last-uplink keeps climbing,
+the device is likely offline or out of battery — open the Sensors view for that group to investigate.
+
+**A new sensor doesn't appear on the Cold Rooms tab. Why?** The card only shows up after the sensor produces its first uplink (the `uplink-handler` writes the `cold_room_card_data`
+record on the organization device the first time it sees a value from that sensor). Once the device sends data, the card appears automatically.
+
+## 💎 Tips
+
+- Use names that match the real world (for example, _Cold Room A_, _Floor 2 — North Wing_). The group name is shown across the application and in notifications.
+- The address is informative only at this level — the Sensors dashboard does not show it on a map. Still, providing an accurate address makes the data easier to interpret later.
+- An organization can have any number of groups. Start with the structure that mirrors your physical sites, then refine as you learn what your users need.
+- Use the search field on the Cold Rooms tab to jump straight to a sensor by name when the organization has many sensors — much faster than scrolling.
diff --git a/dashboard-helpers/HELPER_ORGANIZATIONS.md b/dashboard-helpers/HELPER_ORGANIZATIONS.md
new file mode 100644
index 0000000..52c30ff
--- /dev/null
+++ b/dashboard-helpers/HELPER_ORGANIZATIONS.md
@@ -0,0 +1,51 @@
+# 🏢 Organizations
+
+## 💡 What is an Organization?
+
+An **Organization** is the top-level entity in this application. It represents a customer, a site, a team, or any tenant that owns a set of groups, sensors, dashboards, and users.
+Everything in the application — devices, data, alerts, access — is scoped to one Organization, which is what makes this template multi-tenant by default.
+
+If you are building an IoT application where each of your customers should see only their own data, the Organization is the unit you create for each customer.
+
+## 🛠️ What can I do on this dashboard?
+
+This dashboard is your starting point for managing every organization in the application.
+
+- 📋 **Organization List** — A table with the name and address of every organization. From here, you can:
+ - **View** an organization (the icon on the left) — opens the Groups dashboard already filtered for that organization.
+ - **Edit** the name or address (the pencil icon under _Controls_).
+ - **Delete** the organization (the trash icon under _Controls_). Deleting an organization also removes its dummy device and any data linked to it.
+- 🗺️ **Map View** — Shows every organization as a pin on a map, based on its address. Click a pin to open a popup with the organization name, the last update timestamp, and a _Go
+ to organization_ link that opens the Groups dashboard. Use this view when you want a geographic overview of all your tenants.
+- ➕ **Create Organization** — Opens a form to add a new organization. You provide a name and an address; the address is geocoded so the new organization appears on the Map View.
+
+## ⚙️ How it works behind the scenes
+
+Each organization is stored as a TagoIO **device** of type `mutable` with the tag `device_type = organization`. This device acts as a small store for the organization's metadata
+(name, address, location) and as the anchor for everything that belongs to it.
+
+When you create an organization, the `createOrganization` analysis function runs and:
+
+1. ✅ Validates the form fields using a Zod schema.
+2. 🔍 Checks that the name is unique inside this application.
+3. 🏷️ Creates the device, applies the `organization_id` and `device_type` tags, and stores the address as a device parameter.
+
+Groups, sensors, and run users that you create later carry the matching `organization_id` tag, which is how Access Policies isolate data between tenants.
+
+## ❓ Common questions
+
+**Where does the data come from?** From the application's settings device (the dummy device passed in the URL as `settings_dev`). The list and the map both read the same
+`organization` data variable that the analysis writes when you create or edit an organization.
+
+**Why can't I find a group or a sensor on this dashboard?** This dashboard only shows organizations. To see what belongs to an organization, click _View_ on its row — you will land
+on the Groups dashboard scoped to that organization.
+
+**What happens if I delete an organization that already has groups and sensors?** The delete action removes the organization device and triggers cleanup, but you should remove its
+groups and sensors first to avoid orphan devices. Check the _Groups_ dashboard inside the organization before deleting it.
+
+## 💎 Tips
+
+- Keep organization names clear and unique. They are shown across every dashboard and used in notifications.
+- Use a real address when creating an organization — it powers the Map View and helps users locate sites quickly.
+- This dashboard is intended for application administrators (the `admin` access level). Org admins and guests typically land directly on the Groups dashboard of their own
+ organization.
diff --git a/dashboard-helpers/HELPER_SENSORS.md b/dashboard-helpers/HELPER_SENSORS.md
new file mode 100644
index 0000000..d23efcb
--- /dev/null
+++ b/dashboard-helpers/HELPER_SENSORS.md
@@ -0,0 +1,57 @@
+# 📡 Sensors
+
+## 💡 What is a Sensor?
+
+A **Sensor** is an IoT device that sends data to TagoIO — a temperature probe, a GPS tracker, an energy meter, or any hardware that produces measurements. In this application each
+sensor belongs to one group, which in turn belongs to one organization, so the data is always scoped to the right tenant and the right area.
+
+Each sensor is identified by a unique **EUI**, the hardware code printed on the device. The EUI is what TagoIO uses to route incoming packets to the correct sensor.
+
+## 🛠️ What can I do on this dashboard?
+
+This dashboard shows every sensor inside the group you opened from the Groups dashboard.
+
+- 📊 **Sensor Status cards** — A summary at the top with three counts: total _Registered_, _Active_ (sending data right now), and _Inactive_ (not sending data). The total is
+ updated automatically every time you create or delete a sensor.
+- 📋 **Sensor List** — A table with name, EUI, network, model, last seen, and battery for every sensor in the group. From here, you can:
+ - **View** a sensor (the icon on the left) — opens the sensor's detail dashboard with charts and recent data.
+ - **Edit** the sensor's name (the pencil icon under _Controls_).
+ - **Delete** the sensor (the trash icon under _Controls_). Deletion is irreversible — the device and all its stored data are removed.
+- 🛡️ **All Devices (Admin)** — An admin-only tab that lists every device of this group, including dummy devices (like the group's own device). Use it only when you need to inspect
+ or repair the underlying TagoIO objects.
+- ➕ **Create Sensor** — Opens a form to add a new sensor. You provide a name, pick the network and model, and enter the EUI. The sensor is created inside the current group.
+
+## ⚙️ How it works behind the scenes
+
+Each sensor is stored as a TagoIO **device** of type `immutable` (time-series storage) with the tags `device_type = device`, `sensor_id`, `device_eui`, `group_id`, and
+`organization_id`. The network and connector chosen on the form define how incoming payloads are decoded.
+
+When you create a sensor, the `createSensor` analysis function runs and:
+
+1. ✅ Validates the form fields using a Zod schema (name length, EUI format, network, connector).
+2. 🔍 Checks that the name is unique inside the parent group, and that the EUI is unique across the whole application (no two sensors can share a hardware code).
+3. 🏷️ Creates the device, applies the tags above, and stores the EUI and a deep-link to the sensor dashboard as device parameters.
+4. 🔄 Updates the group's `device_connectivity_summary` record so the Sensor Status cards reflect the new total.
+
+The delete flow runs in reverse: it removes the device and then refreshes the same summary record on the group, so the counter goes down right away.
+
+## ❓ Common questions
+
+**What is the EUI and where do I find it?** The EUI is a 16-character hexadecimal identifier (`0123456789ABCDEF`). It is printed on the device itself or on its box, and the
+manufacturer guarantees it is unique. You can also scan it with the _Scan QR Code_ button if the device has a QR code.
+
+**Why does the Sensor Status widget show `—` for the active and inactive counts?** The total registered count is maintained by the create and delete analyses, but the active and
+inactive counts come from a separate monitoring flow that watches each sensor's last uplink. In a fresh installation, those values will stay as `—` until a monitoring action starts
+updating them.
+
+**Can two sensors have the same EUI?** No. The application enforces uniqueness at creation time, because the EUI is what links incoming data to the right sensor. If two sensors
+shared an EUI, their data would collide.
+
+**What happens if I delete a sensor?** The device, all its time-series data, parameters, and tags are removed. The action is irreversible, so make sure you exported anything you
+need before deleting.
+
+## 💎 Tips
+
+- Use names that describe the role and the location, like _Freezer 02 — Top Shelf_. The sensor name shows up in dashboards, alerts, and notifications.
+- The model defines which decoder is applied to incoming data. Choosing the right model is what makes the sensor's data show up in the correct units.
+- If a sensor stops appearing in _Active_, check the _Last seen_ column first — long gaps usually mean the device is offline or out of battery.
diff --git a/dashboard-helpers/HELPER_USERS.md b/dashboard-helpers/HELPER_USERS.md
new file mode 100644
index 0000000..0eae2a9
--- /dev/null
+++ b/dashboard-helpers/HELPER_USERS.md
@@ -0,0 +1,81 @@
+# 👥 Users
+
+## 💡 What is a User?
+
+A **User** is a person who logs into your application through TagoRUN — a customer, a technician, an operator, or anyone who needs to see data. Every user belongs to one
+organization (with the exception of Application Admins, who are not tied to any) and has an **access level** that decides what they can do.
+
+This dashboard is where you invite users, change what they can see, and remove access when needed.
+
+## 🛠️ What can I do on this dashboard?
+
+This dashboard shows every user that belongs to the organization selected at the top.
+
+- 🏢 **Organization selector** — The dropdown at the top scopes the dashboard to a single organization. Switching it reloads the list with that organization's users without leaving
+ the page.
+- 📋 **Users List** — A table with name, email, phone, and access level for every user. From here, you can:
+ - **Change password** (the key icon under _Controls_) — opens a small dialog to set a new password for the user, bypassing the email flow.
+ - **Edit** (the pencil icon under _Controls_) — opens a form to update the name, phone, or access level. Email is fixed once the user is created.
+ - **Delete** (the trash icon under _Controls_) — removes the user account from TagoRUN. The action is irreversible.
+- 🛡️ **All Users (Admin)** — An admin-only tab that lists every user in the application, including Application Admins not tied to any organization. Use it when you need a global
+ view.
+- ➕ **Create User** — Opens a form to invite a new user. You provide name, email, an optional phone, and the access level. The new user receives an email with a link to set their
+ password.
+
+## 🔐 Access levels
+
+The kickstarter ships with three levels, defined in the user model. Pick the one that matches the user's role:
+
+- **Application Admin** — full access to every organization in the application. Use it for the team that maintains the platform itself.
+- **Organization Admin** — full access to a single organization. Can create, edit, and delete groups, sensors, and other users _inside that organization_.
+- **Guest** — read-only access to a single organization. Can view dashboards but cannot create, edit, or delete anything.
+
+The access level is stored as a tag (`access`) on the run user. Org Admin and Guest also receive an `organization_id` tag that scopes their visibility through Access Policies.
+
+## ⚙️ How it works behind the scenes
+
+When you create a user, the `createUser` analysis function runs and:
+
+1. ✅ Validates name, email, phone, and access level using a Zod schema. The phone must include a country code (for example, `+1` for US numbers); the email must be a valid
+ address.
+2. 🔍 Checks that the email is not already in use anywhere in the application.
+3. 🏷️ Creates the run user, applies the `access` tag, and (for Org Admin and Guest) the `organization_id` and `user_organization_id` tags that link them to the current
+ organization.
+4. 📧 Sends an invite email with the temporary password. **The reference implementation uses SendGrid**, but the call lives in a single helper (`sendInviteEmail`) — swap it for any
+ provider (Mailgun, SES, Resend, SMTP, etc.) without touching the rest of the flow.
+
+When you edit a user, only the fields you changed are sent. If validation fails, the analysis runs `undoUserChanges` to restore the previous values, so the UI never drifts out of
+sync with the backend.
+
+## 📧 Email provider (SendGrid)
+
+The kickstarter expects two analysis environment variables:
+
+- `SENDGRID_API_KEY` — API key with _Mail Send_ scope.
+- `sendgrid_from_email` — verified sender address.
+
+**Errors you may see:**
+
+- Missing vars → `[Error] Missing secrets 'SENDGRID_API_KEY' or 'sendgrid_from_email'.`
+- SendGrid rejects (wrong key, unverified sender, missing template) → log shows `Email sending failed: ...` check the analysis logs to see the SendGrid response.
+
+## ❓ Common questions
+
+**Why isn't there a phone number for some users?** Phone is optional on the form. If you don't fill it in, the user is created without a phone — you can add one later through the
+edit dialog.
+
+**The invite email never arrives. What now?** Check the SendGrid configuration in the application's environment variables (`SENDGRID_API_KEY` and `sendgrid_from_email`). Without
+them set, the invite step fails. As a fallback, you can also use _Change password_ to set a password directly and share it with the user out of band.
+
+**Can I change a user's email?** No. Email is the primary identifier in TagoRUN and is fixed after creation. If a user changes email, the cleanest path is to delete the old account
+and invite them again.
+
+**Can I move a user to a different organization?** There is no built-in flow for that. The user's `organization_id` tag would need to be edited directly. Delete and re-invite is
+the safest path today.
+
+## 💎 Tips
+
+- Use _Organization Admin_ sparingly. It gives full control inside the organization, including the power to delete every group and sensor.
+- _Guest_ is the right level for stakeholders who only need to look at dashboards — they cannot break anything.
+- _Application Admin_ is unscoped. Only assign it to the team that maintains the platform; everyone else should have an organization tag.
+- Keep names readable. The user name shows up in audit logs and notifications, so "Maria — Field Tech" is clearer than just "Maria".
diff --git a/deno.json b/deno.json
new file mode 100644
index 0000000..59e5de7
--- /dev/null
+++ b/deno.json
@@ -0,0 +1,50 @@
+{
+ "name": "@app/root",
+ "version": "1.0.0",
+ "exports": "./app/mod.ts",
+ "nodeModulesDir": "auto",
+ "workspace": ["./app", "./widgets", "./widgets/sensor-status", "./widgets/cold-room-monitor", "./widgets/cold-room-card-data"],
+ "tasks": {
+ "test:app": "cd app && deno task test",
+ "test:all": "deno test --allow-all --ignore=build/,_dist/,node_modules/",
+ "lint:app": "cd app && deno task linter",
+ "lint:widgets": "deno lint widgets/",
+ "lint:all": "deno lint --ignore=build/,_dist/,node_modules/,.github/",
+ "fmt": "deno fmt",
+ "fmt:check": "deno fmt --check",
+ "build:app": "deno check app/",
+ "build:widgets": "deno check widgets/ &&cd widgets && deno task build",
+ "dev:widgets": "cd widgets && deno task dev"
+ },
+ "fmt": {
+ "useTabs": false,
+ "lineWidth": 180,
+ "indentWidth": 2,
+ "semiColons": true,
+ "singleQuote": false,
+ "exclude": ["build/", "_dist/", "node_modules/", ".github/", ".superpowers/"]
+ },
+ "imports": {
+ "@tago-io/sdk": "npm:@tago-io/sdk@^12.2.1",
+ "@std/assert": "jsr:@std/assert@^1.0.19",
+ "@std/testing": "jsr:@std/testing@^1.0.18"
+ },
+ "compilerOptions": {
+ "strict": true,
+ "noImplicitAny": false,
+ "strictNullChecks": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "lib": ["ES2023", "DOM", "Deno.NS"]
+ },
+ "lint": {
+ "plugins": [
+ "jsr:@hugoalh/deno-lint-rules@^0.14.1"
+ ],
+ "rules": {
+ "exclude": ["no-explicit-any", "hugoalh/fmt-jsdoc", "hugoalh/no-duplicate-import-sources", "no-unversioned-import", "no-import-prefix"]
+ }
+ },
+ "exclude": ["build/", "node_modules/", "_dist/", "**/.vite/", ".claude/"],
+ "lock": false
+}
diff --git a/docs/Kickstarter - Guide.pdf b/docs/Kickstarter - Guide.pdf
deleted file mode 100644
index 9193830..0000000
Binary files a/docs/Kickstarter - Guide.pdf and /dev/null differ
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 6b18c10..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,5892 +0,0 @@
-{
- "name": "template-analysis",
- "version": "2.0.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "template-analysis",
- "version": "2.0.0",
- "license": "Copyright",
- "dependencies": {
- "@tago-io/sdk": "11.3.9",
- "async": "3.2.6",
- "axios": "1.8.4",
- "bson-objectid": "2.0.4",
- "geolib": "3.3.4",
- "luxon": "3.6.1",
- "puppeteer": "24.6.0"
- },
- "devDependencies": {
- "@biomejs/biome": "1.9.4",
- "@tago-io/builder": "3.1.3",
- "@types/async": "3.2.24",
- "@types/luxon": "3.6.2",
- "@types/uuid": "10.0.0",
- "husky": "9.1.7",
- "prettier": "3.5.3",
- "ts-node": "10.9.2",
- "ts-node-dev": "2.0.0",
- "typescript": "5.8.2",
- "unplugin-swc": "1.5.1",
- "uuid": "11.1.0",
- "vitest": "3.1.1"
- }
- },
- "node_modules/@babel/code-frame": {
- "version": "7.26.2",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
- "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-validator-identifier": "^7.25.9",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.0.0"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
- "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@biomejs/biome": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
- "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT OR Apache-2.0",
- "bin": {
- "biome": "bin/biome"
- },
- "engines": {
- "node": ">=14.21.3"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/biome"
- },
- "optionalDependencies": {
- "@biomejs/cli-darwin-arm64": "1.9.4",
- "@biomejs/cli-darwin-x64": "1.9.4",
- "@biomejs/cli-linux-arm64": "1.9.4",
- "@biomejs/cli-linux-arm64-musl": "1.9.4",
- "@biomejs/cli-linux-x64": "1.9.4",
- "@biomejs/cli-linux-x64-musl": "1.9.4",
- "@biomejs/cli-win32-arm64": "1.9.4",
- "@biomejs/cli-win32-x64": "1.9.4"
- }
- },
- "node_modules/@biomejs/cli-darwin-arm64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz",
- "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=14.21.3"
- }
- },
- "node_modules/@biomejs/cli-darwin-x64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
- "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=14.21.3"
- }
- },
- "node_modules/@biomejs/cli-linux-arm64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
- "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=14.21.3"
- }
- },
- "node_modules/@biomejs/cli-linux-arm64-musl": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
- "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=14.21.3"
- }
- },
- "node_modules/@biomejs/cli-linux-x64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
- "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=14.21.3"
- }
- },
- "node_modules/@biomejs/cli-linux-x64-musl": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
- "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=14.21.3"
- }
- },
- "node_modules/@biomejs/cli-win32-arm64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
- "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=14.21.3"
- }
- },
- "node_modules/@biomejs/cli-win32-x64": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
- "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT OR Apache-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=14.21.3"
- }
- },
- "node_modules/@cspotcode/source-map-support": {
- "version": "0.8.1",
- "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
- "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/trace-mapping": "0.3.9"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
- "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.13.tgz",
- "integrity": "sha512-RY2fVI8O0iFUNvZirXaQ1vMvK0xhCcl0gqRj74Z6yEiO1zAUa7hbsdwZM1kzqbxHK7LFyMizipfXT3JME+12Hw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz",
- "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz",
- "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz",
- "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz",
- "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz",
- "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz",
- "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz",
- "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz",
- "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz",
- "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.13.tgz",
- "integrity": "sha512-+BoyIm4I8uJmH/QDIH0fu7MG0AEx9OXEDXnqptXCwKOlOqZiS4iraH1Nr7/ObLMokW3sOCeBNyD68ATcV9b9Ag==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz",
- "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz",
- "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz",
- "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz",
- "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz",
- "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz",
- "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz",
- "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz",
- "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz",
- "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz",
- "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz",
- "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz",
- "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz",
- "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
- "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.9",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
- "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.0.3",
- "@jridgewell/sourcemap-codec": "^1.4.10"
- }
- },
- "node_modules/@pnpm/config.env-replace": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
- "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.22.0"
- }
- },
- "node_modules/@pnpm/network.ca-file": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz",
- "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "4.2.10"
- },
- "engines": {
- "node": ">=12.22.0"
- }
- },
- "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
- "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/@pnpm/npm-conf": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz",
- "integrity": "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@pnpm/config.env-replace": "^1.1.0",
- "@pnpm/network.ca-file": "^1.0.1",
- "config-chain": "^1.1.11"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@puppeteer/browsers": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.9.0.tgz",
- "integrity": "sha512-8+xM+cFydYET4X/5/3yZMHs7sjS6c9I6H5I3xJdb6cinzxWUT/I2QVw4avxCQ8QDndwdHkG/FiSZIrCjAbaKvQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "debug": "^4.4.0",
- "extract-zip": "^2.0.1",
- "progress": "^2.0.3",
- "proxy-agent": "^6.5.0",
- "semver": "^7.7.1",
- "tar-fs": "^3.0.8",
- "yargs": "^17.7.2"
- },
- "bin": {
- "browsers": "lib/cjs/main-cli.js"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@rollup/pluginutils": {
- "version": "5.1.4",
- "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
- "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0",
- "estree-walker": "^2.0.2",
- "picomatch": "^4.0.2"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
- },
- "peerDependenciesMeta": {
- "rollup": {
- "optional": true
- }
- }
- },
- "node_modules/@rollup/pluginutils/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
- "integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz",
- "integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz",
- "integrity": "sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz",
- "integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz",
- "integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz",
- "integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz",
- "integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz",
- "integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz",
- "integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz",
- "integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz",
- "integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz",
- "integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz",
- "integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz",
- "integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz",
- "integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz",
- "integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz",
- "integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz",
- "integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz",
- "integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz",
- "integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@sindresorhus/is": {
- "version": "5.6.0",
- "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz",
- "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/is?sponsor=1"
- }
- },
- "node_modules/@socket.io/component-emitter": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
- "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
- "license": "MIT"
- },
- "node_modules/@swc/core": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.16.tgz",
- "integrity": "sha512-wgjrJqVUss8Lxqilg0vkiE0tkEKU3mZkoybQM1Ehy+PKWwwB6lFAwKi20cAEFlSSWo8jFR8hRo19ZELAoLDowg==",
- "dev": true,
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "@swc/counter": "^0.1.3",
- "@swc/types": "^0.1.21"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/swc"
- },
- "optionalDependencies": {
- "@swc/core-darwin-arm64": "1.11.16",
- "@swc/core-darwin-x64": "1.11.16",
- "@swc/core-linux-arm-gnueabihf": "1.11.16",
- "@swc/core-linux-arm64-gnu": "1.11.16",
- "@swc/core-linux-arm64-musl": "1.11.16",
- "@swc/core-linux-x64-gnu": "1.11.16",
- "@swc/core-linux-x64-musl": "1.11.16",
- "@swc/core-win32-arm64-msvc": "1.11.16",
- "@swc/core-win32-ia32-msvc": "1.11.16",
- "@swc/core-win32-x64-msvc": "1.11.16"
- },
- "peerDependencies": {
- "@swc/helpers": "*"
- },
- "peerDependenciesMeta": {
- "@swc/helpers": {
- "optional": true
- }
- }
- },
- "node_modules/@swc/core-darwin-arm64": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.16.tgz",
- "integrity": "sha512-l6uWMU+MUdfLHCl3dJgtVEdsUHPskoA4BSu0L1hh9SGBwPZ8xeOz8iLIqZM27lTuXxL4KsYH6GQR/OdQ/vhLtg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "Apache-2.0 AND MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-darwin-x64": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.16.tgz",
- "integrity": "sha512-TH0IW8Ao1WZ4ARFHIh29dAQHYBEl4YnP74n++rjppmlCjY+8v3s5nXMA7IqxO3b5LVHyggWtU4+46DXTyMJM7g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "Apache-2.0 AND MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-arm-gnueabihf": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.16.tgz",
- "integrity": "sha512-2IxD9t09oNZrbv37p4cJ9cTHMUAK6qNiShi9s2FJ9LcqSnZSN4iS4hvaaX6KZuG54d58vWnMU7yycjkdOTQcMg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-arm64-gnu": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.16.tgz",
- "integrity": "sha512-AYkN23DOiPh1bf3XBf/xzZQDKSsgZTxlbyTyUIhprLJpAAAT0ZCGAUcS5mHqydk0nWQ13ABUymodvHoroutNzw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "Apache-2.0 AND MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-arm64-musl": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.16.tgz",
- "integrity": "sha512-n/nWXDRCIhM51dDGELfBcTMNnCiFatE7LDvsbYxb7DJt1HGjaCNvHHCKURb/apJTh/YNtWfgFap9dbsTgw8yPA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "Apache-2.0 AND MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-x64-gnu": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.16.tgz",
- "integrity": "sha512-xr182YQrF47n7Awxj+/ruI21bYw+xO/B26KFVnb+i3ezF9NOhqoqTX+33RL1ZLA/uFTq8ksPZO/y+ZVS/odtQA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "Apache-2.0 AND MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-linux-x64-musl": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.16.tgz",
- "integrity": "sha512-k2JBfiwWfXCIKrBRjFO9/vEdLSYq0QLJ+iNSLdfrejZ/aENNkbEg8O7O2GKUSb30RBacn6k8HMfJrcPLFiEyCQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "Apache-2.0 AND MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-win32-arm64-msvc": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.16.tgz",
- "integrity": "sha512-taOb5U+abyEhQgex+hr6cI48BoqSvSdfmdirWcxprIEUBHCxa1dSriVwnJRAJOFI9T+5BEz88by6rgbB9MjbHA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "Apache-2.0 AND MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-win32-ia32-msvc": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.16.tgz",
- "integrity": "sha512-b7yYggM9LBDiMY+XUt5kYWvs5sn0U3PXSOGvF3CbLufD/N/YQiDcYON2N3lrWHYL8aYnwbuZl45ojmQHSQPcdA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "Apache-2.0 AND MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/core-win32-x64-msvc": {
- "version": "1.11.16",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.16.tgz",
- "integrity": "sha512-/ibq/YDc3B5AROkpOKPGxVkSyCKOg+ml8k11RxrW7FAPy6a9y5y9KPcWIqV74Ahq4RuaMNslTQqHWAGSm0xJsQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "Apache-2.0 AND MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@swc/counter": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
- "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
- "dev": true,
- "license": "Apache-2.0",
- "peer": true
- },
- "node_modules/@swc/types": {
- "version": "0.1.21",
- "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz",
- "integrity": "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==",
- "dev": true,
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "@swc/counter": "^0.1.3"
- }
- },
- "node_modules/@szmarczak/http-timer": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
- "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "defer-to-connect": "^2.0.1"
- },
- "engines": {
- "node": ">=14.16"
- }
- },
- "node_modules/@tago-io/builder": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/@tago-io/builder/-/builder-3.1.3.tgz",
- "integrity": "sha512-TWyqDomBiHz6merW/hZ8lIsg7Aqk7KUMEa8iM+ZixyfcjlNTGop9UkrToUh/KIIV2/dioVGNsfMMHJxicDcWfg==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "chalk": "5.1.2",
- "commander": "9.4.1",
- "esbuild": "0.15.13",
- "luxon": "3.1.0",
- "typescript": "4.8.4",
- "update-notifier": "6.0.2"
- },
- "bin": {
- "analysis-builder": "index.js",
- "tago-builder": "index.js"
- },
- "engines": {
- "node": ">=14.0.0",
- "npm": ">=6.0.0"
- }
- },
- "node_modules/@tago-io/builder/node_modules/luxon": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.0.tgz",
- "integrity": "sha512-7w6hmKC0/aoWnEsmPCu5Br54BmbmUp5GfcqBxQngRcXJ+q5fdfjEzn7dxmJh2YdDhgW8PccYtlWKSv4tQkrTQg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@tago-io/builder/node_modules/typescript": {
- "version": "4.8.4",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
- "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=4.2.0"
- }
- },
- "node_modules/@tago-io/sdk": {
- "version": "11.3.9",
- "resolved": "https://registry.npmjs.org/@tago-io/sdk/-/sdk-11.3.9.tgz",
- "integrity": "sha512-xZo7mSzjfy4RhY3zY8fK2QCwsXOymWtfemwp3MqWqemrZJwt8wHFyOxL1trQSYCtniL2kMt1PNgiIrkgce31/A==",
- "license": "Apache-2.0",
- "dependencies": {
- "axios": "1.7.4",
- "form-data": "4.0.0",
- "nanoid": "3.3.7",
- "papaparse": "5.4.1",
- "qs": "6.12.0",
- "socket.io-client": "4.7.5"
- },
- "engines": {
- "node": ">=16.0.0",
- "npm": ">=6.0.0"
- },
- "optionalDependencies": {
- "eventsource": "2.0.2"
- }
- },
- "node_modules/@tago-io/sdk/node_modules/axios": {
- "version": "1.7.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
- "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.0",
- "proxy-from-env": "^1.1.0"
- }
- },
- "node_modules/@tootallnate/quickjs-emscripten": {
- "version": "0.23.0",
- "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
- "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
- "license": "MIT"
- },
- "node_modules/@tsconfig/node10": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
- "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@tsconfig/node12": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
- "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@tsconfig/node14": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
- "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@tsconfig/node16": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
- "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/async": {
- "version": "3.2.24",
- "resolved": "https://registry.npmjs.org/@types/async/-/async-3.2.24.tgz",
- "integrity": "sha512-8iHVLHsCCOBKjCF2KwFe0p9Z3rfM9mL+sSP8btyR5vTjJRAqpBYD28/ZLgXPf0pjG1VxOvtCV/BgXkQbpSe8Hw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/estree": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
- "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/http-cache-semantics": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
- "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/luxon": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
- "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/node": {
- "version": "22.14.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
- "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
- "devOptional": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": "~6.21.0"
- }
- },
- "node_modules/@types/strip-bom": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz",
- "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/strip-json-comments": {
- "version": "0.0.30",
- "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz",
- "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/uuid": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
- "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/yauzl": {
- "version": "2.10.3",
- "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
- "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/@vitest/expect": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz",
- "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "3.1.1",
- "@vitest/utils": "3.1.1",
- "chai": "^5.2.0",
- "tinyrainbow": "^2.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz",
- "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "3.1.1",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.17"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^5.0.0 || ^6.0.0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/mocker/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/@vitest/pretty-format": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz",
- "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^2.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/runner": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz",
- "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "3.1.1",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/snapshot": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz",
- "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "3.1.1",
- "magic-string": "^0.30.17",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/spy": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz",
- "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyspy": "^3.0.2"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/utils": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz",
- "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "3.1.1",
- "loupe": "^3.1.3",
- "tinyrainbow": "^2.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/acorn": {
- "version": "8.14.1",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
- "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-walk": {
- "version": "8.3.4",
- "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
- "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.11.0"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/agent-base": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
- "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
- "license": "MIT",
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/ansi-align": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
- "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "string-width": "^4.1.0"
- }
- },
- "node_modules/ansi-align/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-align/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/ansi-align/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-align/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/anymatch": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
- "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/arg": {
- "version": "4.1.3",
- "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
- "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "license": "Python-2.0"
- },
- "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/ast-types": {
- "version": "0.13.4",
- "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
- "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.0.1"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/async": {
- "version": "3.2.6",
- "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
- "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
- "license": "MIT"
- },
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "license": "MIT"
- },
- "node_modules/axios": {
- "version": "1.8.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
- "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.0",
- "proxy-from-env": "^1.1.0"
- }
- },
- "node_modules/b4a": {
- "version": "1.6.7",
- "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
- "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==",
- "license": "Apache-2.0"
- },
- "node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/bare-events": {
- "version": "2.5.4",
- "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
- "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
- "license": "Apache-2.0",
- "optional": true
- },
- "node_modules/bare-fs": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.2.tgz",
- "integrity": "sha512-8wSeOia5B7LwD4+h465y73KOdj5QHsbbuoUfPBi+pXgFJIPuG7SsiOdJuijWMyfid49eD+WivpfY7KT8gbAzBA==",
- "license": "Apache-2.0",
- "optional": true,
- "dependencies": {
- "bare-events": "^2.5.4",
- "bare-path": "^3.0.0",
- "bare-stream": "^2.6.4"
- },
- "engines": {
- "bare": ">=1.16.0"
- },
- "peerDependencies": {
- "bare-buffer": "*"
- },
- "peerDependenciesMeta": {
- "bare-buffer": {
- "optional": true
- }
- }
- },
- "node_modules/bare-os": {
- "version": "3.6.1",
- "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz",
- "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==",
- "license": "Apache-2.0",
- "optional": true,
- "engines": {
- "bare": ">=1.14.0"
- }
- },
- "node_modules/bare-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
- "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
- "license": "Apache-2.0",
- "optional": true,
- "dependencies": {
- "bare-os": "^3.0.1"
- }
- },
- "node_modules/bare-stream": {
- "version": "2.6.5",
- "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
- "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
- "license": "Apache-2.0",
- "optional": true,
- "dependencies": {
- "streamx": "^2.21.0"
- },
- "peerDependencies": {
- "bare-buffer": "*",
- "bare-events": "*"
- },
- "peerDependenciesMeta": {
- "bare-buffer": {
- "optional": true
- },
- "bare-events": {
- "optional": true
- }
- }
- },
- "node_modules/basic-ftp": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
- "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- }
- },
- "node_modules/binary-extensions": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
- "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/boxen": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz",
- "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-align": "^3.0.1",
- "camelcase": "^7.0.1",
- "chalk": "^5.2.0",
- "cli-boxes": "^3.0.0",
- "string-width": "^5.1.2",
- "type-fest": "^2.13.0",
- "widest-line": "^4.0.1",
- "wrap-ansi": "^8.1.0"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/boxen/node_modules/chalk": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
- "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
- "node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fill-range": "^7.1.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/bson-objectid": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/bson-objectid/-/bson-objectid-2.0.4.tgz",
- "integrity": "sha512-vgnKAUzcDoa+AeyYwXCoHyF2q6u/8H46dxu5JN+4/TZeq/Dlinn0K6GvxsCLb3LHUJl0m/TLiEK31kUwtgocMQ==",
- "license": "Apache-2.0"
- },
- "node_modules/buffer-crc32": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
- "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
- "license": "MIT",
- "engines": {
- "node": "*"
- }
- },
- "node_modules/buffer-from": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cacheable-lookup": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
- "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.16"
- }
- },
- "node_modules/cacheable-request": {
- "version": "10.2.14",
- "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz",
- "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/http-cache-semantics": "^4.0.2",
- "get-stream": "^6.0.1",
- "http-cache-semantics": "^4.1.1",
- "keyv": "^4.5.3",
- "mimic-response": "^4.0.0",
- "normalize-url": "^8.0.0",
- "responselike": "^3.0.0"
- },
- "engines": {
- "node": ">=14.16"
- }
- },
- "node_modules/cacheable-request/node_modules/get-stream": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
- "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/call-bind-apply-helpers": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/call-bound": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
- "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "get-intrinsic": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/callsites": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
- "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/camelcase": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz",
- "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/chai": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
- "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "assertion-error": "^2.0.1",
- "check-error": "^2.1.1",
- "deep-eql": "^5.0.1",
- "loupe": "^3.1.0",
- "pathval": "^2.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/chalk": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.1.2.tgz",
- "integrity": "sha512-E5CkT4jWURs1Vy5qGJye+XwCkNj7Od3Af7CP6SujMetSMkLs8Do2RWJK5yx1wamHV/op8Rz+9rltjaTQWDnEFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
- "node_modules/check-error": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
- "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 16"
- }
- },
- "node_modules/chokidar": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
- "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
- "engines": {
- "node": ">= 8.10.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/chromium-bidi": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-3.0.0.tgz",
- "integrity": "sha512-ZOGRDAhBMX1uxL2Cm2TDuhImbrsEz5A/tTcVU6RpXEWaTNUNwsHW6njUXizh51Ir6iqHbKAfhA2XK33uBcLo5A==",
- "license": "Apache-2.0",
- "dependencies": {
- "mitt": "^3.0.1",
- "zod": "^3.24.1"
- },
- "peerDependencies": {
- "devtools-protocol": "*"
- }
- },
- "node_modules/ci-info": {
- "version": "3.9.0",
- "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
- "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/sibiraj-s"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cli-boxes": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
- "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/cliui": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
- "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
- "license": "ISC",
- "dependencies": {
- "string-width": "^4.2.0",
- "strip-ansi": "^6.0.1",
- "wrap-ansi": "^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/cliui/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cliui/node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/cliui/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT"
- },
- "node_modules/cliui/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cliui/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cliui/node_modules/wrap-ansi": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "license": "MIT"
- },
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "license": "MIT",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/commander": {
- "version": "9.4.1",
- "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz",
- "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || >=14"
- }
- },
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/config-chain": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
- "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ini": "^1.3.4",
- "proto-list": "~1.2.1"
- }
- },
- "node_modules/config-chain/node_modules/ini": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/configstore": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz",
- "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "dot-prop": "^6.0.1",
- "graceful-fs": "^4.2.6",
- "unique-string": "^3.0.0",
- "write-file-atomic": "^3.0.3",
- "xdg-basedir": "^5.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/yeoman/configstore?sponsor=1"
- }
- },
- "node_modules/cosmiconfig": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
- "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
- "license": "MIT",
- "dependencies": {
- "env-paths": "^2.2.1",
- "import-fresh": "^3.3.0",
- "js-yaml": "^4.1.0",
- "parse-json": "^5.2.0"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/d-fischer"
- },
- "peerDependencies": {
- "typescript": ">=4.9.5"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/create-require": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
- "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/crypto-random-string": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
- "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "type-fest": "^1.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/crypto-random-string/node_modules/type-fest": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
- "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
- "dev": true,
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/data-uri-to-buffer": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
- "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
- "license": "MIT",
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/debug": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/decompress-response": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mimic-response": "^3.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/decompress-response/node_modules/mimic-response": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/deep-eql": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
- "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/deep-extend": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4.0.0"
- }
- },
- "node_modules/defer-to-connect": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
- "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/degenerator": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
- "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
- "license": "MIT",
- "dependencies": {
- "ast-types": "^0.13.4",
- "escodegen": "^2.1.0",
- "esprima": "^4.0.1"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/devtools-protocol": {
- "version": "0.0.1425554",
- "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1425554.tgz",
- "integrity": "sha512-uRfxR6Nlzdzt0ihVIkV+sLztKgs7rgquY/Mhcv1YNCWDh5IZgl5mnn2aeEnW5stYTE0wwiF4RYVz8eMEpV1SEw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/diff": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
- "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.3.1"
- }
- },
- "node_modules/dot-prop": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
- "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-obj": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/dunder-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "es-errors": "^1.3.0",
- "gopd": "^1.2.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/dynamic-dedupe": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz",
- "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "xtend": "^4.0.0"
- }
- },
- "node_modules/eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/emoji-regex": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/end-of-stream": {
- "version": "1.4.4",
- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
- "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
- "license": "MIT",
- "dependencies": {
- "once": "^1.4.0"
- }
- },
- "node_modules/engine.io-client": {
- "version": "6.5.4",
- "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz",
- "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==",
- "license": "MIT",
- "dependencies": {
- "@socket.io/component-emitter": "~3.1.0",
- "debug": "~4.3.1",
- "engine.io-parser": "~5.2.1",
- "ws": "~8.17.1",
- "xmlhttprequest-ssl": "~2.0.0"
- }
- },
- "node_modules/engine.io-client/node_modules/debug": {
- "version": "4.3.7",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
- "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/engine.io-client/node_modules/ws": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
- "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
- "node_modules/engine.io-parser": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
- "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- }
- },
- "node_modules/env-paths": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
- "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/error-ex": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
- "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
- "license": "MIT",
- "dependencies": {
- "is-arrayish": "^0.2.1"
- }
- },
- "node_modules/es-define-property": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-module-lexer": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
- "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/esbuild": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.13.tgz",
- "integrity": "sha512-Cu3SC84oyzzhrK/YyN4iEVy2jZu5t2fz66HEOShHURcjSkOSAVL8C/gfUT+lDJxkVHpg8GZ10DD0rMHRPqMFaQ==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=12"
- },
- "optionalDependencies": {
- "@esbuild/android-arm": "0.15.13",
- "@esbuild/linux-loong64": "0.15.13",
- "esbuild-android-64": "0.15.13",
- "esbuild-android-arm64": "0.15.13",
- "esbuild-darwin-64": "0.15.13",
- "esbuild-darwin-arm64": "0.15.13",
- "esbuild-freebsd-64": "0.15.13",
- "esbuild-freebsd-arm64": "0.15.13",
- "esbuild-linux-32": "0.15.13",
- "esbuild-linux-64": "0.15.13",
- "esbuild-linux-arm": "0.15.13",
- "esbuild-linux-arm64": "0.15.13",
- "esbuild-linux-mips64le": "0.15.13",
- "esbuild-linux-ppc64le": "0.15.13",
- "esbuild-linux-riscv64": "0.15.13",
- "esbuild-linux-s390x": "0.15.13",
- "esbuild-netbsd-64": "0.15.13",
- "esbuild-openbsd-64": "0.15.13",
- "esbuild-sunos-64": "0.15.13",
- "esbuild-windows-32": "0.15.13",
- "esbuild-windows-64": "0.15.13",
- "esbuild-windows-arm64": "0.15.13"
- }
- },
- "node_modules/esbuild-android-64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.13.tgz",
- "integrity": "sha512-yRorukXBlokwTip+Sy4MYskLhJsO0Kn0/Fj43s1krVblfwP+hMD37a4Wmg139GEsMLl+vh8WXp2mq/cTA9J97g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-android-arm64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.13.tgz",
- "integrity": "sha512-TKzyymLD6PiVeyYa4c5wdPw87BeAiTXNtK6amWUcXZxkV51gOk5u5qzmDaYSwiWeecSNHamFsaFjLoi32QR5/w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-darwin-64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.13.tgz",
- "integrity": "sha512-WAx7c2DaOS6CrRcoYCgXgkXDliLnFv3pQLV6GeW1YcGEZq2Gnl8s9Pg7ahValZkpOa0iE/ojRVQ87sbUhF1Cbg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-darwin-arm64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.13.tgz",
- "integrity": "sha512-U6jFsPfSSxC3V1CLiQqwvDuj3GGrtQNB3P3nNC3+q99EKf94UGpsG9l4CQ83zBs1NHrk1rtCSYT0+KfK5LsD8A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-freebsd-64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.13.tgz",
- "integrity": "sha512-whItJgDiOXaDG/idy75qqevIpZjnReZkMGCgQaBWZuKHoElDJC1rh7MpoUgupMcdfOd+PgdEwNQW9DAE6i8wyA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-freebsd-arm64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.13.tgz",
- "integrity": "sha512-6pCSWt8mLUbPtygv7cufV0sZLeylaMwS5Fznj6Rsx9G2AJJsAjQ9ifA+0rQEIg7DwJmi9it+WjzNTEAzzdoM3Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-linux-32": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.13.tgz",
- "integrity": "sha512-VbZdWOEdrJiYApm2kkxoTOgsoCO1krBZ3quHdYk3g3ivWaMwNIVPIfEE0f0XQQ0u5pJtBsnk2/7OPiCFIPOe/w==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-linux-64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.13.tgz",
- "integrity": "sha512-rXmnArVNio6yANSqDQlIO4WiP+Cv7+9EuAHNnag7rByAqFVuRusLbGi2697A5dFPNXoO//IiogVwi3AdcfPC6A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-linux-arm": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.13.tgz",
- "integrity": "sha512-Ac6LpfmJO8WhCMQmO253xX2IU2B3wPDbl4IvR0hnqcPrdfCaUa2j/lLMGTjmQ4W5JsJIdHEdW12dG8lFS0MbxQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-linux-arm64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.13.tgz",
- "integrity": "sha512-alEMGU4Z+d17U7KQQw2IV8tQycO6T+rOrgW8OS22Ua25x6kHxoG6Ngry6Aq6uranC+pNWNMB6aHFPh7aTQdORQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-linux-mips64le": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.13.tgz",
- "integrity": "sha512-47PgmyYEu+yN5rD/MbwS6DxP2FSGPo4Uxg5LwIdxTiyGC2XKwHhHyW7YYEDlSuXLQXEdTO7mYe8zQ74czP7W8A==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-linux-ppc64le": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.13.tgz",
- "integrity": "sha512-z6n28h2+PC1Ayle9DjKoBRcx/4cxHoOa2e689e2aDJSaKug3jXcQw7mM+GLg+9ydYoNzj8QxNL8ihOv/OnezhA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-linux-riscv64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.13.tgz",
- "integrity": "sha512-+Lu4zuuXuQhgLUGyZloWCqTslcCAjMZH1k3Xc9MSEJEpEFdpsSU0sRDXAnk18FKOfEjhu4YMGaykx9xjtpA6ow==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-linux-s390x": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.13.tgz",
- "integrity": "sha512-BMeXRljruf7J0TMxD5CIXS65y7puiZkAh+s4XFV9qy16SxOuMhxhVIXYLnbdfLrsYGFzx7U9mcdpFWkkvy/Uag==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-netbsd-64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.13.tgz",
- "integrity": "sha512-EHj9QZOTel581JPj7UO3xYbltFTYnHy+SIqJVq6yd3KkCrsHRbapiPb0Lx3EOOtybBEE9EyqbmfW1NlSDsSzvQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-openbsd-64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.13.tgz",
- "integrity": "sha512-nkuDlIjF/sfUhfx8SKq0+U+Fgx5K9JcPq1mUodnxI0x4kBdCv46rOGWbuJ6eof2n3wdoCLccOoJAbg9ba/bT2w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-sunos-64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.13.tgz",
- "integrity": "sha512-jVeu2GfxZQ++6lRdY43CS0Tm/r4WuQQ0Pdsrxbw+aOrHQPHV0+LNOLnvbN28M7BSUGnJnHkHm2HozGgNGyeIRw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-windows-32": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.13.tgz",
- "integrity": "sha512-XoF2iBf0wnqo16SDq+aDGi/+QbaLFpkiRarPVssMh9KYbFNCqPLlGAWwDvxEVz+ywX6Si37J2AKm+AXq1kC0JA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-windows-64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.13.tgz",
- "integrity": "sha512-Et6htEfGycjDrtqb2ng6nT+baesZPYQIW+HUEHK4D1ncggNrDNk3yoboYQ5KtiVrw/JaDMNttz8rrPubV/fvPQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/esbuild-windows-arm64": {
- "version": "0.15.13",
- "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.13.tgz",
- "integrity": "sha512-3bv7tqntThQC9SWLRouMDmZnlOukBhOCTlkzNqzGCmrkCJI7io5LLjwJBOVY6kOUlIvdxbooNZwjtBvj+7uuVg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/escalade": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/escape-goat": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz",
- "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/escodegen": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
- "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
- "license": "BSD-2-Clause",
- "dependencies": {
- "esprima": "^4.0.1",
- "estraverse": "^5.2.0",
- "esutils": "^2.0.2"
- },
- "bin": {
- "escodegen": "bin/escodegen.js",
- "esgenerate": "bin/esgenerate.js"
- },
- "engines": {
- "node": ">=6.0"
- },
- "optionalDependencies": {
- "source-map": "~0.6.1"
- }
- },
- "node_modules/esprima": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
- "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "license": "BSD-2-Clause",
- "bin": {
- "esparse": "bin/esparse.js",
- "esvalidate": "bin/esvalidate.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estree-walker": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
- "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/esutils": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
- "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/eventsource": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
- "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/expect-type": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
- "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/extract-zip": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
- "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
- "license": "BSD-2-Clause",
- "dependencies": {
- "debug": "^4.1.1",
- "get-stream": "^5.1.0",
- "yauzl": "^2.10.0"
- },
- "bin": {
- "extract-zip": "cli.js"
- },
- "engines": {
- "node": ">= 10.17.0"
- },
- "optionalDependencies": {
- "@types/yauzl": "^2.9.1"
- }
- },
- "node_modules/fast-fifo": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
- "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
- "license": "MIT"
- },
- "node_modules/fd-slicer": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
- "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
- "license": "MIT",
- "dependencies": {
- "pend": "~1.2.0"
- }
- },
- "node_modules/fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/follow-redirects": {
- "version": "1.15.9",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
- "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
- "node_modules/form-data": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
- "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
- "license": "MIT",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/form-data-encoder": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz",
- "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.17"
- }
- },
- "node_modules/fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
- "dev": true,
- "license": "ISC"
- },
- "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/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/geolib": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.4.tgz",
- "integrity": "sha512-EicrlLLL3S42gE9/wde+11uiaYAaeSVDwCUIv2uMIoRBfNJCn8EsSI+6nS3r4TCKDO6+RQNM9ayLq2at+oZQWQ==",
- "license": "MIT"
- },
- "node_modules/get-caller-file": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
- "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "license": "ISC",
- "engines": {
- "node": "6.* || 8.* || >= 10.*"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "es-define-property": "^1.0.1",
- "es-errors": "^1.3.0",
- "es-object-atoms": "^1.1.1",
- "function-bind": "^1.1.2",
- "get-proto": "^1.0.1",
- "gopd": "^1.2.0",
- "has-symbols": "^1.1.0",
- "hasown": "^2.0.2",
- "math-intrinsics": "^1.1.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "license": "MIT",
- "dependencies": {
- "dunder-proto": "^1.0.1",
- "es-object-atoms": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/get-stream": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
- "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
- "license": "MIT",
- "dependencies": {
- "pump": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/get-uri": {
- "version": "6.0.4",
- "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz",
- "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==",
- "license": "MIT",
- "dependencies": {
- "basic-ftp": "^5.0.2",
- "data-uri-to-buffer": "^6.0.2",
- "debug": "^4.3.4"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "deprecated": "Glob versions prior to v9 are no longer supported",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- },
- "engines": {
- "node": "*"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/global-dirs": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz",
- "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ini": "2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/gopd": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/got": {
- "version": "12.6.1",
- "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz",
- "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@sindresorhus/is": "^5.2.0",
- "@szmarczak/http-timer": "^5.0.1",
- "cacheable-lookup": "^7.0.0",
- "cacheable-request": "^10.2.8",
- "decompress-response": "^6.0.0",
- "form-data-encoder": "^2.1.2",
- "get-stream": "^6.0.1",
- "http2-wrapper": "^2.1.10",
- "lowercase-keys": "^3.0.0",
- "p-cancelable": "^3.0.0",
- "responselike": "^3.0.0"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/got?sponsor=1"
- }
- },
- "node_modules/got/node_modules/get-stream": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
- "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/has-symbols": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-yarn": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-3.0.0.tgz",
- "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/http-cache-semantics": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
- "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
- "dev": true,
- "license": "BSD-2-Clause"
- },
- "node_modules/http-proxy-agent": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
- "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
- "license": "MIT",
- "dependencies": {
- "agent-base": "^7.1.0",
- "debug": "^4.3.4"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/http2-wrapper": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
- "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "quick-lru": "^5.1.1",
- "resolve-alpn": "^1.2.0"
- },
- "engines": {
- "node": ">=10.19.0"
- }
- },
- "node_modules/https-proxy-agent": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
- "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
- "license": "MIT",
- "dependencies": {
- "agent-base": "^7.1.2",
- "debug": "4"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/husky": {
- "version": "9.1.7",
- "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
- "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "husky": "bin.js"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/typicode"
- }
- },
- "node_modules/import-fresh": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
- "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
- "license": "MIT",
- "dependencies": {
- "parent-module": "^1.0.0",
- "resolve-from": "^4.0.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/import-lazy": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
- "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
- "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
- },
- "node_modules/inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
- "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
- "node_modules/inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/ini": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz",
- "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/ip-address": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
- "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
- "license": "MIT",
- "dependencies": {
- "jsbn": "1.1.0",
- "sprintf-js": "^1.1.3"
- },
- "engines": {
- "node": ">= 12"
- }
- },
- "node_modules/is-arrayish": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
- "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
- "license": "MIT"
- },
- "node_modules/is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "binary-extensions": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-ci": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
- "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ci-info": "^3.2.0"
- },
- "bin": {
- "is-ci": "bin.js"
- }
- },
- "node_modules/is-core-module": {
- "version": "2.16.1",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
- "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "hasown": "^2.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-installed-globally": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz",
- "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "global-dirs": "^3.0.0",
- "is-path-inside": "^3.0.2"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/is-npm": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz",
- "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
- "node_modules/is-obj": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
- "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-path-inside": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
- "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-typedarray": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
- "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/is-yarn-global": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.4.1.tgz",
- "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "license": "MIT"
- },
- "node_modules/js-yaml": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
- "node_modules/jsbn": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
- "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
- "license": "MIT"
- },
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-parse-even-better-errors": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
- "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
- "license": "MIT"
- },
- "node_modules/keyv": {
- "version": "4.5.4",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "json-buffer": "3.0.1"
- }
- },
- "node_modules/latest-version": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz",
- "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "package-json": "^8.1.0"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/lines-and-columns": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
- "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
- "license": "MIT"
- },
- "node_modules/load-tsconfig": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz",
- "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- }
- },
- "node_modules/loupe": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
- "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/lowercase-keys": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
- "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/lru-cache": {
- "version": "7.18.3",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
- "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
- "license": "ISC",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/luxon": {
- "version": "3.6.1",
- "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
- "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/magic-string": {
- "version": "0.30.17",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
- "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0"
- }
- },
- "node_modules/make-error": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
- "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/math-intrinsics": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mimic-response": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
- "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/minimist": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/mitt": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
- "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
- "license": "MIT"
- },
- "node_modules/mkdirp": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
- "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "mkdirp": "bin/cmd.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
- "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/netmask": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
- "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4.0"
- }
- },
- "node_modules/normalize-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/normalize-url": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz",
- "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/object-inspect": {
- "version": "1.13.4",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
- "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "license": "ISC",
- "dependencies": {
- "wrappy": "1"
- }
- },
- "node_modules/p-cancelable": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
- "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.20"
- }
- },
- "node_modules/pac-proxy-agent": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
- "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
- "license": "MIT",
- "dependencies": {
- "@tootallnate/quickjs-emscripten": "^0.23.0",
- "agent-base": "^7.1.2",
- "debug": "^4.3.4",
- "get-uri": "^6.0.1",
- "http-proxy-agent": "^7.0.0",
- "https-proxy-agent": "^7.0.6",
- "pac-resolver": "^7.0.1",
- "socks-proxy-agent": "^8.0.5"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/pac-resolver": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
- "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
- "license": "MIT",
- "dependencies": {
- "degenerator": "^5.0.0",
- "netmask": "^2.0.2"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/package-json": {
- "version": "8.1.1",
- "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz",
- "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "got": "^12.1.0",
- "registry-auth-token": "^5.0.1",
- "registry-url": "^6.0.0",
- "semver": "^7.3.7"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/papaparse": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
- "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==",
- "license": "MIT"
- },
- "node_modules/parent-module": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
- "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
- "license": "MIT",
- "dependencies": {
- "callsites": "^3.0.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/parse-json": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
- "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.0.0",
- "error-ex": "^1.3.1",
- "json-parse-even-better-errors": "^2.3.0",
- "lines-and-columns": "^1.1.6"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/path-is-absolute": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
- "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/path-parse": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
- "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true,
- "license": "MIT"
- },
- "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/pathval": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
- "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.16"
- }
- },
- "node_modules/pend": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
- "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
- "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==",
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.3",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
- "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
- "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.8",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/postcss/node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "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/prettier": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
- "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "prettier": "bin/prettier.cjs"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
- }
- },
- "node_modules/progress": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
- "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/proto-list": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
- "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/proxy-agent": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
- "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
- "license": "MIT",
- "dependencies": {
- "agent-base": "^7.1.2",
- "debug": "^4.3.4",
- "http-proxy-agent": "^7.0.1",
- "https-proxy-agent": "^7.0.6",
- "lru-cache": "^7.14.1",
- "pac-proxy-agent": "^7.1.0",
- "proxy-from-env": "^1.1.0",
- "socks-proxy-agent": "^8.0.5"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
- "license": "MIT"
- },
- "node_modules/pump": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
- "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
- "license": "MIT",
- "dependencies": {
- "end-of-stream": "^1.1.0",
- "once": "^1.3.1"
- }
- },
- "node_modules/pupa": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz",
- "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "escape-goat": "^4.0.0"
- },
- "engines": {
- "node": ">=12.20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/puppeteer": {
- "version": "24.6.0",
- "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.6.0.tgz",
- "integrity": "sha512-wYTB8WkzAr7acrlsp+0at1PZjOJPOxe6dDWKOG/kaX4Zjck9RXCFx3CtsxsAGzPn/Yv6AzgJC/CW1P5l+qxsqw==",
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@puppeteer/browsers": "2.9.0",
- "chromium-bidi": "3.0.0",
- "cosmiconfig": "^9.0.0",
- "devtools-protocol": "0.0.1425554",
- "puppeteer-core": "24.6.0",
- "typed-query-selector": "^2.12.0"
- },
- "bin": {
- "puppeteer": "lib/cjs/puppeteer/node/cli.js"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/puppeteer-core": {
- "version": "24.6.0",
- "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.6.0.tgz",
- "integrity": "sha512-Cukxysy12m0v350bhl/Gzof0XQYmtON9l2VvGp3D4BOQZVgyf+y5wIpcjDZQ/896Okoi95dKRGRV8E6a7SYAQQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@puppeteer/browsers": "2.9.0",
- "chromium-bidi": "3.0.0",
- "debug": "^4.4.0",
- "devtools-protocol": "0.0.1425554",
- "typed-query-selector": "^2.12.0",
- "ws": "^8.18.1"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/qs": {
- "version": "6.12.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz",
- "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "side-channel": "^1.0.6"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/quick-lru": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
- "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/rc": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
- "dev": true,
- "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
- "dependencies": {
- "deep-extend": "^0.6.0",
- "ini": "~1.3.0",
- "minimist": "^1.2.0",
- "strip-json-comments": "~2.0.1"
- },
- "bin": {
- "rc": "cli.js"
- }
- },
- "node_modules/rc/node_modules/ini": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/readdirp": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
- "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "picomatch": "^2.2.1"
- },
- "engines": {
- "node": ">=8.10.0"
- }
- },
- "node_modules/registry-auth-token": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.0.tgz",
- "integrity": "sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@pnpm/npm-conf": "^2.1.0"
- },
- "engines": {
- "node": ">=14"
- }
- },
- "node_modules/registry-url": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz",
- "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "rc": "1.2.8"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/require-directory": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
- "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/resolve": {
- "version": "1.22.10",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
- "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-core-module": "^2.16.0",
- "path-parse": "^1.0.7",
- "supports-preserve-symlinks-flag": "^1.0.0"
- },
- "bin": {
- "resolve": "bin/resolve"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/resolve-alpn": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
- "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/resolve-from": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
- "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/responselike": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz",
- "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "lowercase-keys": "^3.0.0"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/rimraf": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
- "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- }
- },
- "node_modules/rollup": {
- "version": "4.39.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz",
- "integrity": "sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.7"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.39.0",
- "@rollup/rollup-android-arm64": "4.39.0",
- "@rollup/rollup-darwin-arm64": "4.39.0",
- "@rollup/rollup-darwin-x64": "4.39.0",
- "@rollup/rollup-freebsd-arm64": "4.39.0",
- "@rollup/rollup-freebsd-x64": "4.39.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.39.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.39.0",
- "@rollup/rollup-linux-arm64-gnu": "4.39.0",
- "@rollup/rollup-linux-arm64-musl": "4.39.0",
- "@rollup/rollup-linux-loongarch64-gnu": "4.39.0",
- "@rollup/rollup-linux-powerpc64le-gnu": "4.39.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.39.0",
- "@rollup/rollup-linux-riscv64-musl": "4.39.0",
- "@rollup/rollup-linux-s390x-gnu": "4.39.0",
- "@rollup/rollup-linux-x64-gnu": "4.39.0",
- "@rollup/rollup-linux-x64-musl": "4.39.0",
- "@rollup/rollup-win32-arm64-msvc": "4.39.0",
- "@rollup/rollup-win32-ia32-msvc": "4.39.0",
- "@rollup/rollup-win32-x64-msvc": "4.39.0",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/semver-diff": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz",
- "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^7.3.5"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/side-channel": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
- "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "object-inspect": "^1.13.3",
- "side-channel-list": "^1.0.0",
- "side-channel-map": "^1.0.1",
- "side-channel-weakmap": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/side-channel-list": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
- "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "object-inspect": "^1.13.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/side-channel-map": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
- "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
- "license": "MIT",
- "dependencies": {
- "call-bound": "^1.0.2",
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.5",
- "object-inspect": "^1.13.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/side-channel-weakmap": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
- "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
- "license": "MIT",
- "dependencies": {
- "call-bound": "^1.0.2",
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.5",
- "object-inspect": "^1.13.3",
- "side-channel-map": "^1.0.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "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/signal-exit": {
- "version": "3.0.7",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
- "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/smart-buffer": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
- "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
- "license": "MIT",
- "engines": {
- "node": ">= 6.0.0",
- "npm": ">= 3.0.0"
- }
- },
- "node_modules/socket.io-client": {
- "version": "4.7.5",
- "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz",
- "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==",
- "license": "MIT",
- "dependencies": {
- "@socket.io/component-emitter": "~3.1.0",
- "debug": "~4.3.2",
- "engine.io-client": "~6.5.2",
- "socket.io-parser": "~4.2.4"
- },
- "engines": {
- "node": ">=10.0.0"
- }
- },
- "node_modules/socket.io-client/node_modules/debug": {
- "version": "4.3.7",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
- "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/socket.io-parser": {
- "version": "4.2.4",
- "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
- "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
- "license": "MIT",
- "dependencies": {
- "@socket.io/component-emitter": "~3.1.0",
- "debug": "~4.3.1"
- },
- "engines": {
- "node": ">=10.0.0"
- }
- },
- "node_modules/socket.io-parser/node_modules/debug": {
- "version": "4.3.7",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
- "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/socks": {
- "version": "2.8.4",
- "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
- "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==",
- "license": "MIT",
- "dependencies": {
- "ip-address": "^9.0.5",
- "smart-buffer": "^4.2.0"
- },
- "engines": {
- "node": ">= 10.0.0",
- "npm": ">= 3.0.0"
- }
- },
- "node_modules/socks-proxy-agent": {
- "version": "8.0.5",
- "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
- "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
- "license": "MIT",
- "dependencies": {
- "agent-base": "^7.1.2",
- "debug": "^4.3.4",
- "socks": "^2.8.3"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "devOptional": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "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/source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- }
- },
- "node_modules/sprintf-js": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
- "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
- "license": "BSD-3-Clause"
- },
- "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": "3.9.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
- "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/streamx": {
- "version": "2.22.0",
- "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
- "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==",
- "license": "MIT",
- "dependencies": {
- "fast-fifo": "^1.3.2",
- "text-decoder": "^1.1.0"
- },
- "optionalDependencies": {
- "bare-events": "^2.2.0"
- }
- },
- "node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/strip-bom": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
- "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/strip-json-comments": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/supports-preserve-symlinks-flag": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
- "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/tar-fs": {
- "version": "3.0.8",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
- "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
- "license": "MIT",
- "dependencies": {
- "pump": "^3.0.0",
- "tar-stream": "^3.1.5"
- },
- "optionalDependencies": {
- "bare-fs": "^4.0.1",
- "bare-path": "^3.0.0"
- }
- },
- "node_modules/tar-stream": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
- "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
- "license": "MIT",
- "dependencies": {
- "b4a": "^1.6.4",
- "fast-fifo": "^1.2.0",
- "streamx": "^2.15.0"
- }
- },
- "node_modules/text-decoder": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
- "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
- "license": "Apache-2.0",
- "dependencies": {
- "b4a": "^1.6.4"
- }
- },
- "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": "0.3.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
- "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinypool": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
- "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- }
- },
- "node_modules/tinyrainbow": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
- "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tinyspy": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
- "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-number": "^7.0.0"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
- "node_modules/tree-kill": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
- "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "tree-kill": "cli.js"
- }
- },
- "node_modules/ts-node": {
- "version": "10.9.2",
- "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
- "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@cspotcode/source-map-support": "^0.8.0",
- "@tsconfig/node10": "^1.0.7",
- "@tsconfig/node12": "^1.0.7",
- "@tsconfig/node14": "^1.0.0",
- "@tsconfig/node16": "^1.0.2",
- "acorn": "^8.4.1",
- "acorn-walk": "^8.1.1",
- "arg": "^4.1.0",
- "create-require": "^1.1.0",
- "diff": "^4.0.1",
- "make-error": "^1.1.1",
- "v8-compile-cache-lib": "^3.0.1",
- "yn": "3.1.1"
- },
- "bin": {
- "ts-node": "dist/bin.js",
- "ts-node-cwd": "dist/bin-cwd.js",
- "ts-node-esm": "dist/bin-esm.js",
- "ts-node-script": "dist/bin-script.js",
- "ts-node-transpile-only": "dist/bin-transpile.js",
- "ts-script": "dist/bin-script-deprecated.js"
- },
- "peerDependencies": {
- "@swc/core": ">=1.2.50",
- "@swc/wasm": ">=1.2.50",
- "@types/node": "*",
- "typescript": ">=2.7"
- },
- "peerDependenciesMeta": {
- "@swc/core": {
- "optional": true
- },
- "@swc/wasm": {
- "optional": true
- }
- }
- },
- "node_modules/ts-node-dev": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz",
- "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chokidar": "^3.5.1",
- "dynamic-dedupe": "^0.3.0",
- "minimist": "^1.2.6",
- "mkdirp": "^1.0.4",
- "resolve": "^1.0.0",
- "rimraf": "^2.6.1",
- "source-map-support": "^0.5.12",
- "tree-kill": "^1.2.2",
- "ts-node": "^10.4.0",
- "tsconfig": "^7.0.0"
- },
- "bin": {
- "ts-node-dev": "lib/bin.js",
- "tsnd": "lib/bin.js"
- },
- "engines": {
- "node": ">=0.8.0"
- },
- "peerDependencies": {
- "node-notifier": "*",
- "typescript": "*"
- },
- "peerDependenciesMeta": {
- "node-notifier": {
- "optional": true
- }
- }
- },
- "node_modules/tsconfig": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz",
- "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/strip-bom": "^3.0.0",
- "@types/strip-json-comments": "0.0.30",
- "strip-bom": "^3.0.0",
- "strip-json-comments": "^2.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==",
- "license": "0BSD"
- },
- "node_modules/type-fest": {
- "version": "2.19.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
- "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
- "dev": true,
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=12.20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/typed-query-selector": {
- "version": "2.12.0",
- "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz",
- "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==",
- "license": "MIT"
- },
- "node_modules/typedarray-to-buffer": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
- "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-typedarray": "^1.0.0"
- }
- },
- "node_modules/typescript": {
- "version": "5.8.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
- "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
- "devOptional": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "devOptional": true,
- "license": "MIT"
- },
- "node_modules/unique-string": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
- "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "crypto-random-string": "^4.0.0"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/unplugin": {
- "version": "1.16.1",
- "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz",
- "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.14.0",
- "webpack-virtual-modules": "^0.6.2"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/unplugin-swc": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.5.1.tgz",
- "integrity": "sha512-/ZLrPNjChhGx3Z95pxJ4tQgfI6rWqukgYHKflrNB4zAV1izOQuDhkTn55JWeivpBxDCoK7M/TStb2aS/14PS/g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rollup/pluginutils": "^5.1.0",
- "load-tsconfig": "^0.2.5",
- "unplugin": "^1.11.0"
- },
- "peerDependencies": {
- "@swc/core": "^1.2.108"
- }
- },
- "node_modules/update-notifier": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-6.0.2.tgz",
- "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "boxen": "^7.0.0",
- "chalk": "^5.0.1",
- "configstore": "^6.0.0",
- "has-yarn": "^3.0.0",
- "import-lazy": "^4.0.0",
- "is-ci": "^3.0.1",
- "is-installed-globally": "^0.4.0",
- "is-npm": "^6.0.0",
- "is-yarn-global": "^0.4.0",
- "latest-version": "^7.0.0",
- "pupa": "^3.1.0",
- "semver": "^7.3.7",
- "semver-diff": "^4.0.0",
- "xdg-basedir": "^5.1.0"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/yeoman/update-notifier?sponsor=1"
- }
- },
- "node_modules/uuid": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
- "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
- "dev": true,
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist/esm/bin/uuid"
- }
- },
- "node_modules/v8-compile-cache-lib": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
- "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/vite": {
- "version": "6.2.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
- "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "esbuild": "^0.25.0",
- "postcss": "^8.5.3",
- "rollup": "^4.30.1"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "jiti": ">=1.21.0",
- "less": "*",
- "lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "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/vite-node": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz",
- "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cac": "^6.7.14",
- "debug": "^4.4.0",
- "es-module-lexer": "^1.6.0",
- "pathe": "^2.0.3",
- "vite": "^5.0.0 || ^6.0.0"
- },
- "bin": {
- "vite-node": "vite-node.mjs"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/vite/node_modules/@esbuild/android-arm": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz",
- "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-loong64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz",
- "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/esbuild": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
- "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.2",
- "@esbuild/android-arm": "0.25.2",
- "@esbuild/android-arm64": "0.25.2",
- "@esbuild/android-x64": "0.25.2",
- "@esbuild/darwin-arm64": "0.25.2",
- "@esbuild/darwin-x64": "0.25.2",
- "@esbuild/freebsd-arm64": "0.25.2",
- "@esbuild/freebsd-x64": "0.25.2",
- "@esbuild/linux-arm": "0.25.2",
- "@esbuild/linux-arm64": "0.25.2",
- "@esbuild/linux-ia32": "0.25.2",
- "@esbuild/linux-loong64": "0.25.2",
- "@esbuild/linux-mips64el": "0.25.2",
- "@esbuild/linux-ppc64": "0.25.2",
- "@esbuild/linux-riscv64": "0.25.2",
- "@esbuild/linux-s390x": "0.25.2",
- "@esbuild/linux-x64": "0.25.2",
- "@esbuild/netbsd-arm64": "0.25.2",
- "@esbuild/netbsd-x64": "0.25.2",
- "@esbuild/openbsd-arm64": "0.25.2",
- "@esbuild/openbsd-x64": "0.25.2",
- "@esbuild/sunos-x64": "0.25.2",
- "@esbuild/win32-arm64": "0.25.2",
- "@esbuild/win32-ia32": "0.25.2",
- "@esbuild/win32-x64": "0.25.2"
- }
- },
- "node_modules/vitest": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz",
- "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/expect": "3.1.1",
- "@vitest/mocker": "3.1.1",
- "@vitest/pretty-format": "^3.1.1",
- "@vitest/runner": "3.1.1",
- "@vitest/snapshot": "3.1.1",
- "@vitest/spy": "3.1.1",
- "@vitest/utils": "3.1.1",
- "chai": "^5.2.0",
- "debug": "^4.4.0",
- "expect-type": "^1.2.0",
- "magic-string": "^0.30.17",
- "pathe": "^2.0.3",
- "std-env": "^3.8.1",
- "tinybench": "^2.9.0",
- "tinyexec": "^0.3.2",
- "tinypool": "^1.0.2",
- "tinyrainbow": "^2.0.0",
- "vite": "^5.0.0 || ^6.0.0",
- "vite-node": "3.1.1",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@types/debug": "^4.1.12",
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "@vitest/browser": "3.1.1",
- "@vitest/ui": "3.1.1",
- "happy-dom": "*",
- "jsdom": "*"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@types/debug": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser": {
- "optional": true
- },
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
- "optional": true
- },
- "jsdom": {
- "optional": true
- }
- }
- },
- "node_modules/webpack-virtual-modules": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
- "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
- "dev": true,
- "license": "MIT"
- },
- "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"
- }
- },
- "node_modules/widest-line": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
- "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "string-width": "^5.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "license": "ISC"
- },
- "node_modules/write-file-atomic": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
- "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "imurmurhash": "^0.1.4",
- "is-typedarray": "^1.0.0",
- "signal-exit": "^3.0.2",
- "typedarray-to-buffer": "^3.1.5"
- }
- },
- "node_modules/ws": {
- "version": "8.18.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
- "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
- "node_modules/xdg-basedir": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz",
- "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/xmlhttprequest-ssl": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
- "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/xtend": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
- "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.4"
- }
- },
- "node_modules/y18n": {
- "version": "5.0.8",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
- "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
- "license": "ISC",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/yargs": {
- "version": "17.7.2",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
- "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
- "license": "MIT",
- "dependencies": {
- "cliui": "^8.0.1",
- "escalade": "^3.1.1",
- "get-caller-file": "^2.0.5",
- "require-directory": "^2.1.1",
- "string-width": "^4.2.3",
- "y18n": "^5.0.5",
- "yargs-parser": "^21.1.1"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/yargs-parser": {
- "version": "21.1.1",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
- "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
- "license": "ISC",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/yargs/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yargs/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT"
- },
- "node_modules/yargs/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yargs/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yauzl": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
- "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
- "license": "MIT",
- "dependencies": {
- "buffer-crc32": "~0.2.3",
- "fd-slicer": "~1.1.0"
- }
- },
- "node_modules/yn": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
- "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/zod": {
- "version": "3.24.2",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
- "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- }
- }
-}
diff --git a/package.json b/package.json
deleted file mode 100644
index 0182318..0000000
--- a/package.json
+++ /dev/null
@@ -1,39 +0,0 @@
-{
- "name": "template-analysis",
- "version": "2.0.0",
- "author": "Tago LLC",
- "license": "Copyright",
- "private": true,
- "scripts": {
- "test": "vitest .",
- "test:single": "vitest --",
- "linter": "biome lint ./src --no-errors-on-unmatched --diagnostic-level=error",
- "linter-fix": "biome lint --apply ./src",
- "build": "rm -rf ./build; tsc --build",
- "deploy-analysis": "tsnd ./.github/scripts/deploy-analysis.ts"
- },
- "devDependencies": {
- "@biomejs/biome": "1.9.4",
- "@tago-io/builder": "3.1.3",
- "@types/async": "3.2.24",
- "@types/luxon": "3.6.2",
- "@types/uuid": "10.0.0",
- "husky": "9.1.7",
- "prettier": "3.5.3",
- "ts-node": "10.9.2",
- "ts-node-dev": "2.0.0",
- "typescript": "5.8.2",
- "unplugin-swc": "1.5.1",
- "uuid": "11.1.0",
- "vitest": "3.1.1"
- },
- "dependencies": {
- "@tago-io/sdk": "11.3.9",
- "async": "3.2.6",
- "axios": "1.8.4",
- "bson-objectid": "2.0.4",
- "geolib": "3.3.4",
- "luxon": "3.6.1",
- "puppeteer": "24.6.0"
- }
-}
diff --git a/src/analysis/README.md b/src/analysis/README.md
deleted file mode 100644
index 25f8fce..0000000
--- a/src/analysis/README.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Analysis
-The files in this folder represents analysis scripts that must be uploaded to your account.
-
-* **handler**: The script that handles actions take on the dashboards, such clicking in a button to create/delete/edit a user and other entities.
-* **alertCentral**: Same as handler, but it handles alert buttons.
-* **dataRetention**: When ran, it will make sure all the devices has the correct data retention set. It must paired with a scheduled action.
-* **deviceUpdater**: When ran, it will update all device configuration parameters with last checkin, battery, and also send alerts if any. It must paired with a scheduled action.
-* **sendReport**: Send the scheduled report of an organization. The handler script will automatically generate actions for this analysis.
-* **alertTrigger**: Send an alert of an organization. The alertCentral script will automatically generate actions for this analysis.
-* **sendReport**: Responsible to generate the PDF report via email.
-* **monthlyUsageReset**: Responsible for reset the monthly usage of SMS and Email from all clients.
-* **uplinkHandler**: The script handles uplinks from devices, like geolocation for outdoor tracking.
-* **userSignUp**: The script handles users that sign up in the application, if it is enabled in your Run configuration.
-
-# Analysis Template
-You can get the template for each analysis, with all the Environment Variables in place (you still need to update environment variables parameters).
-
-* **Uplink Handler**: https://admin.tago.io/template/61c1c1346aec8f001844ea3b
-* **User Signup**: https://admin.tago.io/template/61b327b8e3f46d00192153b7
-* **Send Report**: https://admin.tago.io/template/61b2f6199e269200196d4344
-* **Handler**: https://admin.tago.io/template/61b2f617e3f46d00191d997c
-* **Data Retention**: https://admin.tago.io/template/61c310d6d6df77001acb54a4
-* **Device Updater**: https://admin.tago.io/template/61b2f6124edcc00019b44f0b
-* **Alert Trigger**: https://admin.tago.io/template/61b2f610a14c040018c6672f
-
-# Dashboard Template
-You can get the dashboard templates to use with the analysis in the following links:
-* **Administrator**: https://admin.tago.io/template/61b2f61c9da1b800183a3284
-* **Alerts**: https://admin.tago.io/template/61b2f61c9e269200196d434d
-* **Groups**: https://admin.tago.io/template/61b2f61da14c040018c6680c
-* **Group** View: https://admin.tago.io/template/61b2f61e9da1b800183a32c1
-* **User List for Administrators**: https://admin.tago.io/template/61b2f61f561da800197abfde
-* **User List**: https://admin.tago.io/template/61b2f622e3f46d00191d9aa7
-* **Plan Management**: https://admin.tago.io/template/61b2f620e3f46d00191d9a1f
-* **Sensor** List: https://admin.tago.io/template/61b2f621561da800197ac053
-* **Reports**: https://admin.tago.io/template/61bc7b19dd44bf0019a56d22
-* **Organization** Details: https://admin.tago.io/template/61c0d2c05fb101001b417fbd
-
-# Sensor dashboard templates
-The following templates are optional, for specific sensor dashboards. You can use as an example for building your own.
-* Door Sensor: https://admin.tago.io/template/61b2f61c9da1b800183a329a
diff --git a/src/analysis/alert-handler.ts b/src/analysis/alert-handler.ts
deleted file mode 100644
index 25b0c13..0000000
--- a/src/analysis/alert-handler.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * KickStarter Analysis
- * Alert Handler
- *
- * Work same as the handler analysis, but only for alerts.
- * This analysis handles most of buttons clickable by dashboard input form widgets such as dynamic table and input form widgets.
- *
- * Handles the following actions:
- * - Add, edit and delete an Alert
- */
-import { Analysis, Utils } from "@tago-io/sdk";
-import { Data, TagoContext } from "@tago-io/sdk/lib/types";
-
-import { editAlert } from "../services/alerts/edit";
-import { createAlert } from "../services/alerts/register";
-import { deleteAlert } from "../services/alerts/remove";
-
-/**
- * Function that starts the analysis
- * @param context Context is a variable sent by the analysis
- * @param scope Scope is a variable sent by the analysis
- */
-async function startAnalysis(context: TagoContext, scope: Data[]): Promise {
- if (!scope[0]) {
- return console.error("Not a valid TagoIO Data");
- }
-
- console.debug(JSON.stringify(scope));
- console.debug("Alert analysis started");
-
- // Get the environment variables.
- const environment = Utils.envToJson(context.environment);
-
- const router = new Utils.AnalysisRouter({
- environment,
- scope,
- context,
- });
-
- router.register(createAlert).whenInputFormID("create-alert-dev");
- router.register(editAlert).whenWidgetExec("edit");
- router.register(deleteAlert).whenWidgetExec("delete");
-
- const result = await router.exec();
-
- console.debug("Script end. Functions that run:");
- console.debug(result.services);
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { startAnalysis };
diff --git a/src/analysis/alert-trigger.ts b/src/analysis/alert-trigger.ts
deleted file mode 100644
index ce1a16c..0000000
--- a/src/analysis/alert-trigger.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-/*
- * KickStarter Analysis
- * Alert Trigger
- *
- * The analysis runs every time a device uplink matches an alert and must send an email, sms or notification.
- */
-import { Analysis, Resources, Services, Utils } from "@tago-io/sdk";
-import { Conditionals, Data, DeviceInfo, TagoContext, UserInfo } from "@tago-io/sdk/lib/types";
-
-import { checkAndChargeUsage } from "../services/plan/check-and-charge-usage";
-
-interface IMessageDetail {
- device_name: string;
- device_id: string;
- sensor_type: string;
- value: string;
- variable: string;
-}
-
-type triggerType = {
- device: string;
- variable: string;
- is: Conditionals;
- value: string;
- second_value?: string;
- value_type: "string" | "number" | "boolean" | "*";
- unlock?: boolean;
-};
-
-/**
- * Notification messages to be sent
- * @param type Type of message to be sent
- * @param context Context is a variable sent by the analysis
- * @param org_id Organization ID of the device that triggered the alert
- * @param to_dispatch_qty Number of messages to be sent
- * @param users_info Array of users to receive the message
- * @param message Message to be sent
- */
-async function notificationMessages(type: string[], context: TagoContext, org_id: string, to_dispatch_qty: number, users_info: UserInfo[], message: string) {
- if (type.includes("notification_run")) {
- const has_service_limit = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "notification_run");
-
- if (has_service_limit) {
- for (const user of users_info) {
- void Resources.run
- .notificationCreate(user.id, {
- message,
- title: "Alert Trigger",
- })
- .then(() => console.debug("Notification sent"));
- }
- } else {
- await Resources.devices.sendDeviceData(org_id, {
- variable: "plan_status",
- value: `Attempt to send ${to_dispatch_qty} alert(s) was not successful. No notification service limit available, check your service usage at "Info" to learn more about your plan status.`,
- });
- }
- }
-}
-
-/**
- * Email messages to be sent
- * @param type Type of message to be sent
- * @param context Context is a variable sent by the analysis
- * @param org_id Organization ID of the device that triggered the alert
- * @param to_dispatch_qty Number of messages to be sent
- * @param users_info Array of users to receive the message
- * @param device_info Device information
- * @param message Message to be sent
- */
-async function emailMessages(type: string[], context: TagoContext, org_id: string, to_dispatch_qty: number, users_info: UserInfo[], device_info: any, message: string) {
- if (type.includes("email")) {
- const has_service_limit = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "email");
-
- if (has_service_limit) {
- const email = new Services({ token: context.token }).email;
-
- void email
- .send({
- to: users_info.map((x) => x.email).join(","),
- template: {
- name: "email_alert",
- params: {
- device_name: device_info.name,
- alert_message: message,
- },
- },
- })
- .then((msg) => console.debug(msg));
- } else {
- await Resources.devices.sendDeviceData(org_id, {
- variable: "plan_status",
- value: `Attempt to send ${to_dispatch_qty} alert(s) was not successful. No email service limit available, check your service usage at "Info" to learn more about your plan status.`,
- });
- }
- }
-}
-
-/**
- * Sms messages to be sent
- * @param type Type of message to be sent
- * @param context Context is a variable sent by the analysis
- * @param org_id Organization ID of the device that triggered the alert
- * @param to_dispatch_qty Number of messages to be sent
- * @param users_info Array of users to receive the message
- * @param message Message to be sent
- */
-async function smsMessages(type: string[], context: TagoContext, org_id: string, to_dispatch_qty: number, users_info: UserInfo[], message: string) {
- if (type.includes("sms")) {
- const has_service_limit = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "sms");
-
- if (has_service_limit) {
- for (const user of users_info) {
- const smsService = new Services({ token: context.token }).sms;
- if (!user.phone) {
- throw "user.phone not found";
- }
- void smsService
- .send({
- message,
- to: user.phone,
- })
- .then((msg) => console.debug(msg));
- }
- } else {
- await Resources.devices.sendDeviceData(org_id, {
- variable: "plan_status",
- value: `Attempt to send ${to_dispatch_qty} alert(s) was not successful. No SMS service limit available, check your service usage at "Info" to learn more about your plan status.`,
- });
- }
- }
-}
-
-/**
- * Function that starts the analysis and handles the alert trigger and message dispatch
- * @param type Type of message to be sent
- * @param context Context is a variable sent by the analysis
- * @param org_id Organization ID of the device that triggered the alert
- * @param to_dispatch_qty Number of messages to be sent
- * @param users_info Array of users to receive the message
- * @param message Message to be sent
- * @param device_info Device information
- */
-async function dispatchMessages(type: string[], context: TagoContext, org_id: string, to_dispatch_qty: number, users_info: UserInfo[], message: string, device_info: DeviceInfo) {
- await notificationMessages(type, context, org_id, to_dispatch_qty, users_info, message);
-
- await emailMessages(type, context, org_id, to_dispatch_qty, users_info, device_info, message);
-
- await smsMessages(type, context, org_id, to_dispatch_qty, users_info, message);
-}
-
-/**
- * Function that replaces the message with the variables
- * @param message Message to be sent
- * @param replace_details Object with the variables to be replaced
- */
-function replaceMessage(message: string, replace_details: IMessageDetail) {
- for (const key of Object.keys(replace_details)) {
- message = message.replaceAll(new RegExp(`#${key}#`, "g"), (replace_details as any)[key]);
- console.debug((replace_details as any)[key]);
- }
-
- return message;
-}
-
-/**
- * Function that get the users information
- * @param send_to Array of users to receive the message
- */
-async function getUsers(send_to: string[]) {
- const func_list = send_to.map((user_id) => Resources.run.userInfo(user_id).catch(() => null));
-
- return (await Promise.all(func_list)).filter((x) => x) as UserInfo[];
-}
-
-/**
- * Function that starts the analysis and handles the alert trigger
- * @param context Context is a variable sent by the analysis
- * @param scope Scope is an array of data sent by the analysis
- */
-async function analysisAlert(context: TagoContext, scope: Data[]): Promise {
- console.debug("Running Analysis");
- if (!scope[0]) {
- return console.debug("This analysis must be triggered by an action.");
- }
-
- console.debug(JSON.stringify(scope));
- // Get the environment variables.
- const environment_variables = Utils.envToJson(context.environment);
-
- const action_id = environment_variables._action_id;
- if (!action_id) {
- return console.debug("This analysis must be triggered by an action.");
- }
-
- // Get action details
- const action_info = await Resources.actions.info(action_id);
- if (!action_info.tags) {
- throw "action_info.tags not found";
- }
-
- if (!action_info.trigger) {
- throw "action_info.trigger not found";
- }
-
- const send_to = action_info.tags
- .find((x) => x.key === "send_to")
- ?.value?.replace(/;/g, ",")
- .split(",");
- const type = action_info.tags
- .find((x) => x.key === "action_type")
- ?.value?.replace(/;/g, ",")
- .split(",");
-
- if (!send_to) {
- throw "send_to not found";
- }
-
- if (!type) {
- throw "type not found";
- }
- // const alert_id = action_info.tags.find((x) => x.key === "action_id")?.value;
- const alert_id = action_id;
-
- // Get action message
- const org_id = action_info.tags.find((x) => x.key === "organization_id")?.value;
-
- if (!org_id) {
- throw "org_id not found";
- }
- const [message_var] = await Resources.devices.getDeviceData(org_id, { variables: ["action_list_message", "action_group_message"], groups: alert_id, qty: 1 });
-
- // Get the triggered variable
- const trigger = action_info.trigger as unknown as triggerType[];
- const trigger_variables = trigger?.filter((x) => !x.unlock).map((x) => x.variable);
- const trigger_variable = scope.find((x) => trigger_variables.includes(x.variable));
-
- if (!trigger_variable) {
- throw "trigger_variable.value not found";
- }
-
- const device_id = scope[0].device;
- const device_info = await Resources.devices.info(device_id);
-
- const sensor_type = device_info?.tags?.find((tag) => tag.key === "sensor")?.value;
- if (!sensor_type) {
- throw "sensoor_type not found";
- }
-
- const replace_details: IMessageDetail = {
- device_name: device_info?.name,
- device_id: device_info?.id,
- sensor_type: sensor_type,
- value: String(trigger_variable?.value),
- variable: trigger_variable?.variable,
- };
-
- const message = replaceMessage(message_var.value as string, replace_details);
-
- const users_info = await getUsers(send_to);
-
- const to_dispatch_qty = users_info.length;
-
- await dispatchMessages(type, context, org_id, to_dispatch_qty, users_info, message, device_info);
-
- return console.debug("Analysis Finished!");
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(analysisAlert, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { analysisAlert };
diff --git a/src/analysis/battery-updater.ts b/src/analysis/battery-updater.ts
deleted file mode 100644
index 6152db1..0000000
--- a/src/analysis/battery-updater.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * KickStarter Analysis
- * Battery Updater
- *
- * This analysis is responsible to
- * update sensor's last checkin parameter.
- *
- * Battery Updater will run when:
- * - When the scheduled action (Battery Updater Trigger) triggers this script. (Default 1 day)
- */
-
-import { Analysis, Resources } from "@tago-io/sdk";
-
-import { fetchDeviceList } from "../lib/fetch-device-list";
-
-async function resolveDevice(org_id: string, device_id: string) {
- if (!org_id || !device_id) {
- throw "Missing Router parameter";
- }
-
- const device_params = await Resources.devices.paramList(device_id);
- const dev_battery_param = device_params.find((param) => param.key === "dev_battery") || { key: "dev_battery", value: "N/A", sent: false };
-
- const [dev_battery] = await Resources.devices.getDeviceData(device_id, { variables: ["bat", "battery_capacity"], qty: 1 });
-
- if (dev_battery?.value) {
- await Resources.devices.paramSet(device_id, { ...dev_battery_param, value: String(dev_battery.value) });
- }
-}
-
-async function startAnalysis() {
- console.debug("Running Analysis");
-
- try {
- const sensorList = await fetchDeviceList({ tags: [{ key: "device_type", value: "device" }] });
-
- sensorList.map((device) =>
- resolveDevice(device.tags.find((tag) => tag.key === "organization_id")?.value as string, device.tags.find((tag) => tag.key === "device_id")?.value as string)
- );
-
- console.debug("Analysis finished");
- } catch (error) {
- console.debug(error);
- console.debug(error.message || JSON.stringify(error));
- }
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { startAnalysis };
diff --git a/src/analysis/clear-buckets.ts b/src/analysis/clear-buckets.ts
deleted file mode 100644
index fbac945..0000000
--- a/src/analysis/clear-buckets.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Analysis, Resources } from "@tago-io/sdk";
-
-import { fetchDeviceList } from "../lib/fetch-device-list";
-
-/**
- * Function to start the analysis and clear variables from devices of type organization
- * @param context
- * @param scope
- */
-async function startAnalysis() {
- const deviceList = await fetchDeviceList({ tags: [{ key: "device_type", value: "organization" }] });
-
- for (const device of deviceList) {
- const result = await Resources.devices.deleteDeviceData(device.id, { variables: ["device_qty", "plan_usage"], qty: 9999 });
- console.debug(result);
- }
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN });
-}
diff --git a/src/analysis/data-retention.ts b/src/analysis/data-retention.ts
deleted file mode 100644
index bdecf5b..0000000
--- a/src/analysis/data-retention.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * KickStarter Analysis
- * Data Retention Updater
- *
- * This analysis gets all sensors in the application and make sure bucket data retention is set properly.
- */
-
-import { Analysis, Resources } from "@tago-io/sdk";
-import { DeviceListItem } from "@tago-io/sdk/lib/types";
-
-import { fetchDeviceList } from "../lib/fetch-device-list";
-
-/**
- * Function that resolves the data retention of the organization
- * @param org_id Organization ID to resolve the data retention
- */
-async function resolveDataRetentionByOrg(org_id: string) {
- const device_list: DeviceListItem[] = await fetchDeviceList({
- tags: [
- { key: "device_type", value: "device" },
- { key: "organization_id", value: org_id },
- ],
- });
-
- for (const device_obj of device_list) {
- const variables = await Resources.devices.getDeviceData(device_obj.id, { qty: 9999 });
- if (!variables[0]) {
- return;
- }
-
- const bucket_vars = variables.map((v) => v.variable);
- const data_retention_ignore = bucket_vars
- .map((r) => {
- if (!r.includes("action")) {
- return null;
- }
- return r;
- })
- .filter((x) => x);
- // @ts-ignore: Unreachable code error
- await Resources.buckets.edit(device_obj.bucket.id, { data_retention_ignore });
- }
-}
-
-/**
- * Function that updates the data retention of the application
- */
-async function updateDataRetention() {
- console.debug("Running");
-
- const organization_list: DeviceListItem[] = await fetchDeviceList({ tags: [{ key: "device_type", value: "organization" }] });
-
- for (const org of organization_list) {
- const org_id = org.id;
- const org_param_list = await Resources.devices.paramList(org_id);
- const plan_data_retention = org_param_list.find((x) => x.key === "plan_data_retention")?.value || "";
-
- if (plan_data_retention !== "") {
- await resolveDataRetentionByOrg(org_id);
- }
- }
-
- console.debug("success");
-}
-
-/**
- * Function that starts the analysis
- */
-async function startAnalysis() {
- await updateDataRetention()
- .then(() => {
- console.debug("Script end.");
- })
- .catch((error) => {
- console.debug(error);
- console.debug(error.message || JSON.stringify(error));
- });
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { startAnalysis };
diff --git a/src/analysis/handler.ts b/src/analysis/handler.ts
deleted file mode 100644
index 5aa1c95..0000000
--- a/src/analysis/handler.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * KickStarter Analysis
- * Handler
- *
- * This analysis handles most of buttons clickable by dashboard input form widgets such as dynamic table and input form widgets.
- *
- * Handles the following actions:
- * - Add, edit and delete an Organization.
- * - Add, edit and delete a Group.
- * - Add, edit and delete a Sensor.
- * - Add, edit and delete a User.
- * - Add, edit and delete scheduled reports.
- */
-
-import { Analysis, Utils } from "@tago-io/sdk";
-import { Data, TagoContext } from "@tago-io/sdk/lib/types";
-
-import { sensorEdit } from "../services/device/edit";
-import { sensorPlacement } from "../services/device/place-sensor";
-import { sensorAdd } from "../services/device/register";
-import { sensorDel } from "../services/device/remove";
-import { groupEdit } from "../services/group/edit";
-import { groupAdd } from "../services/group/register";
-import { groupDel } from "../services/group/remove";
-import { orgEdit } from "../services/organization/edit";
-import { orgAdd } from "../services/organization/register";
-import { orgDel } from "../services/organization/remove";
-import { planEdit } from "../services/plan/edit";
-import { planAdd } from "../services/plan/register";
-import { planDel } from "../services/plan/remove";
-import { reportAdd } from "../services/reports/create";
-import { reportEdit } from "../services/reports/edit";
-import { reportDel } from "../services/reports/remove";
-import { userEdit } from "../services/user/edit";
-import { userAdd } from "../services/user/register";
-import { userDel } from "../services/user/remove";
-
-// import { createAlert } from "../services/alerts/register";
-// import { deleteAlert } from "../services/alerts/remove";
-// import { editAlert } from "../services/alerts/edit";
-
-/**
- * This function is the main function of the analysis.
- * @param context The context of the analysis, containing the environment variables and parameters.
- * @param scope The scope of the analysis, containing the data sent to the analysis.
- */
-async function startAnalysis(context: TagoContext, scope: Data[]): Promise {
- context.log("Running Analysis");
- console.log("Scope:", scope);
-
- // Convert the environment variables from [{ key, value }] to { key: value };
- const environment = Utils.envToJson(context.environment);
- console.log("Environment:", environment);
-
- // Check if all tokens needed for the application were provided.
- if (!environment.config_id) {
- throw "Missing config_id environment var";
- } else if (environment.config_id.length !== 24) {
- return context.log('Invalid "config_id" in the environment variable');
- }
-
- // Just a little hack to set the device_list_button_id that come from the scope
- // and set it to the environment variables instead. It makes easier to use router function later.
- environment._input_id = (scope as any).find((x: any) => x.device_list_button_id)?.device_list_button_id;
-
- // Instance the router class of Utils.router
- const router = new Utils.AnalysisRouter({ scope, context, environment });
-
- // Organization Routing
- router.register(orgAdd).whenInputFormID("create-org");
- router.register(orgDel).whenDeviceListIdentifier("delete-org");
- router.register(orgEdit).whenCustomBtnID("edit-org");
-
- // Sensor routing
- router.register(sensorAdd).whenInputFormID("create-dev");
- router.register(sensorDel).whenDeviceListIdentifier("delete-dev");
- router.register(sensorEdit).whenDeviceListIdentifier("edit-dev");
-
- // Sensor uplink routing
- router.register(sensorPlacement).whenVariables(["set_dev_pin_id"]);
-
- // group routing
- router.register(groupAdd).whenInputFormID("create-group");
- router.register(groupDel).whenDeviceListIdentifier("delete-group");
- router.register(groupEdit).whenCustomBtnID("edit-group");
-
- // User routing
- router.register(userAdd).whenInputFormID("create-user");
- router.register(userDel).whenUserListIdentifier("delete-user");
- router.register(userEdit).whenCustomBtnID("edit-user");
-
- //Plan routing
- router.register(planAdd).whenInputFormID("create-plan");
- router.register(planDel).whenVariableLike("plan_").whenWidgetExec("delete");
- router.register(planEdit).whenVariableLike("plan_").whenWidgetExec("edit");
-
- // //Alert routing
- // router.register(createAlert).whenInputFormID("create-alert");
- // router.register(editAlert).whenVariableLike("action_").whenWidgetExec("edit");
- // router.register(deleteAlert).whenVariableLike("action_").whenWidgetExec("delete");
-
- // Report routing
- router.register(reportAdd).whenInputFormID("create-report");
- router.register(reportDel).whenVariableLike("report_").whenWidgetExec("delete");
- router.register(reportEdit).whenVariableLike("report_").whenWidgetExec("edit");
-
- await router.exec();
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { startAnalysis };
diff --git a/src/analysis/monthly-usage-reset.ts b/src/analysis/monthly-usage-reset.ts
deleted file mode 100644
index 1c78cf1..0000000
--- a/src/analysis/monthly-usage-reset.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * KickStarter Analysis
- * Monthly Usage Reset
- *
- * This analysis will reset the monthly usage of SMS and Email from all clients.
- *
- * How it works:
- * - The action "[TagoIO] - Monthly plan reset trigger" will trigger this analysis on the first day of each month at 00:00 UTC.
- * - Organization's SMS and Email usage will be reset to 0.
- */
-
-import { Analysis, Resources } from "@tago-io/sdk";
-import { DeviceListItem } from "@tago-io/sdk/lib/types";
-
-import { fetchDeviceList } from "../lib/fetch-device-list";
-
-/**
- * Function that initializes the analysis
- * @param context Context is a variable sent by the analysis
- * @param scope Scope is a variable sent by the analysis
- */
-async function init(): Promise {
- console.debug("Monthly usage reset analysis started");
-
- const org_list: DeviceListItem[] = await fetchDeviceList({ tags: [{ key: "device_type", value: "organization" }] });
-
- for (const org of org_list) {
- const org_params = await Resources.devices.paramList(org.id);
-
- const plan_email_limit_usage = org_params.find((x) => x.key === "plan_email_limit_usage") || { key: "plan_email_limit_usage", value: "0", sent: false };
- const plan_sms_limit_usage = org_params.find((x) => x.key === "plan_sms_limit_usage") || { key: "plan_sms_limit_usage", value: "0", sent: false };
- const plan_notif_limit_usage = org_params.find((x) => x.key === "plan_notif_limit_usage") || { key: "plan_notif_limit_usage", value: "0", sent: false };
-
- await Resources.devices.paramSet(org.id, { ...plan_email_limit_usage, value: "0", sent: false });
- await Resources.devices.paramSet(org.id, { ...plan_sms_limit_usage, value: "0", sent: false });
- await Resources.devices.paramSet(org.id, { ...plan_notif_limit_usage, value: "0", sent: false });
- }
-
- return console.debug("Analysis finished successfuly!");
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(init, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { init };
diff --git a/src/analysis/send-report.ts b/src/analysis/send-report.ts
deleted file mode 100644
index 69cf5e5..0000000
--- a/src/analysis/send-report.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-/*
- * KickStarter Analysis
- * Send Report
- *
- * This analysis is responsible to generate the PDF and its content.
- *
- * Reports are generated when:
- * - On the dashboard Report, through Send Now button;
- * - When setting a scheduled report, an action will trigger this script.
- */
-
-import { DateTime } from "luxon";
-
-import { Analysis, Resources } from "@tago-io/sdk";
-import { ActionInfo, TagoContext, UserInfo } from "@tago-io/sdk/lib/types";
-
-import { htmlBody } from "../lib/html-body";
-import { createPDF } from "../lib/send-pdf";
-import { checkAndChargeUsage } from "../services/plan/check-and-charge-usage";
-
-interface SensorData {
- name: string;
- status: string;
- battery: string;
- rssi: string;
- date: string;
-}
-
-/**
- * Function that resolves the report of the organization and send it to the user
- * @param context Context is a variable sent by the analysis
- * @param action_info Action information of the action that triggered the analysis
- * @param org_id Organization ID to resolve the report
- * @param via Via is a string that defines how the report was triggered
- */
-async function resolveReport(context: TagoContext, action_info: ActionInfo, org_id: string, via?: string) {
- if (!context || !action_info || !org_id) {
- throw "Missing Router parameter";
- }
- const { name: org_name } = await Resources.devices.info(org_id);
-
- let sensor_id_list: string[] = [];
-
- if (!action_info.tags) {
- throw console.debug("action_info.tags is undefined");
- }
-
- const action_sensor_list = action_info.tags.find((x) => x.key === "sensor_list")?.value;
- const action_group_list = action_info.tags.find((x) => x.key === "group_list")?.value;
-
- if (action_sensor_list) {
- sensor_id_list = action_sensor_list.split(", ");
- } else if (action_group_list) {
- const site_id_list = action_group_list.split(", ");
-
- for (const site_id of site_id_list) {
- const site_dev_id_list = await Resources.devices.getDeviceData(site_id, { variables: "dev_id", qty: 9999 });
- for (const dev_id_data of site_dev_id_list) {
- const existing_sensor = sensor_id_list.find((id) => id === (dev_id_data?.value as string));
-
- if (!existing_sensor) {
- sensor_id_list.push(dev_id_data.value as string);
- }
- }
- }
- } else {
- throw console.debug("Error - no sensor on scheduled action");
- }
-
- const report_data: SensorData[] = [];
-
- for (const id of sensor_id_list) {
- const sensor_info = await Resources.devices.info(id);
-
- if (!sensor_info) {
- continue; //sensor has been deleted
- }
-
- const sensor_data = await Resources.devices.getDeviceData(id, { variables: ["temperature", "humidity", "compressor", "battery", "rssi"], qty: 1 });
- // const status_history = sensor_data.find((x) => x.variable === "status_history");
- const temperature = sensor_data.find((x) => x.variable === "temperature");
- const humidity = sensor_data.find((x) => x.variable === "humidity");
- const compressor = sensor_data.find((x) => x.variable === "compressor");
- const temp_status = `Temp: ${temperature?.value ?? "N/A"}${temperature?.unit ?? ""}`;
- const hum_status = `Hum: ${humidity?.value ?? "N/A"}${humidity?.unit ?? ""}`;
- const compressor_status = `Compressor: ${compressor?.value ?? "N/A"}`;
- const status_history = `${temp_status} | ${hum_status} | ${compressor_status}`;
- const battery = sensor_data.find((x) => x.variable === "battery");
- const rssi = sensor_data.find((x) => x.variable === "rssi");
-
- report_data.push({
- name: sensor_info.name,
- status: status_history,
- battery: `${(battery?.value as string) ?? "N/A"}${battery?.unit ?? ""}`,
- rssi: (rssi?.value as string) ?? "N/A",
- date: DateTime.fromISO(String(sensor_info.last_input)).toFormat("yyyy-MM-dd HH:mm:ss"),
- });
- }
-
- const table_header = `
- Sensor |
- Status |
- Battery |
- RSSI |
- Last Input | `;
-
- let final_html_body = htmlBody;
- final_html_body = final_html_body.replace("$TABLE_HEADER$", table_header);
-
- let report_table = ``;
-
- for (const data of report_data) {
- let report_row = `
-
- | $NAME$ |
- $STATUS$ |
- $BATTERY$ |
- $RSSI$ |
- $DATE$ |
-
`;
- report_row = report_row.replace("$NAME$", data.name);
- report_row = report_row.replace("$STATUS$", data?.status || "No data sent yet");
- report_row = report_row.replace("$BATTERY$", data.battery);
- report_row = report_row.replace("$RSSI$", data.rssi);
- report_row = report_row.replace("$DATE$", data.date);
-
- report_table = report_table.concat(report_row);
- }
-
- final_html_body = final_html_body.replace("$REPORT_TABLE$", report_table);
-
- const org_indicators = await Resources.devices.getDeviceData(org_id, { variables: ["device_qty"], qty: 1 });
- const total_qty = org_indicators[0].value || "0";
- const active_qty = org_indicators[0]?.metadata?.active_qty || "0";
- const inactive_qty = org_indicators[0]?.metadata?.inactive_qty || "0";
-
- final_html_body = final_html_body.replace("$TOTAL_QTY$", String(total_qty));
- final_html_body = final_html_body.replace("$ACTIVE_QTY$", active_qty);
- final_html_body = final_html_body.replace("$INACTIVE_QTY$", inactive_qty);
-
- const action_report_contact = action_info.tags.find((x) => x.key === "report_contact")?.value;
-
- if (!action_report_contact) {
- throw console.debug("action_report_contact not found");
- }
-
- const users_id_list: string[] = action_report_contact.split(", ");
-
- //fetch users set first
-
- const users_info_list: UserInfo[] = [];
- const all_users_label: string[] = [];
-
- for (const user_id_from_list of users_id_list) {
- const current_user_info = await Resources.run.userInfo(user_id_from_list).catch((error) => console.debug(error));
- if (!current_user_info) {
- //user has been deleted
- continue;
- }
- users_info_list.push(current_user_info);
- all_users_label.push(current_user_info?.email);
- }
-
- //check if users are invited and still existing first -> charge from which will be actually being sent.
- const to_dispatch_qty = users_info_list.length;
-
- const plan_service_status = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "email");
-
- if (plan_service_status === false) {
- return await Resources.devices.sendDeviceData(org_id, [
- {
- variable: "plan_status",
- value: `Attempt to send ${to_dispatch_qty} report(s) was not successful. No email service limit available, check your plan status or get in touch with us.`,
- },
- { variable: "report_sent", value: `Report has not been sent. No plan service usage available.`, metadata: { users: "-" } },
- ]);
- }
-
- let filename: string | undefined;
- if (users_id_list.length > 0 && plan_service_status) {
- filename = await createPDF(context, final_html_body, users_info_list, org_name, org_id);
- } else if (users_id_list.length === 0) {
- return await Resources.devices.sendDeviceData(org_id, [{ variable: "report_sent", value: `Report has not been sent. No user registered.`, metadata: { users: "-" } }]);
- }
-
- const all_users_string = all_users_label.join(", ");
-
- const url_file = filename ? `https://api.tago.io/file/61b2f46e561da800197a9c43${filename}` : "-";
- await Resources.devices.sendDeviceData(org_id, [{ variable: "report_sent", value: `Report has been sent. Via: ${via}.`, metadata: { users: all_users_string, url_file } }]);
-}
-
-/**
- * Function to start the analysis
- * @param context Context is a variable sent by the analysis
- * @param scope Scope is a variable sent by the analysis
- */
-async function startAnalysis(context: TagoContext, scope: any) {
- console.debug("Running Analysis");
-
- let org_id: string | undefined = "";
-
- const action_id = context.environment.find((x) => x.key === "_action_id")?.value as string;
-
- if (action_id) {
- //THROUGH ACTION
- const action_info = await Resources.actions.info(action_id);
-
- const { tags } = action_info;
-
- if (!tags) {
- return console.debug("tags not found");
- }
-
- org_id = tags.find((x) => x.key === "organization_id")?.value;
- if (!org_id) {
- throw "organization_id not found";
- }
-
- void resolveReport(context, action_info, org_id, "Squeduled Action");
- } else if (scope) {
- //THROUGH BUTTON SEND NOW
- const action_group = scope[0]?.group;
-
- const [action_registered] = await Resources.actions.list({
- page: 1,
- fields: ["id", "name", "tags"],
- filter: {
- tags: [{ key: "action_group", value: action_group }],
- },
- amount: 1,
- });
-
- if (!action_registered.tags) {
- return console.debug("ERROR - No action found");
- }
-
- org_id = action_registered.tags.find((x) => x.key === "organization_id")?.value;
- if (!org_id) {
- throw "organization_id not found";
- }
-
- void resolveReport(context, action_registered, org_id, "Button");
- }
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { startAnalysis };
diff --git a/src/analysis/status-updater.ts b/src/analysis/status-updater.ts
deleted file mode 100644
index 95934ff..0000000
--- a/src/analysis/status-updater.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-/*
- * KickStarter Analysis
- * Status Updater
- *
- * This analysis is responsible to update organization's plan usage (displayed at Info Dashboard),
- * update the indicators from the organization (total, active and inactive),
- * update sensor's params (last checkin and battery) and update sensors location.
- *
- * Status Updater will run when:
- * - When the scheduled action (Status Updater Trigger) triggers this script. (Default 1 minute)
- */
-
-import async from "async";
-import { DateTime } from "luxon";
-
-import { Analysis, Resources, Utils } from "@tago-io/sdk";
-import { DeviceInfo, DeviceListItem, TagoContext } from "@tago-io/sdk/lib/types";
-
-import { parseTagoObject } from "../lib/data.logic";
-import { fetchDeviceList } from "../lib/fetch-device-list";
-import { checkInTrigger } from "../services/alerts/check-in-alerts";
-
-/**
- * Function that update the organization's plan usage
- * @param org Organization device
- */
-async function resolveOrg(org: DeviceListItem) {
- let total_qty = 0;
- let active_qty = 0;
- let inactive_qty = 0;
- const org_id = org.id;
-
- const sensorList = await fetchDeviceList({
- tags: [
- { key: "organization_id", value: org.id },
- { key: "device_type", value: "device" },
- ],
- });
-
- for (const sensor of sensorList) {
- const last_input = DateTime.fromISO(String(sensor.last_input || ""));
- const now = DateTime.now();
- const diff_time = now.diff(last_input, "hours").hours;
- total_qty++;
- if (diff_time < 24) {
- active_qty++;
- } else {
- inactive_qty++;
- }
- }
-
- const org_params = await Resources.devices.paramList(org_id);
-
- const plan_email_limit_usage = org_params.find((x) => x.key === "plan_email_limit_usage")?.value || "0";
- const plan_sms_limit_usage = org_params.find((x) => x.key === "plan_sms_limit_usage")?.value || "0";
- const plan_notif_limit_usage = org_params.find((x) => x.key === "plan_notif_limit_usage")?.value || "0";
- const plan_data_retention = org_params.find((x) => x.key === "plan_data_retention")?.value || "0";
-
- const to_tago = {
- device_qty: { value: total_qty, metadata: { total_qty: total_qty, active_qty: active_qty, inactive_qty: inactive_qty } },
- plan_usage: {
- value: plan_email_limit_usage,
- metadata: {
- plan_email_limit_usage: plan_email_limit_usage,
- plan_sms_limit_usage: plan_sms_limit_usage,
- plan_notif_limit_usage: plan_notif_limit_usage,
- plan_data_retention: plan_data_retention,
- },
- },
- };
- //CONSIDER INSTEAD OF DELETING VARIABLES, PLACE A DATA RETENTION RULE AND SHOW THEM IN A HISTORIC GRAPHIC ON THE WIDGET HEADER BUTTON
- const old_data = await Resources.devices.getDeviceData(org_id, {
- variables: ["device_qty", "plan_usage"],
- query: "last_item",
- });
- const device_data = old_data.find((x) => x.variable === "device_qty");
- const plan_data = old_data.find((x) => x.variable === "plan_usage");
- const new_data = parseTagoObject(to_tago);
-
- if (!device_data || !plan_data) {
- await Resources.devices.sendDeviceData(org_id, new_data);
- return;
- }
-
- await Resources.devices.editDeviceData(org_id, [
- { ...device_data, ...new_data[0] },
- { ...plan_data, ...new_data[1] },
- ]);
-}
-
-/**
- * Function that update the sensor's params (last checkin and battery)
- * @param sensor_info - Sensor information
- */
-const checkLocation = async (sensor_info: DeviceInfo) => {
- const [location_data] = await Resources.devices.getDeviceData(sensor_info.id, { variables: "location", qty: 1 });
-
- if (!location_data) {
- return "No location sent by device";
- }
-
- const site_id = sensor_info.tags.find((x) => x.key === "site_id")?.value;
-
- if (!site_id) {
- return "No site addressed to the sensor";
- }
-
- const [dev_id] = await Resources.devices.getDeviceData(site_id, { variables: "dev_id", groups: sensor_info.id, qty: 1 });
- if (
- (dev_id?.location as any).coordinates[0] === (location_data.location as any).coordinates[0] &&
- (dev_id?.location as any).coordinates[1] === (location_data.location as any).coordinates[1]
- ) {
- return "Same position";
- }
- await Resources.devices.editDeviceData(site_id, { ...dev_id, location: location_data.location });
-};
-
-/**
- * Function that update the sensor's params (last checkin and battery)
- * @param context
- * @param org_id
- * @param device_id
- */
-async function resolveDevice(context: TagoContext, org_id: string, device_id: string) {
- console.debug("Resolving device", device_id);
- const sensor_info = await Resources.devices.info(device_id);
-
- if (!sensor_info) {
- return Promise.reject("Device not found");
- }
-
- checkLocation(sensor_info).catch((error) => console.debug(error));
-
- const device_info = await Resources.devices.info(device_id);
- if (!device_info.last_input) {
- return Promise.reject("Device not found");
- }
-
- const checkin_date = DateTime.fromISO(device_info.last_input.toString());
-
- if (!checkin_date.isValid) {
- return "no data";
- }
-
- let diff_hours: string | number = DateTime.now().diff(checkin_date, "hours").hours;
-
- if (isNaN(diff_hours)) {
- diff_hours = "-";
- } //checking for NaN
-
- const device_params = await Resources.devices.paramList(device_id);
- const dev_lastcheckin_param = device_params.find((param) => param.key === "dev_lastcheckin") || { key: "dev_lastcheckin", value: String(diff_hours), sent: false };
-
- await checkInTrigger(context, org_id, { device_id, last_input: device_info.last_input });
-
- await Resources.devices.paramSet(device_id, { ...dev_lastcheckin_param, value: String(diff_hours), sent: (diff_hours as number) >= 24 ? true : false });
-
- console.debug("Device resolved", device_id);
-}
-
-async function handler(context: TagoContext): Promise {
- console.debug("Running Analysis");
-
- const environment = Utils.envToJson(context.environment);
- if (!environment) {
- return;
- }
-
- const orgList = await fetchDeviceList({ tags: [{ key: "device_type", value: "organization" }] });
-
- orgList.map((org) => resolveOrg(org));
-
- const sensorList = await fetchDeviceList({ tags: [{ key: "device_type", value: "device" }] });
-
- const processSensorQueue = async.queue(async function (sensorItem: DeviceListItem, callback) {
- const organization_id = sensorItem?.tags?.find((tag) => tag.key === "organization_id")?.value as string;
- const device_id = sensorItem?.tags?.find((tag) => tag.key === "device_id")?.value as string;
- await resolveDevice(context, organization_id, device_id).catch((error) => console.debug(`${error} - ${sensorItem.id}`));
- callback();
- }, 1);
-
- //populating the queue
- for (const sensorItem of sensorList) {
- void processSensorQueue.push(sensorItem);
- }
-
- // console.debug("Queue populated", processSensorQueue.length());
-
- await processSensorQueue.drain();
-
- //throwing possible errors generated while running the queue
- processSensorQueue.error((error) => {
- console.debug(error);
- process.exit();
- });
-}
-
-/**
- * Start the analysis
- */
-async function startAnalysis(context: TagoContext) {
- try {
- await handler(context);
- console.debug("Analysis finished");
- } catch (error) {
- console.debug(error);
- console.debug(error.message || JSON.stringify(error));
- }
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { startAnalysis };
diff --git a/src/analysis/uplink-handler.ts b/src/analysis/uplink-handler.ts
deleted file mode 100644
index 2aee260..0000000
--- a/src/analysis/uplink-handler.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * KickStarter Analysis
- * Handler
- *
- * This analysis handles most of buttons clickable by dashboard input form widgets such as dynamic table and input form widgets.
- *
- * Handles the following actions:
- * - Add, edit and delete an Organization.
- * - Add, edit and delete a Group.
- * - Add, edit and delete a Sensor.
- * - Add, edit and delete a User.
- * - Add, edit and delete scheduled reports.
- */
-
-import { Analysis, Utils } from "@tago-io/sdk";
-import { Data, TagoContext } from "@tago-io/sdk/lib/types";
-
-import { sensorUplinkLocation } from "../services/uplinks/sensor-uplink-location";
-import { sensorUplinkStatus } from "../services/uplinks/sensor-uplink-status";
-import { sensorUplinkTempHum } from "../services/uplinks/sensor-uplink-temp-hum";
-
-/**
- *
- * @param context
- * @param scope
- * @returns
- */
-async function startAnalysis(context: TagoContext, scope: Data[]): Promise {
- context.log("Running Analysis");
- console.log("Scope:", scope);
-
- // Convert environment variables to a JSON.
- const environment = Utils.envToJson(context.environment);
- console.log("Environment:", environment);
-
- // Check if all tokens needed for the application were provided.
- if (!environment.config_id) {
- throw "Missing config_id environment var";
- } else if (environment.config_id.length !== 24) {
- return context.log('Invalid "config_id" in the environment variable');
- }
-
- // Just a little hack to set the device_list_button_id that come sfrom the scope
- // and set it to the environment variables instead. It makes easier to use router function later.
- environment._input_id = (scope as any).find((x: any) => x.device_list_button_id)?.device_list_button_id;
-
- // The router class will help you route the function the analysis must run
- // based on what had been received in the analysis.
- const router = new Utils.AnalysisRouter({ scope, context, environment });
-
- // Sensor uplink routing
- router.register(sensorUplinkLocation).whenVariables(["location"]);
- router.register(sensorUplinkStatus).whenVariables(["status", "water_leakage_detected"]);
- router.register(sensorUplinkTempHum).whenVariables(["temperature", "relative_humidity"]);
-
- await router.exec();
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { startAnalysis };
diff --git a/src/analysis/user-sign-up.ts b/src/analysis/user-sign-up.ts
deleted file mode 100644
index c46d646..0000000
--- a/src/analysis/user-sign-up.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * KickStarter Analysis
- * User Signup
- *
- * This analysis handles new users that register themselves in the application. It requires the RUN to have user auto-signup enabled.
- *
- *
- * How to setup this analysis
- * Make sure you have the following enviroment variables:
- * - config_token: the value must be a token from a HTTPs device, that stores general information of the application.
- * - account_token: the value must be a token from your profile. See how to generate account-token at: https://help.tago.io/portal/en/kb/articles/495-account-token.
- *
- * In the RUN Settings, enable user to auto-signup.
- * Create an Action of type Resource whenever an user is registered in the application to run this analysis.
- */
-
-import { Analysis, Resources, Utils } from "@tago-io/sdk";
-import { TagoContext, UserInfo } from "@tago-io/sdk/lib/types";
-
-import { parseTagoObject } from "../lib/data.logic";
-import { orgAdd } from "../services/organization/register";
-
-/**
- *
- * @param context
- * @param scope
- * @returns
- */
-async function startAnalysis(context: TagoContext, scope: UserInfo[]): Promise {
- console.debug("SCOPE:", JSON.stringify(scope, null, 4));
- console.debug("CONTEXT:", JSON.stringify(context, null, 4));
- console.debug("Running Analysis");
-
- // Convert the environment variables from [{ key, value }] to { key: value };
- const environment = Utils.envToJson(context.environment);
- if (!environment) {
- return;
- }
-
- const config_id = environment.config_id;
- if (!config_id) {
- throw "[Error] No config device ID: config_id.";
- }
-
- const organization_scope: any = parseTagoObject({
- new_org_name: scope[0].name,
- new_org_address: "N/A",
- new_org_plan_group: environment.plan_group,
- }).map((x) => ({ ...x, device: " ", time: new Date() }));
-
- let org_id: string | void = "";
-
- try {
- org_id = await orgAdd({ context, scope: organization_scope, environment });
- } catch (error) {
- await Resources.run.userDelete(scope[0].id);
- return console.debug(error);
- }
-
- if (!org_id) {
- throw "Error creating organization";
- }
-
- await Resources.run.userEdit(scope[0].id, {
- tags: [
- { key: "organization_id", value: org_id },
- { key: "access", value: "orgadmin" },
- ],
- });
-
- await Resources.devices.sendDeviceData(org_id, { variable: "user_id", value: scope[0].id, metadata: { label: `${scope[0].name} (${scope[0].email})` } });
-}
-
-if (!process.env.T_TEST) {
- Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN });
-}
-
-export { startAnalysis };
diff --git a/src/lib/README.MD b/src/lib/README.MD
deleted file mode 100644
index 3f1b177..0000000
--- a/src/lib/README.MD
+++ /dev/null
@@ -1,36 +0,0 @@
-# Alerts Folder Overview
-This folder is responsible for the alerts handling. Mostly actions are triggered by the "Alert Central" dashboard.
-
-## Files
-The Alert folder contains the following files:
-* Register an alert - register.ts
-* Edit an alert - edit.ts
-* Remove an alert - remove.ts
-
-### Diagram
-
-```mermaid
-sequenceDiagram
- participant RUN Application
- participant Handler Analysis
- participant Alerts Script Folder
- participant TagoIO
- alt register.ts
- RUN Application ->> Handler Analysis: "Create New" command trigger
- Handler Analysis ->> Alerts Script Folder: Redirects
- Alerts Script Folder -->> TagoIO: Creates TagoIO action
- Alerts Script Folder -->> TagoIO: Send new alert to "Alert List" table (create action_list variable)
- end
- alt edit.ts
- RUN Application ->> Handler Analysis: Controls "Edit" command trigger
- Handler Analysis ->> Alerts Script Folder: Redirects
- Alerts Script Folder -->> TagoIO: Edit TagoIO action
- Alerts Script Folder -->> TagoIO: Edit existing alert on "Alert List" table
- end
- alt delete.ts
- RUN Application ->> Handler Analysis: Controls "Delete" command trigger
- Handler Analysis ->> Alerts Script Folder: Redirects
- Alerts Script Folder -->> TagoIO: Delete TagoIO action
- Alerts Script Folder -->> TagoIO: Delete existing alert on "Alert List" table
- end
-```
diff --git a/src/lib/auditlog-setup.ts b/src/lib/auditlog-setup.ts
deleted file mode 100644
index b87d129..0000000
--- a/src/lib/auditlog-setup.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Resources } from "@tago-io/sdk";
-
-import { parseTagoObject } from "./data.logic";
-
-/**
- * Function that edit user information and send audit log
- * @param device_id Device ID
- */
-function auditLogSetup(device_id: string) {
- return async function _(new_value: string, siren_state?: string, user_id?: string) {
- if (!new_value) {
- throw "Missing new_value";
- }
- let name = "System";
- if (user_id) {
- name = (await Resources.run.userInfo(user_id))?.name || "System";
- if (name === "System") {
- user_id = "System";
- }
- }
-
- await Resources.devices.sendDeviceData(
- device_id,
- parseTagoObject(
- {
- audit_user: { value: user_id || "System", metadata: { label: name } },
- audit_new_value: new_value,
- siren_state: siren_state,
- },
- String(Date.now())
- )
- );
- };
-}
-
-export { auditLogSetup };
diff --git a/src/lib/create-dash-url.ts b/src/lib/create-dash-url.ts
deleted file mode 100644
index aa90e0d..0000000
--- a/src/lib/create-dash-url.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * Creates a URL for a TagoIO dashboard with the given ID and query parameters.
- * @param dashID - The ID of the dashboard to create the URL for.
- * @param params - An object containing key-value pairs to be used as query parameters in the URL.
- * @returns The URL for the dashboard with the given ID and query parameters.
- */
-function createDashURL(dashID: string, params: { [key: string]: string }) {
- const url = `https://admin.tago.io/dashboards/info/${dashID}`;
- const paramsURL = new URLSearchParams(params).toString();
- if (paramsURL.length === 0) {
- return `${url}`;
- }
- return `${url}?${paramsURL}`;
-}
-
-export { createDashURL };
diff --git a/src/lib/data.logic.ts b/src/lib/data.logic.ts
deleted file mode 100644
index 0f73aea..0000000
--- a/src/lib/data.logic.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-// ? ==================================== (c) TagoIO ====================================
-// ? What is this file?
-// * This file is all logics of parseInt (example script).
-// ? ====================================================================================
-
-import { DataToSend } from "@tago-io/sdk/lib/types";
-
-interface GenericBody {
- [index: string]: any;
-}
-/**
- * Function that parse the body of the request to TagoIO
- * @param body Body of the request
- * @param group Group of the request
- */
-function parseTagoObject(body: GenericBody, group?: string): DataToSend[] {
- if (!group) {
- group = String(Date.now());
- }
- return Object.keys(body)
- .map((item) => {
- return {
- variable: item,
- value: body[item] instanceof Object ? body[item].value : body[item],
- group,
- time: body[item] instanceof Object ? body[item].time : null,
- location: body[item] instanceof Object ? body[item].location : null,
- metadata: body[item] instanceof Object ? body[item].metadata : null,
- };
- })
- .filter((item) => item.value !== null && item.value !== undefined);
-}
-
-export { parseTagoObject };
diff --git a/src/lib/device-name-exists.test.ts b/src/lib/device-name-exists.test.ts
deleted file mode 100644
index 701aa16..0000000
--- a/src/lib/device-name-exists.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { deviceNameExists } from "./device-name-exists";
-
-vitest.mock("../lib/fetch-device-list", () => {
- return {
- fetchDeviceList: vitest
- .fn()
- .mockImplementationOnce(() => Promise.resolve([]))
- .mockImplementationOnce(() => Promise.resolve([{ id: "orgOne" }]))
- .mockImplementationOnce(() => Promise.resolve([{ id: "orgOne" }, { id: "orgTwo" }])),
- };
-});
-
-describe("deviceNameExists", () => {
- test("should return false when device name does not exist", async () => {
- const result = await deviceNameExists({ name: "My Organization", tags: [{ key: "device_type", value: "organization" }] });
- expect(result).toBe(false);
- });
-
- test("should return true when device name exists and isEdit is false", async () => {
- const result = await deviceNameExists({ name: "My Organization", tags: [{ key: "device_type", value: "organization" }] });
- expect(result).toBe(true);
- });
-
- test("should return true when device name exists and isEdit is true with more than 1 matching device", async () => {
- const result = await deviceNameExists({ name: "My Organization", tags: [{ key: "device_type", value: "organization" }], isEdit: true });
- expect(result).toBe(true);
- });
-});
diff --git a/src/lib/device-name-exists.ts b/src/lib/device-name-exists.ts
deleted file mode 100644
index 27400e3..0000000
--- a/src/lib/device-name-exists.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { TagsObj } from "@tago-io/sdk/lib/types";
-
-import { fetchDeviceList } from "./fetch-device-list";
-
-interface DeviceSource {
- name: string;
- tags: TagsObj[];
- isEdit?: boolean;
-}
-
-/**
- * The Device Creation and Edit utilize this method.
- * @description Check if device name exists
- * @param {string} name Device name
- * @param {TagsObj[]} tags Device tags
- * @param {boolean} isEdit When editing a device, if a device with the same name already exists, it should return two devices.
- * This is because the frontend automatically handles the editing process.
- */
-async function deviceNameExists({ name, tags, isEdit = false }: DeviceSource) {
- const device = await fetchDeviceList({
- name,
- tags,
- });
-
- if (isEdit && device.length > 1) {
- return true;
- } else if (!isEdit && device.length > 0) {
- return true;
- }
-
- return false;
-}
-
-export { deviceNameExists };
diff --git a/src/lib/edit.params.test.ts b/src/lib/edit.params.test.ts
deleted file mode 100644
index 41e0a65..0000000
--- a/src/lib/edit.params.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { ParamResolver } from "./edit.params";
-
-describe("LIB | edit.params Resolver", () => {
- test("Success Resolver", async () => {
- const paramResolver = ParamResolver([{ key: "test", value: "1", sent: false, id: "1234" }], true);
- paramResolver.setParam("test2", "23423");
- paramResolver.setParam("test", "5555");
- const result = await paramResolver.apply("");
-
- expect(result).toStrictEqual([
- { key: "test2", value: "23423", sent: true },
- { key: "test", value: "5555", sent: true, id: "1234" },
- ]);
- });
-
- test("Invalid Key Type", () => {
- const paramResolver = ParamResolver([]);
-
- // @ts-expect-error we are testing an invalid key
- expect(() => paramResolver.setTag(1234, "test")).toThrow();
- });
-
- test("Invalid Value Type", () => {
- const paramResolver = ParamResolver([]);
-
- // @ts-expect-error we are testing an invalid key
- expect(() => paramResolver.setTag("test", 1234)).toThrow();
- });
-});
diff --git a/src/lib/edit.params.ts b/src/lib/edit.params.ts
deleted file mode 100644
index bd88943..0000000
--- a/src/lib/edit.params.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { Resources } from "@tago-io/sdk";
-import { ConfigurationParams } from "@tago-io/sdk/lib/types";
-
-/**
- * Creates a resolver to add/update configuration params on the devices.
- * It automatically identifies if the data already exists or not.
- * @example
- * const paramList = await Resources.devices.list(deviceID);
- * const editParam = ParamResolver(paramList);
- * editParam.setParam("device_status", "ON");
- * await editParam.apply(account, deviceID);
- *
- * @param {ConfigurationParams[]} rawParams param list if you already have in your code.
- * @param debug
- * @returns
- */
-function ParamResolver(rawParams: ConfigurationParams[], debug: boolean = false) {
- const paramList: ConfigurationParams[] = [];
-
- const paramResolver = {
- /**
- * Set the configuration parameter for your Device
- * @param {string} key key of the Tag
- * @param {string} value value of the Tag
- * @param {string} [sent] optional sent value
- * @returns
- */
- setParam: function (key: string, value: string, sent: boolean = true) {
- if (typeof key !== "string") {
- throw "[ParamResolver] key is not a string";
- }
- if (typeof value !== "string") {
- throw "[ParamResolver] key is not a string";
- }
- const oldParam = rawParams.find((x) => x.key === key);
- paramList.push({ ...oldParam, key, value, sent });
- return this;
- },
-
- /**
- * Apply the changes to the configuration parameters.
- * @param {string} deviceID Device ID to apply the changes
- * @returns
- */
- apply: async function (deviceID: string) {
- if (debug) {
- return paramList;
- }
- await Resources.devices.paramSet(deviceID, paramList);
- },
-
- /**
- * Check if there is any change to be applied.
- * @returns {boolean} true if there is any change to be applied.
- */
- hasChanged: function () {
- return paramList.length > 0;
- },
- };
-
- return paramResolver;
-}
-
-export { ParamResolver };
diff --git a/src/lib/edit.tag.test.ts b/src/lib/edit.tag.test.ts
deleted file mode 100644
index 19650b5..0000000
--- a/src/lib/edit.tag.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { TagResolver } from "./edit.tag";
-
-describe("LIB | edit.tag Resolver", () => {
- test("Success Resolver", async () => {
- const tagResolver = TagResolver([{ key: "test", value: "1" }], true);
- tagResolver.setTag("test2", "23423");
- tagResolver.setTag("test", "5555");
- const result = await tagResolver.apply("");
-
- expect(result).toStrictEqual([
- { key: "test2", value: "23423" },
- { key: "test", value: "5555" },
- ]);
- });
-
- test("Invalid Key Type", () => {
- const tagResolver = TagResolver([]);
-
- // @ts-expect-error we are testing an invalid key
- expect(() => tagResolver.setTag(1234, "test")).toThrow();
- });
-
- test("Invalid Value Type", () => {
- const tagResolver = TagResolver([]);
-
- // @ts-expect-error we are testing an invalid key
- expect(() => tagResolver.setTag("test", 1234)).toThrow();
- });
-});
diff --git a/src/lib/edit.tag.ts b/src/lib/edit.tag.ts
deleted file mode 100644
index ddcef48..0000000
--- a/src/lib/edit.tag.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Resources } from "@tago-io/sdk";
-import { TagsObj } from "@tago-io/sdk/lib/types";
-
-/**
- * Creates a resolver to add/update tags on the devices.
- * It automatically identifies if the tag already exists or not.
- * @example
- * const { tags } = await Resources.devices.info(deviceID);
- * const editTags = TagResolver(tags);
- * editTags.setTag("device_status", "ON");
- * await editTags.apply(deviceID);
- *
- * @param {TagsObj[]} rawTags list of your device existing Tags
- * @param debug
- * @returns
- */
-function TagResolver(rawTags: TagsObj[], debug: boolean = false) {
- const tags = JSON.parse(JSON.stringify(rawTags)) as TagsObj[];
- const newTags: TagsObj[] = [];
-
- const tagResolver = {
- /**
- * Set the Tag for your Device
- * @param {string} key key of the Tag
- * @param {string} value value of the Tag
- * @returns
- */
- setTag: function (key: string, value: string) {
- if (typeof key !== "string") {
- throw "[TagResolver] key is not a string";
- }
- if (typeof value !== "string") {
- throw "[TagResolver] key is not a string";
- }
- const tagExist = tags.find((x) => x.key === key);
- if (!tagExist || tagExist.value !== value) {
- newTags.push({ key, value });
- }
- return this;
- },
-
- /**
- * Apply the changes to the tags
- * @param {string} deviceID Device ID to apply the changes
- * @returns
- */
- apply: async function (deviceID: string) {
- if (debug) {
- return newTags;
- }
- // merge tags and newTags, replacing the old tags with the new ones.
- for (const newTag of newTags) {
- const oldTagIndex = tags.findIndex((x) => x.key === newTag.key);
- if (oldTagIndex >= 0) {
- tags[oldTagIndex] = newTag;
- } else {
- tags.push(newTag);
- }
- }
-
- await Resources.devices.edit(deviceID, { tags });
- },
-
- /**
- * Check if there is any change to be applied.
- * @returns {boolean} true if there is any change to be applied.
- * @memberof TagResolver
- */
- hasChanged: function () {
- return newTags.length > 0;
- },
- };
-
- return tagResolver;
-}
-
-export { TagResolver };
diff --git a/src/lib/fetch-device-list.ts b/src/lib/fetch-device-list.ts
deleted file mode 100644
index 167edb4..0000000
--- a/src/lib/fetch-device-list.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Resources } from "@tago-io/sdk";
-import { DeviceListItem, DeviceQuery } from "@tago-io/sdk/lib/types";
-
-type FetchDeviceResponse = Pick;
-/**
- * Fetchs the device list using filters.
- * Automatically apply pagination to not run on throughtput errors.
- * @param filter filter conditions of the request
- * @returns
- */
-async function fetchDeviceList(filter: DeviceQuery["filter"]): Promise {
- let device_list: FetchDeviceResponse[] = [];
-
- for (let index = 1; index < 9999; index++) {
- const amount = 100;
- const foundDevices = await Resources.devices.list({
- page: index,
- fields: ["id", "name", "tags", "last_input", "created_at"],
- filter,
- resolveBucketName: false,
- amount,
- });
-
- device_list = device_list.concat(foundDevices);
- if (foundDevices.length < amount) {
- return device_list;
- }
- }
-
- return device_list;
-}
-
-export { fetchDeviceList, FetchDeviceResponse };
diff --git a/src/lib/fetch-user-list.ts b/src/lib/fetch-user-list.ts
deleted file mode 100644
index 4402f4c..0000000
--- a/src/lib/fetch-user-list.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Resources } from "@tago-io/sdk";
-import { UserInfo, UserQuery } from "@tago-io/sdk/lib/types";
-
-type FetchUserResponse = Pick;
-
-/**
- * Fetchs the user list using filters.
- * Automatically apply pagination to not run on throughtput errors.
- * @param filter filter conditions of the request
- * @returns
- */
-async function fetchUserList(filter: UserQuery["filter"]): Promise {
- let userList: FetchUserResponse[] = [];
-
- for (let index = 1; index < 9999; index++) {
- const amount = 40;
- const foundUsers = await Resources.run
- .listUsers({
- page: index,
- fields: ["id", "name", "phone", "company", "tags", "active", "email", "timezone"],
- filter,
- amount,
- })
- .then((r) => r as FetchUserResponse[]);
-
- userList = userList.concat(foundUsers);
- if (foundUsers.length < amount) {
- return userList;
- }
- }
-
- return userList;
-}
-
-export { fetchUserList };
diff --git a/src/lib/find-resource.ts b/src/lib/find-resource.ts
deleted file mode 100644
index 20edd3a..0000000
--- a/src/lib/find-resource.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { Resources } from "@tago-io/sdk";
-
-/**
- * Get the ANalysis ID by it's tag export_id value
- * @param resources Resources with Account Token
- * @param tagValue tag value string
- * @returns
- */
-async function getAnalysisByTagID(resources: Resources, tagValue: string, tagKey: string = "export_id") {
- // Should be pass the Resources with Account Token because the Access Management doesn't have access to the Analysis
- const [analysis] = await resources.analysis.list({
- amount: 1,
- fields: ["id", "tags"],
- filter: { tags: [{ key: tagKey, value: tagValue }] },
- });
- if (!analysis) {
- throw `Analysis ${tagValue} not found`;
- }
-
- return analysis?.id;
-}
-
-/**
- * Get the Dashboard ID by it's tag export_id value
- * @param tagValue tag value string
- * @returns
- */
-async function getDashboardByTagID(tagValue: string, tagKey: string = "export_id") {
- const [dash] = await Resources.dashboards.list({
- amount: 1,
- fields: ["id", "tags"],
- filter: { tags: [{ key: tagKey, value: tagValue }] },
- });
- if (!dash) {
- throw `Dashboard ${tagValue} not found`;
- }
-
- return dash?.id;
-}
-
-/**
- * Get the Mutable Device ID of a device using the device_type tag
- * Matches the tag device_id with the deviceID param
- * @param deviceID
- * @param deviceType tag value string
- * @returns
- */
-async function getLinkedDeviceID(deviceID: string, deviceType: string = "device-storage") {
- const [device] = await Resources.devices.list({
- amount: 1,
- fields: ["id", "tags"],
- filter: {
- tags: [
- { key: "device_id", value: deviceID },
- { key: "device_type", value: deviceType },
- ],
- },
- });
-
- if (!device) {
- throw `Linked Device ${deviceID} not found`;
- }
-
- return device.id;
-}
-
-/**
- * Get the Dashboard ID by it's tag connector_id value
- * @param tagValue tag value string
- * @returns
- */
-async function getDashboardByConnectorID(connector_id: string) {
- const [dash] = await Resources.dashboards.list({ amount: 1, fields: ["id", "tags"], filter: { tags: [{ key: "connector_id", value: connector_id }] } });
- if (!dash) {
- throw `Dashboard ${connector_id} not found`;
- }
-
- return { id: dash?.id };
-}
-
-export { getDashboardByTagID, getAnalysisByTagID, getLinkedDeviceID, getDashboardByConnectorID };
diff --git a/src/lib/get-push-message.ts b/src/lib/get-push-message.ts
deleted file mode 100644
index 35395ab..0000000
--- a/src/lib/get-push-message.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Resources } from "@tago-io/sdk";
-
-interface NotificationMessage {
- [key: string]: any;
- device?: string;
- percent?: string;
- location?: string;
-}
-
-async function getPushMessage(message_builder: NotificationMessage, template_name: string) {
- const run = await Resources.run.info();
- const template = run.email_templates[template_name];
-
- template.value = template.value.replaceAll("$", "");
- for (const key of Object.keys(message_builder)) {
- const regex = new RegExp(`${key}`, "g");
- template.value = template.value.replace(regex, message_builder[key]);
- }
-
- return template;
-}
-export { getPushMessage };
diff --git a/src/lib/html-body.ts b/src/lib/html-body.ts
deleted file mode 100644
index fbe79d0..0000000
--- a/src/lib/html-body.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-const htmlBody = `
-
-
-
-
-
-
-
-
-
-
Registered Sensor(s):
- $TOTAL_QTY$
-
-
-
Active Sensor(s):
- $ACTIVE_QTY$
-
-
-
Inactive Sensor(s):
- $INACTIVE_QTY$
-
-
-
-
- $TABLE_HEADER$
-
-
- $REPORT_TABLE$
-
-
-
-
-
-