diff --git a/.changeset/desktop-autoupdate-notifications.md b/.changeset/desktop-autoupdate-notifications.md new file mode 100644 index 0000000000..21e245195b --- /dev/null +++ b/.changeset/desktop-autoupdate-notifications.md @@ -0,0 +1,23 @@ +--- +"@electric-ax/agents-desktop": patch +--- + +Wire `electron-updater` so the desktop app can detect new releases. Phase +one of two: + +* Adds a working **Check for Updates…** menu item (Electric Agents menu + on macOS, Help menu on Windows/Linux, plus the in-window app-icon + menu) and a quiet background check ~10s after launch. +* On Windows/Linux, signed-platform flow is wired end-to-end: downloads + in the background with a dock/taskbar progress bar, then prompts + "Restart now" to apply via `quitAndInstall()`. +* On macOS, ships as **notify-only** until Developer ID signing lands — + Squirrel.Mac can't swap an unsigned bundle, so we skip the download + entirely and prompt to open the GitHub releases page instead. +* Switches the publish provider from `github` to `generic` pointed at + the moving `agents-desktop-latest` tag, because the repo's overall + "latest" release is shared across packages and the GitHub provider + was picking the wrong one. +* Adds channel separation so canary builds publish to the `beta` + channel against an `agents-desktop-canary` URL — stable users never + auto-update to canaries. diff --git a/.github/workflows/agents_desktop_build.yml b/.github/workflows/agents_desktop_build.yml index b7d7d80cf3..13c18c28c9 100644 --- a/.github/workflows/agents_desktop_build.yml +++ b/.github/workflows/agents_desktop_build.yml @@ -254,15 +254,29 @@ jobs: if [[ "${{ inputs.sign }}" != "true" ]]; then builder_args+=("-c.mac.identity=-") fi + if [[ "${{ inputs.channel }}" == "canary" ]]; then + builder_args+=("-c.channel=beta") + builder_args+=("-c.publish.url=https://github.com/electric-sql/electric/releases/download/agents-desktop-canary") + fi pnpm --filter @electric-ax/agents-desktop exec electron-builder "${builder_args[@]}" --publish never - name: Package desktop app if: ${{ matrix.id != 'macos' }} + shell: bash env: CSC_IDENTITY_AUTO_DISCOVERY: ${{ inputs.sign && 'true' || 'false' }} GH_TOKEN: ${{ github.token }} - run: pnpm --filter @electric-ax/agents-desktop exec electron-builder ${{ matrix.builder_args }} --publish never + run: | + set -euo pipefail + + builder_args=(${{ matrix.builder_args }}) + if [[ "${{ inputs.channel }}" == "canary" ]]; then + builder_args+=("-c.channel=beta") + builder_args+=("-c.publish.url=https://github.com/electric-sql/electric/releases/download/agents-desktop-canary") + fi + + pnpm --filter @electric-ax/agents-desktop exec electron-builder "${builder_args[@]}" --publish never - name: Verify macOS app signatures if: ${{ matrix.id == 'macos' }} @@ -539,6 +553,13 @@ jobs: exit 1 } + # Always include the original artifacts + electron-updater metadata + # (latest*.yml / beta*.yml + .blockmap files). The updater expects + # the filenames referenced in the yml; renamed copies are added on + # top for canary so users get stable "latest canary" download URLs. + cp packages/agents-desktop/release/*.{dmg,zip,exe,AppImage,deb,blockmap,yml,yaml} \ + packages/agents-desktop/publish-assets/ 2>/dev/null || true + if [[ "$CHANNEL" == "canary" ]]; then case "$MATRIX_ID" in macos) @@ -555,9 +576,6 @@ jobs: copy_first "Electric-Agents-canary-linux-x64.deb" packages/agents-desktop/release/*.deb ;; esac - else - cp packages/agents-desktop/release/*.{dmg,zip,exe,AppImage,deb,blockmap,yml,yaml} \ - packages/agents-desktop/publish-assets/ 2>/dev/null || true fi assets=(packages/agents-desktop/publish-assets/*) diff --git a/packages/agents-desktop/electron-builder.yml b/packages/agents-desktop/electron-builder.yml index da70c87b0a..f318f9a5e6 100644 --- a/packages/agents-desktop/electron-builder.yml +++ b/packages/agents-desktop/electron-builder.yml @@ -73,6 +73,13 @@ linux: - x64 publish: - provider: github - owner: electric-sql - repo: electric + # Using `generic` (not `github`) because the electric-sql/electric repo + # publishes many packages to the same Releases page (changesets-style). + # GitHub's "latest" marker tracks whichever release was most recently + # tagged across all packages, so the `github` provider grabs the wrong + # release and can't find latest-mac.yml. + # Instead we point at the moving `agents-desktop-latest` tag that the + # release workflow maintains specifically for desktop stable builds. + # Canary builds override `url` to `agents-desktop-canary` at build time. + provider: generic + url: 'https://github.com/electric-sql/electric/releases/download/agents-desktop-latest' diff --git a/packages/agents-desktop/package.json b/packages/agents-desktop/package.json index 8c8b907a2d..5a1b0ae7af 100644 --- a/packages/agents-desktop/package.json +++ b/packages/agents-desktop/package.json @@ -32,6 +32,7 @@ "better-sqlite3": "^12.9.0", "dockerode": "^5.0.0", "e2b": ">=2.0.0", + "electron-updater": "^6.3.9", "fix-path": "^4.0.0", "jsdom": "^28.1.0", "pino": "^10.3.1", diff --git a/packages/agents-desktop/src/app/controller.ts b/packages/agents-desktop/src/app/controller.ts index 95ba2bdb87..605573750f 100644 --- a/packages/agents-desktop/src/app/controller.ts +++ b/packages/agents-desktop/src/app/controller.ts @@ -2,6 +2,7 @@ import { BrowserWindow } from 'electron' import type { DesktopAppContext } from './context' import * as AppLifecycle from './lifecycle' import * as LoginItems from './login-items' +import { createDesktopUpdater } from './updater' import * as CloudAuthInjection from '../cloud/auth-injection' import * as ServerFetch from '../cloud/server-fetch' import { createCredentialsController } from '../credentials/controller' @@ -305,12 +306,20 @@ export function createDesktopMainController(ctx: DesktopAppContext) { AppLifecycle.applyNativeAppearance(ctx, appearance) } + const updater = createDesktopUpdater({ + showOrCreateWindow, + }) + + const checkForUpdates = (): Promise => + updater.checkForUpdates({ triggeredManually: true }) + const applicationMenuDeps: ApplicationMenu.ApplicationMenuDeps = { windows, createWindow, sendCommand, quitApp, showAboutDialog, + checkForUpdates, } function showAboutDialog(): void { @@ -345,7 +354,11 @@ export function createDesktopMainController(ctx: DesktopAppContext) { win: BrowserWindow, bounds: DesktopMenuPopupBounds ): void => { - ApplicationMenu.popupAppIconMenu({ showAboutDialog }, win, bounds) + ApplicationMenu.popupAppIconMenu( + { showAboutDialog, checkForUpdates }, + win, + bounds + ) } const desktopIpcDeps: DesktopIpc.RegisterDesktopIpcDeps = { @@ -472,6 +485,7 @@ export function createDesktopMainController(ctx: DesktopAppContext) { syncLaunchAtLoginSetting, connectConfiguredServers, startDiscoveryLoop: localDiscovery.startDiscoveryLoop, + initializeUpdater: updater.initialize, quitApp, } } diff --git a/packages/agents-desktop/src/app/updater.ts b/packages/agents-desktop/src/app/updater.ts new file mode 100644 index 0000000000..9760c80dde --- /dev/null +++ b/packages/agents-desktop/src/app/updater.ts @@ -0,0 +1,307 @@ +import { app, BrowserWindow, dialog, shell } from 'electron' +import { autoUpdater, type UpdateInfo } from 'electron-updater' +import { APP_DISPLAY_NAME, ELECTRIC_GITHUB_URL } from '../shared/constants' + +const RELEASES_URL = `${ELECTRIC_GITHUB_URL}/releases` + +// macOS auto-install via Squirrel.Mac requires the app to be Developer-ID +// signed. Until phase 2 (signing + notarization in CI), surface updates on +// macOS as a notification that opens the releases page in the browser. +const MAC_AUTO_INSTALL_SUPPORTED = false + +const STARTUP_CHECK_DELAY_MS = 10_000 + +export type DesktopUpdater = { + initialize: () => void + checkForUpdates: (options: { triggeredManually: boolean }) => Promise +} + +export type DesktopUpdaterDeps = { + showOrCreateWindow: () => void +} + +type CheckMode = `auto` | `manual` + +const updaterLogger = { + info: (message: unknown) => console.info(`[agents-desktop:updater]`, message), + warn: (message: unknown) => console.warn(`[agents-desktop:updater]`, message), + error: (message: unknown) => + console.error(`[agents-desktop:updater]`, message), + debug: (_message: unknown) => { + // electron-updater's debug stream is very chatty; drop it. + }, +} + +export function createDesktopUpdater(deps: DesktopUpdaterDeps): DesktopUpdater { + let initialized = false + // Tracks which mode triggered the in-flight check so event handlers can + // decide whether to show user-facing dialogs (manual) or stay silent (auto). + let activeMode: CheckMode | null = null + let checkInFlight: Promise | null = null + // electron-updater caches downloads and re-fires `update-downloaded` if + // `downloadUpdate()` is called twice for the same version (e.g. a manual + // Download click overlaps with the background auto check). Track which + // version we already triggered a download for and which version's "ready" + // dialog we already showed, so overlapping flows don't stack duplicates. + let downloadStartedVersion: string | null = null + let promptedDownloadedVersion: string | null = null + + function attachEventHandlers(): void { + autoUpdater.logger = updaterLogger + // Ask the user before pulling down a potentially large installer. + autoUpdater.autoDownload = false + // On macOS we never auto-install in phase 1; on Win/Linux we let the + // installer run on next quit if the user dismisses the restart prompt. + autoUpdater.autoInstallOnAppQuit = + process.platform !== `darwin` || MAC_AUTO_INSTALL_SUPPORTED + + autoUpdater.on(`error`, (error) => { + console.error(`[agents-desktop:updater] update error:`, error) + setDownloadProgressBar(null) + if (activeMode === `manual`) { + void showMessageBox({ + type: `error`, + title: `${APP_DISPLAY_NAME} updates`, + message: `Couldn't check for updates`, + detail: + (error instanceof Error ? error.message : String(error)) || + `Unknown error`, + buttons: [`OK`], + defaultId: 0, + }) + } + }) + + autoUpdater.on(`update-available`, (info) => { + void handleUpdateAvailable(info) + }) + + autoUpdater.on(`update-not-available`, () => { + if (activeMode === `manual`) { + void showMessageBox({ + type: `info`, + title: `${APP_DISPLAY_NAME} updates`, + message: `You're up to date`, + detail: `${APP_DISPLAY_NAME} ${app.getVersion()} is the latest version.`, + buttons: [`OK`], + defaultId: 0, + }) + } + }) + + autoUpdater.on(`download-progress`, (progress) => { + const percent = Math.round(progress.percent ?? 0) + console.info(`[agents-desktop:updater] downloading update: ${percent}%`) + setDownloadProgressBar(progress.percent ?? 0) + }) + + autoUpdater.on(`update-downloaded`, (info) => { + setDownloadProgressBar(null) + void handleUpdateDownloaded(info) + }) + } + + function setDownloadProgressBar(percent: number | null): void { + // `progress` of -1 clears the dock/taskbar indicator; otherwise a 0–1 + // fraction. Apply to every live window so the indicator shows up + // regardless of which window has focus. + const value = + percent === null ? -1 : Math.max(0, Math.min(1, percent / 100)) + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.setProgressBar(value) + } + } + + async function startDownload(version: string): Promise { + if (downloadStartedVersion === version) return + downloadStartedVersion = version + try { + await autoUpdater.downloadUpdate() + } catch (error) { + console.error(`[agents-desktop:updater] download failed:`, error) + // Allow a retry on the next check. + downloadStartedVersion = null + setDownloadProgressBar(null) + } + } + + function getParentWindow(): BrowserWindow | undefined { + const focused = BrowserWindow.getFocusedWindow() + if (focused && !focused.isDestroyed()) return focused + const any = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) + if (any) return any + // No live window — surface one before showing the dialog so the user + // sees a modal anchored to the app rather than a stray system alert. + deps.showOrCreateWindow() + return BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) + } + + async function showMessageBox( + options: Electron.MessageBoxOptions + ): Promise { + const parent = getParentWindow() + return parent + ? dialog.showMessageBox(parent, options) + : dialog.showMessageBox(options) + } + + function canAutoInstall(): boolean { + // Squirrel.Mac requires a Developer-ID signature to swap the bundle, so + // on unsigned macOS we operate as a notifier: we don't download the + // installer (it'd just be discarded — the user has to grab it from the + // browser to install it anyway), we just point them at the releases page. + return process.platform !== `darwin` || MAC_AUTO_INSTALL_SUPPORTED + } + + async function handleUpdateAvailable(info: UpdateInfo): Promise { + if (!canAutoInstall()) { + // Notifier-only mode: skip download, prompt on manual check only. + // (Auto checks stay silent so they don't pop dialogs while the user + // is mid-task; they'll find out next time they manually check.) + if (activeMode === `manual`) { + await promptOpenReleasesPage(info.version) + } + return + } + + if (activeMode !== `manual`) { + // Background check: download silently; prompt on update-downloaded. + void startDownload(info.version) + return + } + + // Manual flow: a prior auto check may have already kicked off the + // download — skip straight to a confirmation in that case. + if (downloadStartedVersion === info.version) { + void showMessageBox({ + type: `info`, + title: `${APP_DISPLAY_NAME} updates`, + message: `${APP_DISPLAY_NAME} ${info.version} is downloading`, + detail: `You'll be notified when it's ready to install.`, + buttons: [`OK`], + defaultId: 0, + }) + return + } + + const { response } = await showMessageBox({ + type: `info`, + title: `${APP_DISPLAY_NAME} updates`, + message: `A new version is available`, + detail: `${APP_DISPLAY_NAME} ${info.version} is available. You're currently on ${app.getVersion()}.`, + buttons: [`Download`, `Later`], + defaultId: 0, + cancelId: 1, + }) + if (response !== 0) return + + void startDownload(info.version) + // Confirm so the user knows the click registered — the download runs in + // the background and only surfaces UI again when it finishes. + void showMessageBox({ + type: `info`, + title: `${APP_DISPLAY_NAME} updates`, + message: `Downloading ${APP_DISPLAY_NAME} ${info.version}`, + detail: `Download progress shows in the dock. You'll be notified when it's ready to install.`, + buttons: [`OK`], + defaultId: 0, + }) + } + + async function promptOpenReleasesPage(version: string): Promise { + const { response } = await showMessageBox({ + type: `info`, + title: `${APP_DISPLAY_NAME} updates`, + message: `${APP_DISPLAY_NAME} ${version} is available`, + detail: `Open the releases page to download and install the new version.`, + buttons: [`Open releases page`, `Later`], + defaultId: 0, + cancelId: 1, + }) + if (response === 0) { + void shell.openExternal(RELEASES_URL) + } + } + + async function handleUpdateDownloaded(info: UpdateInfo): Promise { + // Only fires on platforms that can auto-install; unsigned macOS skips the + // download in `handleUpdateAvailable`, so it never reaches this path. + if (promptedDownloadedVersion === info.version) return + promptedDownloadedVersion = info.version + + const { response } = await showMessageBox({ + type: `info`, + title: `${APP_DISPLAY_NAME} updates`, + message: `${APP_DISPLAY_NAME} ${info.version} is ready to install`, + detail: `Restart ${APP_DISPLAY_NAME} now to apply the update.`, + buttons: [`Restart now`, `Later`], + defaultId: 0, + cancelId: 1, + }) + if (response === 0) { + autoUpdater.quitAndInstall() + } + } + + function initialize(): void { + if (initialized) return + initialized = true + + if (!app.isPackaged) { + console.info( + `[agents-desktop:updater] running unpackaged; auto-update disabled` + ) + return + } + + attachEventHandlers() + + setTimeout(() => { + void checkForUpdates({ triggeredManually: false }) + }, STARTUP_CHECK_DELAY_MS) + } + + async function checkForUpdates(options: { + triggeredManually: boolean + }): Promise { + const mode: CheckMode = options.triggeredManually ? `manual` : `auto` + + if (!app.isPackaged) { + if (mode === `manual`) { + await showMessageBox({ + type: `info`, + title: `${APP_DISPLAY_NAME} updates`, + message: `Updates are only available in packaged builds`, + detail: `You're running ${APP_DISPLAY_NAME} from source.`, + buttons: [`OK`], + defaultId: 0, + }) + } + return + } + + // Coalesce concurrent checks — manual click during an auto check just + // attaches to the in-flight promise (and upgrades to manual mode so + // dialogs fire when it resolves). + if (checkInFlight) { + if (mode === `manual`) activeMode = `manual` + return checkInFlight + } + + activeMode = mode + checkInFlight = (async () => { + try { + await autoUpdater.checkForUpdates() + } catch (error) { + console.error(`[agents-desktop:updater] check failed:`, error) + } finally { + checkInFlight = null + activeMode = null + } + })() + + return checkInFlight + } + + return { initialize, checkForUpdates } +} diff --git a/packages/agents-desktop/src/main.ts b/packages/agents-desktop/src/main.ts index 5f7430aa4b..7972220313 100644 --- a/packages/agents-desktop/src/main.ts +++ b/packages/agents-desktop/src/main.ts @@ -131,6 +131,7 @@ async function main(): Promise { } controller.connectConfiguredServers() controller.startDiscoveryLoop() + controller.initializeUpdater() } void main() diff --git a/packages/agents-desktop/src/ui/application-menu.ts b/packages/agents-desktop/src/ui/application-menu.ts index e70fbc58d0..fec2a089b2 100644 --- a/packages/agents-desktop/src/ui/application-menu.ts +++ b/packages/agents-desktop/src/ui/application-menu.ts @@ -18,6 +18,7 @@ export type ApplicationMenuDeps = { sendCommand: (command: DesktopCommand) => void quitApp: () => Promise showAboutDialog: () => void + checkForUpdates: () => Promise } function windowDisplayLabel(win: BrowserWindow): string { @@ -78,6 +79,10 @@ export function buildApplicationMenuTemplate( label: APP_DISPLAY_NAME, submenu: [ { role: `about` as const }, + { + label: `Check for Updates…`, + click: () => void deps.checkForUpdates(), + }, { type: `separator` as const }, { label: `Settings…`, @@ -213,6 +218,14 @@ export function buildApplicationMenuTemplate( label: `About ${APP_DISPLAY_NAME}`, click: () => deps.showAboutDialog(), }, + ...(!isMac + ? ([ + { + label: `Check for Updates…`, + click: () => void deps.checkForUpdates(), + }, + ] as Array) + : []), { type: `separator` }, { label: `Electric Documentation`, @@ -264,7 +277,7 @@ export function popupApplicationMenuSection( } export function popupAppIconMenu( - deps: Pick, + deps: Pick, win: BrowserWindow, bounds: DesktopMenuPopupBounds ): void { @@ -275,7 +288,7 @@ export function popupAppIconMenu( }, { label: `Check for Updates…`, - enabled: false, + click: () => void deps.checkForUpdates(), }, ]).popup({ window: win, diff --git a/packages/agents-desktop/vite.config.ts b/packages/agents-desktop/vite.config.ts index eb491d56df..5a3fe59bdc 100644 --- a/packages/agents-desktop/vite.config.ts +++ b/packages/agents-desktop/vite.config.ts @@ -9,6 +9,7 @@ const REPO_ROOT = path.resolve(PACKAGE_DIR, `../..`) const MUST_EXTERNALIZE = new Set([ `electron`, + `electron-updater`, `better-sqlite3`, `sqlite-vec`, `canvas`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4c71f32f9..bc213ae547 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1584,6 +1584,9 @@ importers: e2b: specifier: '>=2.0.0' version: 2.25.0 + electron-updater: + specifier: ^6.3.9 + version: 6.8.3 fix-path: specifier: ^4.0.0 version: 4.0.0 @@ -13034,6 +13037,9 @@ packages: electron-to-chromium@1.5.52: resolution: {integrity: sha512-xtoijJTZ+qeucLBDNztDOuQBE1ksqjvNjvqFoST3nGC7fSpqJ+X6BdTBaY5BHG+IhWWmpc6b/KfpeuEDupEPOQ==} + electron-updater@6.8.3: + resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} + electron-winstaller@5.4.0: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} @@ -15707,6 +15713,9 @@ packages: lodash.escape@4.0.1: resolution: {integrity: sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flattendeep@4.4.0: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} @@ -19513,6 +19522,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} @@ -31770,7 +31782,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 + semver: 7.7.4 ts-api-utils: 2.1.0(typescript@5.6.3) typescript: 5.6.3 transitivePeerDependencies: @@ -31786,7 +31798,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 + semver: 7.7.4 ts-api-utils: 2.1.0(typescript@5.7.2) typescript: 5.7.2 transitivePeerDependencies: @@ -31802,7 +31814,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 + semver: 7.7.4 ts-api-utils: 2.1.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -34898,6 +34910,19 @@ snapshots: electron-to-chromium@1.5.52: {} + electron-updater@6.8.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron-winstaller@5.4.0: dependencies: '@electron/asar': 3.4.1 @@ -38460,6 +38485,8 @@ snapshots: lodash.escape@4.0.1: {} + lodash.escaperegexp@4.1.2: {} + lodash.flattendeep@4.4.0: {} lodash.includes@4.3.0: {} @@ -43542,6 +43569,8 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-typed-emitter@2.1.0: {} + tiny-warning@1.0.3: {} tinybench@2.9.0: {}